nodered-mcp
Enables reading, querying, and editing Node-RED flows.json files through MCP, providing tools for node management, layout validation, and deployment.
README
nodered-mcp
<p align="center"> <img src="https://img.shields.io/github/stars/ljmerza/nodered-mcp?style=for-the-badge&label=Stars&color=orange" alt="Stars"> <a href="https://github.com/ljmerza/nodered-mcp/releases/latest"><img src="https://img.shields.io/github/v/release/ljmerza/nodered-mcp?style=for-the-badge&color=purple" alt="Version"></a> <a href="https://pypi.org/project/nodered-mcp/"><img src="https://img.shields.io/pypi/v/nodered-mcp?style=for-the-badge&label=PyPI&color=blue" alt="PyPI"></a> <a href="https://github.com/ljmerza/nodered-mcp/actions/workflows/ci.yml"><img src="https://img.shields.io/github/actions/workflow/status/ljmerza/nodered-mcp/ci.yml?style=for-the-badge&label=CI" alt="CI"></a> <a href="https://github.com/ljmerza/nodered-mcp/blob/main/LICENSE"><img src="https://img.shields.io/github/license/ljmerza/nodered-mcp?style=for-the-badge&label=License&color=green" alt="License"></a> </p>
<p align="center"> <a href="https://www.buymeacoffee.com/JMISm06AD"><img src="https://img.shields.io/badge/Buy%20Me%20A%20Coffee-FFDD00?style=for-the-badge&logo=buy-me-a-coffee&logoColor=black" alt="Buy Me A Coffee"></a> </p>
An MCP server that reads, queries, and edits a Node-RED flows.json.
About
Node-RED stores every flow, node, wire, and group box in one large JSON file.
Editing it by hand, or with jq and sed, is how you end up with dangling
wires, groups whose boxes no longer cover their own nodes, and new nodes stacked
on top of existing ones.
This server exposes that file to an MCP client as a set of tools that understand the format. It knows the difference between a flow node and a config node, it can trace a wire path, and it reproduces the Node-RED editor's own geometry so a group box it draws is the box the editor would have drawn.
It is a port of the flows_util.py / layout_util.py pair used to script
Node-RED changes in a home-automation repo, generalised so the file path,
container name, and restart command are all configuration.
Features
- Read tabs, groups, orphaned nodes, subflows, config nodes and who references them, referenced Home Assistant entities, and wire traces through a flow.
- Create, update, delete, rename, and duplicate nodes. Enable or disable them, wire and unwire them, splice one into an existing wire, or route traffic around it.
- Create, populate, restyle, and delete groups; create, rename, reorder, and delete tabs; import and export node sets.
- Claim empty canvas before creating nodes instead of guessing coordinates, lint the canvas for collisions, repair overlaps, and repack a tab's groups into columns instead of one tall stack.
- Edits accumulate in memory and reach disk only when you ask, so a multi-node
build lands as one unit.
diffshows what they would change before you commit, andundowalks them back one call at a time. - Two guards the underlying scripts never needed: a layout gate that refuses
writes which introduce new collisions, and a staleness check that refuses to
overwrite a
flows.jsonsomeone deployed from the browser.
Requirements
- Python 3.11+
- A
flows.jsonon the local filesystem - Docker on
PATH, used only by thedeploytool, which copies the file into a container and restarts it
Installation
git clone https://github.com/ljmerza/nodered-mcp
cd nodered-mcp
uv sync
Usage
The flows.json path is the only required setting. There is no sensible
default, so the server refuses to start without one.
uv run nodered-mcp --flows-path /path/to/nodered/data/flows.json
Register with an MCP client
{
"mcpServers": {
"nodered": {
"type": "stdio",
"command": "uv",
"args": ["run", "--directory", "/path/to/nodered-mcp", "nodered-mcp"],
"env": {
"NODERED_FLOWS_PATH": "/path/to/nodered/data/flows.json"
}
}
}
}
See .mcp.json.example for a fuller example.
Configuration
Every setting resolves CLI flag > environment variable > default.
| Flag | Environment variable | Default | Purpose |
|---|---|---|---|
--flows-path |
NODERED_FLOWS_PATH |
(required) | Path to flows.json on the host |
--container |
NODERED_CONTAINER |
nodered |
Container name used by deploy |
--container-flows-path |
NODERED_CONTAINER_FLOWS_PATH |
/data/flows.json |
Path to flows.json inside the container |
--restart-cmd |
NODERED_RESTART_CMD |
docker restart <container> |
Restart command; {container} is substituted |
--transport |
NODERED_MCP_TRANSPORT |
stdio |
stdio, http, or sse |
--host / --port |
NODERED_MCP_HOST / NODERED_MCP_PORT |
127.0.0.1 / 8080 |
Bind address for http and sse |
If Node-RED is managed by something other than plain Docker, point
--restart-cmd at it:
NODERED_RESTART_CMD="docker compose restart {container}"
Tools
Eight tools, each dispatching on an op argument.
| Tool | Ops |
|---|---|
nodered_query |
summary, tabs, groups, tab, group, search, ungrouped, orphans, subflows, styles, configs, entities, inspect, connections, trace |
nodered_find_nodes |
Structured search by tab, type, or name substring |
nodered_get_node |
One node's raw JSON plus its wiring context |
nodered_edit |
create_node, create_config_node, update_node, update_many, delete_node, rename_node, set_enabled, duplicate_node, replace_node, wire, unwire, insert_between, bypass, import_nodes, export_group |
nodered_group |
create, add, move_node, delete, rename, set_style, normalize_styles, refit, shift, bounds, decouple |
nodered_tab |
create, rename, delete, reorder, set_enabled, set_info |
nodered_layout |
check, audit, free_region, occupied, arrange, fix |
nodered_session |
status, diff, undo, save, deploy, reload |
A typical build
nodered_query(op="tabs") -> tab ids
nodered_layout(op="free_region", tab_id=TAB, w=800, h=200) -> {"x": 100, "y": 3240}
nodered_edit(op="create_node", tab_id=TAB, node_type="inject",
name="tick", x=100, y=3240) -> node id
nodered_edit(op="create_node", tab_id=TAB, node_type="switch",
name="gate", x=300, y=3240) -> node id
nodered_edit(op="wire", source_id=..., target_id=...)
nodered_group(op="create", name="My Flow", tab_id=TAB, node_ids=[...])
nodered_session(op="save")
Nothing above touches flows.json until the final save.
Arranging a tab
A tab that has been edited for a year drifts into one tall column, because
every routine that needed space took the next free spot underneath everything
else. arrange repacks it.
nodered_layout(op="audit") -> worst tabs first
nodered_layout(op="arrange", tab_id=TAB) -> the plan, nothing moved
nodered_layout(op="arrange", tab_id=TAB, apply=true) -> groups repacked
nodered_session(op="save")
Groups are packed into columns, each one going into whichever column is
currently shortest. The column count is chosen to land the tab's bounding box
nearest target_ratio (1.6 by default, roughly a widescreen viewport). Pass
columns to force it. sort picks the placement order: packed (tallest
first, densest, but it reorders the tab), current (keeps the existing
reading order), or name.
Nodes belonging to no group never move. If the packed block would land on one, the whole block drops below them instead, so a tab with a scratch node parked in the middle arranges around it rather than burying it.
arrange is a dry run unless you pass apply=true, it reports the footprint
it will produce before it produces it, and it goes on the undo stack like any
other edit.
Decoupling groups
A wire that runs from a node in one group to a node in another pins the two
groups to each other: move one and the wire stretches across the tab, so the
groups can no longer be arranged independently. decouple swaps every such
wire for a link out / link in pair -- the wire now stops at the edge of its
own group and is picked up inside the other one.
nodered_group(op="decouple", tab_id=TAB) -> the crossings, nothing changed
nodered_group(op="decouple", tab_id=TAB, apply=true) -> one link pair per crossing
nodered_layout(op="arrange", tab_id=TAB, apply=true) -> now safe to repack
nodered_session(op="save")
One pair per wire, named after the node at the other end (-> compute,
tick ->). The link out goes in a column just right of the source group's
nodes, the link in just left of the target group's, and a group grows by one
column however many wires cross it. Both boxes are refitted afterwards.
Two kinds of wire are left alone. One touching an ungrouped node, because there is no second group to decouple from; and one between a group and its own parent or child, because those move together anyway.
Scope it with tab_id for a whole tab or group_id for the crossings that
touch one group. Like arrange it is a dry run unless you pass apply=true,
and it goes on the undo stack. Widening the boxes can push a group into a
neighbour, which the layout gate would block on save -- the op reports any
overlap it introduces, and arrange repacks them.
How it protects the file
The layout gate
save and deploy lint the canvas before and after your edit, and refuse to
write if the edit introduces a new error-level finding:
| Finding | Severity | Meaning |
|---|---|---|
group-overlap |
error | A group box landed on another group box |
group-escape |
error | A group box no longer covers its own nodes |
stray-in-group |
warning | A node sits inside a group box it isn't a member of |
node-overlap |
warning | Two nodes occupy the same space |
Problems that already existed on disk never block. Only the ones your edit created do. When the gate fires, the fix is usually one of:
nodered_layout(op="free_region")to claim clear canvas, then place therenodered_group(op="refit", group_id=...)to resize a group around its nodesnodered_session(op="save", allow_overlap=true)if the overlap is deliberate
Group geometry is exact: the sizing rules are ported from the Node-RED editor, so a computed box matches what the editor draws. Node geometry is exact apart from label text width, which is approximated from Helvetica metrics. That is why node-level findings are only ever warnings.
The staleness check
Node-RED rewrites flows.json whenever someone presses Deploy in the browser.
The session records (mtime_ns, size) when it loads the file and re-checks
before every write. If the file changed underneath you, the commit is refused
rather than silently reverting that work. Either reload and redo your edits,
or pass force=true.
Nanoseconds rather than os.path.getmtime: a float epoch only resolves to about
a microsecond, so a write landing in the same tick as the load compares equal
and slips past the check.
Standalone use
Both engine modules work as libraries and CLIs, independent of MCP.
uv run python -m nodered_mcp.flows summary --flows-path /path/to/flows.json
uv run python -m nodered_mcp.layout --path /path/to/flows.json --fix boxes,move
from nodered_mcp.flows import Flows
f = Flows("/path/to/flows.json")
ox, oy = f.free_region(tab_id, w=1600, h=300)
f.create_node(tab_id, "inject", "tick", x=ox, y=oy)
f.save()
--fix boxesalone makes things worse: refitting grows some boxes so they swallow neighbouring non-member nodes. Runboxes,movetogether, and read the dry run before passing--apply.
Project layout
src/nodered_mcp/
├── server.py FastMCP server: the eight tools
├── session.py in-memory session, stdout capture, staleness guard
├── config.py CLI flags and environment resolution
├── flows.py the Flows class, composed from the mixins below
├── constants.py defaults, the group style, LayoutError
├── reports.py ReadMixin — summary, tab, group, search, trace
├── nodes.py NodeEditMixin — create/update/delete/wire nodes
├── groups.py GroupMixin — create, populate, and delete group boxes
├── tabs.py TabMixin — create, rename, reorder, and delete tabs
├── placement.py LayoutMixin — claim free canvas, refit boxes, arrange tabs
├── transfer.py TransferMixin — import and export node sets
├── persist.py PersistMixin — save, deploy, and the layout gate
└── layout.py canvas geometry and linter, ported from the NR editor
Flows composes the mixins, so the public API stays flat: f.summary(),
f.create_node(), f.free_region(), f.save().
Development
uv sync --group dev
uv run pytest # 93 tests
uv run ruff check .
uv run ruff format --check .
Tests run against a synthetic fixture in tests/fixtures/, never a real flows
file. They cover configuration precedence, the read tools, in-memory-until-save
semantics, the layout gate both blocking and overridden, the staleness guard,
the deploy command sequence, and that no tool writes to stdout: a stray print
would corrupt MCP's stdio framing.
CI runs the same checks through
ljmerza/misc-actions.
Contributing
Issues and pull requests are welcome. Please keep ruff check, ruff format,
and pytest green.
Acknowledgments
- Node-RED: the canvas geometry here is ported from its editor client, so group boxes match what the editor draws.
- FastMCP: the MCP server framework.
License
MIT. See LICENSE.
推荐服务器
Baidu Map
百度地图核心API现已全面兼容MCP协议,是国内首家兼容MCP协议的地图服务商。
Playwright MCP Server
一个模型上下文协议服务器,它使大型语言模型能够通过结构化的可访问性快照与网页进行交互,而无需视觉模型或屏幕截图。
Magic Component Platform (MCP)
一个由人工智能驱动的工具,可以从自然语言描述生成现代化的用户界面组件,并与流行的集成开发环境(IDE)集成,从而简化用户界面开发流程。
Audiense Insights MCP Server
通过模型上下文协议启用与 Audiense Insights 账户的交互,从而促进营销洞察和受众数据的提取和分析,包括人口统计信息、行为和影响者互动。
VeyraX
一个单一的 MCP 工具,连接你所有喜爱的工具:Gmail、日历以及其他 40 多个工具。
graphlit-mcp-server
模型上下文协议 (MCP) 服务器实现了 MCP 客户端与 Graphlit 服务之间的集成。 除了网络爬取之外,还可以将任何内容(从 Slack 到 Gmail 再到播客订阅源)导入到 Graphlit 项目中,然后从 MCP 客户端检索相关内容。
Kagi MCP Server
一个 MCP 服务器,集成了 Kagi 搜索功能和 Claude AI,使 Claude 能够在回答需要最新信息的问题时执行实时网络搜索。
e2b-mcp-server
使用 MCP 通过 e2b 运行代码。
Neon MCP Server
用于与 Neon 管理 API 和数据库交互的 MCP 服务器
Exa MCP Server
模型上下文协议(MCP)服务器允许像 Claude 这样的 AI 助手使用 Exa AI 搜索 API 进行网络搜索。这种设置允许 AI 模型以安全和受控的方式获取实时的网络信息。