Initial commit: Node-RED MCP server (Python/FastMCP)

22-tool MCP server for Node-RED flow management with Pydantic models,
incremental flow patching, layout linting, and flexible auth (token,
basic, OAuth2). 137 tests, full ruff compliance.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Joe Carter
2026-02-24 09:19:33 +00:00
commit 764d123fdb
34 changed files with 5923 additions and 0 deletions

View File

343
src/nodered_mcp/client.py Normal file
View File

@@ -0,0 +1,343 @@
"""Node-RED HTTP API client."""
import time
from typing import Any
import httpx
from nodered_mcp.config import get_config
class NodeRedClient:
"""Async HTTP client for Node-RED Admin HTTP API.
Supports three authentication methods:
- token: Static bearer token (default)
- basic: HTTP Basic auth (for forward auth proxies)
- oauth: OAuth2 client credentials grant with auto-refresh
"""
def __init__(
self,
base_url: str,
api_version: str = "v1",
auth_method: str = "token",
token: str = "",
auth_username: str = "",
auth_password: str = "",
oauth_token_url: str = "",
oauth_client_id: str = "",
) -> None:
"""Initialize the Node-RED client.
Args:
base_url: Base URL of the Node-RED instance (e.g. http://localhost:1880)
api_version: API version header value (default: v1)
auth_method: Authentication method - "token", "basic", or "oauth"
token: Bearer token for auth_method="token"
auth_username: Username for auth_method="basic" or "oauth"
auth_password: Password/app password for auth_method="basic" or "oauth"
oauth_token_url: OAuth2 token endpoint URL for auth_method="oauth"
oauth_client_id: OAuth2 client ID for auth_method="oauth"
"""
self._auth_method = auth_method
headers: dict[str, str] = {"Node-RED-API-Version": api_version}
auth: httpx.BasicAuth | None = None
if auth_method == "token" and token:
headers["Authorization"] = f"Bearer {token}"
elif auth_method == "basic":
auth = httpx.BasicAuth(auth_username, auth_password)
elif auth_method == "oauth":
self._oauth_token_url = oauth_token_url
self._oauth_client_id = oauth_client_id
self._auth_username = auth_username
self._auth_password = auth_password
self._oauth_token: str | None = None
self._oauth_expiry: float = 0.0
self._http = httpx.AsyncClient(
base_url=base_url,
headers=headers,
auth=auth,
timeout=30.0,
)
async def _ensure_oauth_token(self) -> None:
"""Fetch or refresh OAuth2 token via client credentials grant."""
if self._oauth_token and time.time() < self._oauth_expiry - 30:
return
async with httpx.AsyncClient(timeout=10.0) as auth_client:
resp = await auth_client.post(
self._oauth_token_url,
data={
"grant_type": "client_credentials",
"client_id": self._oauth_client_id,
"username": self._auth_username,
"password": self._auth_password,
},
)
if resp.status_code != 200:
raise RuntimeError(
f"OAuth token error {resp.status_code}: {resp.text}"
)
token_data = resp.json()
self._oauth_token = token_data["access_token"]
self._oauth_expiry = time.time() + token_data.get("expires_in", 600)
self._http.headers["Authorization"] = f"Bearer {self._oauth_token}"
async def _request(
self,
method: str,
path: str,
data: Any = None,
headers: dict[str, str] | None = None,
) -> Any:
"""Centralized HTTP request handler.
Args:
method: HTTP method (GET, POST, PUT, DELETE)
path: API path (e.g. /flows)
data: Optional request body (will be JSON-encoded)
headers: Optional per-request headers to merge
Returns:
Parsed JSON response, or None for 204 No Content
Raises:
RuntimeError: On HTTP error status
"""
if self._auth_method == "oauth":
await self._ensure_oauth_token()
request_headers = headers or {}
response = await self._http.request(
method,
path,
json=data,
headers=request_headers,
)
# OAuth 401 retry: token may have been revoked server-side
if response.status_code == 401 and self._auth_method == "oauth":
self._oauth_token = None
await self._ensure_oauth_token()
response = await self._http.request(
method,
path,
json=data,
headers=request_headers,
)
if response.status_code == 204:
return None
if response.status_code >= 400:
raise RuntimeError(
f"Node-RED API error {response.status_code}: {response.text}"
)
return response.json()
async def close(self) -> None:
"""Close the HTTP client."""
await self._http.aclose()
# Flow endpoints
async def get_flows(self) -> list[dict]:
"""Get all flows (flat array of tabs/subflows/nodes).
Returns:
List of flow objects (heterogeneous: tabs, subflows, nodes)
"""
return await self._request("GET", "/flows")
async def update_flows(
self, flows: list[dict], deployment_type: str = "full"
) -> None:
"""Update all flows with deployment type.
Args:
flows: Full flow configuration
deployment_type: Deployment type (full, nodes, flows, reload)
"""
headers = {"Node-RED-Deployment-Type": deployment_type}
await self._request("POST", "/flows", data=flows, headers=headers)
async def get_flow(self, flow_id: str) -> dict:
"""Get a single flow by ID.
Args:
flow_id: Flow ID
Returns:
Flow object with nodes, configs, subflows
"""
return await self._request("GET", f"/flow/{flow_id}")
async def update_flow(self, flow_id: str, flow: dict) -> None:
"""Update a single flow.
Args:
flow_id: Flow ID
flow: Flow configuration
"""
await self._request("PUT", f"/flow/{flow_id}", data=flow)
async def create_flow(self, flow: dict) -> dict:
"""Create a new flow.
Args:
flow: Flow configuration
Returns:
Created flow with id field
"""
return await self._request("POST", "/flow", data=flow)
async def delete_flow(self, flow_id: str) -> None:
"""Delete a flow.
Args:
flow_id: Flow ID
"""
await self._request("DELETE", f"/flow/{flow_id}")
async def get_flows_state(self) -> dict:
"""Get the current flows runtime state.
Returns:
State object with state field
"""
return await self._request("GET", "/flows/state")
async def set_flows_state(self, state: str) -> dict:
"""Set the flows runtime state.
Args:
state: Target state (start, stop)
Returns:
New state object
"""
return await self._request("POST", "/flows/state", data={"state": state})
# Node endpoints
async def get_nodes(self) -> list[dict]:
"""Get list of installed node modules.
Returns:
List of NodeSet objects
"""
return await self._request("GET", "/nodes")
async def get_node_info(self, module: str) -> dict:
"""Get info about a specific node module.
Args:
module: Module name
Returns:
NodeModule object
"""
return await self._request("GET", f"/nodes/{module}")
async def toggle_node_module(self, module: str, enabled: bool) -> dict:
"""Enable or disable a node module.
Args:
module: Module name
enabled: True to enable, False to disable
Returns:
Updated NodeModule object
"""
return await self._request("PUT", f"/nodes/{module}", data={"enabled": enabled})
async def inject(self, node_id: str) -> None:
"""Trigger an inject node.
Args:
node_id: Node ID to inject
"""
await self._request("POST", f"/inject/{node_id}")
# Settings endpoints
async def get_settings(self) -> dict:
"""Get Node-RED runtime settings.
Returns:
Settings object
"""
return await self._request("GET", "/settings")
async def get_diagnostics(self) -> dict:
"""Get Node-RED diagnostics information.
Returns:
Diagnostics object (structure varies)
"""
return await self._request("GET", "/diagnostics")
def _build_client(config: Any) -> NodeRedClient:
"""Build a NodeRedClient from a config instance."""
return NodeRedClient(
base_url=config.url,
api_version=config.api_version,
auth_method=config.auth_method,
token=config.token,
auth_username=config.auth_username,
auth_password=config.auth_password,
oauth_token_url=config.oauth_token_url,
oauth_client_id=config.oauth_client_id,
)
# Global client instance
_client: NodeRedClient | None = None
def get_client() -> NodeRedClient:
"""Get the global NodeRedClient instance.
Returns:
Singleton NodeRedClient instance.
"""
global _client
if _client is None:
_client = _build_client(get_config())
return _client
async def reset_client() -> NodeRedClient:
"""Reset and return a fresh client instance (for testing).
Closes the old client if it exists to prevent connection leaks.
Returns:
New NodeRedClient instance.
"""
global _client
if _client is not None:
await _client.close()
_client = _build_client(get_config())
return _client
def initialize_client(config: Any) -> NodeRedClient:
"""Explicit client initialization with a config instance.
Args:
config: Config instance with url, token, api_version, auth fields
Returns:
Initialized NodeRedClient instance.
"""
global _client
_client = _build_client(config)
return _client

58
src/nodered_mcp/config.py Normal file
View File

@@ -0,0 +1,58 @@
"""Configuration management for Node-RED MCP."""
from pydantic_settings import BaseSettings, SettingsConfigDict
class Config(BaseSettings):
"""Node-RED MCP configuration from environment variables.
Environment variables use NODE_RED_ prefix:
NODE_RED_URL, NODE_RED_TOKEN, NODE_RED_API_VERSION
"""
url: str = "http://localhost:1880"
token: str = ""
api_version: str = "v1"
# Auth method: "token" (default), "basic", "oauth"
auth_method: str = "token"
# For basic and oauth: username
auth_username: str = ""
# For basic and oauth: password (app password)
auth_password: str = ""
# For oauth: token endpoint URL
oauth_token_url: str = ""
# For oauth: client ID
oauth_client_id: str = ""
model_config = SettingsConfigDict(
env_prefix="NODE_RED_",
case_sensitive=False,
)
# Global config instance
_config: Config | None = None
def get_config() -> Config:
"""Get the global config instance.
Returns:
Singleton Config instance.
"""
global _config
if _config is None:
_config = Config()
return _config
def reset_config() -> Config:
"""Reset and return a fresh config instance (for testing).
Returns:
New Config instance.
"""
global _config
_config = Config()
return _config

View File

View File

@@ -0,0 +1,38 @@
"""Base API model with generic from_api() class method."""
from typing import Any, Self, overload
from pydantic import BaseModel, ConfigDict
class BaseApiModel(BaseModel):
"""Base model for all API response objects.
Provides a generic from_api() that delegates to model_validate().
Provider-specific subclasses override field mapping via
@field_validator(mode='before') and @model_validator(mode='before').
"""
model_config = ConfigDict(populate_by_name=True)
@overload
@classmethod
def from_api(cls, data: dict[str, Any]) -> Self: ...
@overload
@classmethod
def from_api(cls, data: list[dict[str, Any]]) -> list[Self]: ...
@classmethod
def from_api(cls, data: dict[str, Any] | list[dict[str, Any]]) -> "Self | list[Self]":
"""Create model instance(s) from API response data.
Args:
data: Single dict or list of dicts from provider API.
Returns:
Single model or list of models.
"""
if isinstance(data, list):
return [cls.model_validate(item) for item in data]
return cls.model_validate(data)

View File

@@ -0,0 +1,48 @@
"""Flow-related models for Node-RED."""
from pydantic import ConfigDict, Field
from nodered_mcp.models.base import BaseApiModel
class Node(BaseApiModel):
"""Node-RED node.
Node-RED nodes have many type-specific fields that vary by node type,
so we use extra="allow" to capture them without strict modeling.
"""
model_config = ConfigDict(populate_by_name=True, extra="allow")
id: str = ""
type: str = ""
z: str = "" # parent flow ID
name: str = ""
wires: list[list[str]] = Field(default_factory=list)
x: int = 0
y: int = 0
class FlowTab(BaseApiModel):
"""Flow tab (workspace) in Node-RED."""
id: str = ""
label: str = ""
disabled: bool = False
info: str = ""
class Flow(BaseApiModel):
"""Complete flow representation."""
id: str = ""
label: str = ""
nodes: list[Node] = Field(default_factory=list)
configs: list[Node] = Field(default_factory=list)
subflows: list[dict] = Field(default_factory=list)
class FlowState(BaseApiModel):
"""Flow state (start/stop)."""
state: str = ""

View File

@@ -0,0 +1,20 @@
"""Layout lint result models for Node-RED flow visualization."""
from nodered_mcp.models.base import BaseApiModel
class LayoutIssue(BaseApiModel):
"""A single layout issue found by the linter."""
severity: str = "" # "error" | "warning"
rule: str = "" # "overlap" | "spacing" | "wire_crossing"
message: str = ""
node_ids: list[str] = []
class LayoutReport(BaseApiModel):
"""Layout lint report for a flow."""
issues: list[LayoutIssue] = []
nodes_checked: int = 0
summary: str = ""

View File

@@ -0,0 +1,24 @@
"""Node module models for Node-RED."""
from pydantic import Field
from nodered_mcp.models.base import BaseApiModel
class NodeSet(BaseApiModel):
"""Node-RED node set (collection of node types in a module)."""
id: str = ""
name: str = ""
types: list[str] = Field(default_factory=list)
enabled: bool = True
module: str = ""
version: str = ""
class NodeModule(BaseApiModel):
"""Node-RED module containing node sets."""
name: str = ""
version: str = ""
nodes: list[NodeSet] = Field(default_factory=list)

View File

@@ -0,0 +1,52 @@
"""Response models for MCP tools.
All MCP tools must return Pydantic models, never dicts.
These models wrap paginated and structured responses.
"""
from typing import Any
from pydantic import Field
from nodered_mcp.models.base import BaseApiModel
class FlowList(BaseApiModel):
"""List of flows with summary statistics."""
flows: list[Any] = Field(default_factory=list) # raw flow data (heterogeneous)
tabs: list[Any] = Field(default_factory=list) # raw tab data
summary: str = ""
statistics: dict = Field(default_factory=dict)
class FlowSummary(BaseApiModel):
"""Formatted flow summary with grouped data."""
summary: str = ""
statistics: dict = Field(default_factory=dict)
data: dict = Field(default_factory=dict)
class FlowCreateResult(BaseApiModel):
"""Result of flow creation."""
id: str = ""
class Settings(BaseApiModel):
"""Node-RED settings."""
http_node_root: str = Field("", validation_alias="httpNodeRoot")
version: str = ""
user: dict | None = None
class DiagnosticsResult(BaseApiModel):
"""Diagnostics result.
Thin wrapper - diagnostics response structure is too variable
for strict modeling.
"""
data: dict = Field(default_factory=dict)

43
src/nodered_mcp/server.py Normal file
View File

@@ -0,0 +1,43 @@
"""FastMCP server for Node-RED operations."""
import logging
from fastmcp import FastMCP
from nodered_mcp.client import initialize_client
from nodered_mcp.config import get_config
logger = logging.getLogger(__name__)
# Create the MCP server
mcp = FastMCP(
name="nodered-mcp",
instructions="""Node-RED MCP server providing comprehensive access to Node-RED flows, nodes, and settings.
Supports reading/updating flows, managing nodes, runtime control, and diagnostics.
Responses are optimized for AI consumption with structured data and clear error messages.""",
)
# Load configuration and initialize client
config = get_config()
initialize_client(config)
logger.info(
"Initialized Node-RED client for %s (API version: %s)",
config.url,
config.api_version,
)
# Import all tool modules - tools are registered via @mcp.tool() decorators
from nodered_mcp.tools import flows # noqa: F401, E402
from nodered_mcp.tools import nodes # noqa: F401, E402
from nodered_mcp.tools import settings # noqa: F401, E402
from nodered_mcp.tools import utility # noqa: F401, E402
from nodered_mcp.tools import layout # noqa: F401, E402
def main():
"""Run the MCP server."""
mcp.run()
if __name__ == "__main__":
main()

View File

View File

@@ -0,0 +1,365 @@
"""Flow management tools for Node-RED."""
import json
from nodered_mcp.client import get_client
from nodered_mcp.models.flow import Flow, FlowState, FlowTab
from nodered_mcp.models.responses import FlowCreateResult, FlowList, FlowSummary
from nodered_mcp.server import mcp
def _build_flow_list(raw_flows: list[dict]) -> FlowList:
"""Build FlowList with summary and statistics from raw flow data."""
tabs = [f for f in raw_flows if f.get("type") == "tab"]
nodes = [f for f in raw_flows if f.get("type") not in ("tab", "subflow")]
subflows = [f for f in raw_flows if f.get("type") == "subflow"]
node_types: dict[str, int] = {}
for node in nodes:
node_type = node.get("type", "")
node_types[node_type] = node_types.get(node_type, 0) + 1
summary = f"Node-RED project: {len(tabs)} tabs, {len(nodes)} nodes, {len(subflows)} subflows"
stats = {
"tabCount": len(tabs),
"nodeCount": len(nodes),
"subflowCount": len(subflows),
"nodeTypes": node_types,
}
return FlowList(flows=raw_flows, tabs=tabs, summary=summary, statistics=stats)
def _build_flow_summary(raw_flows: list[dict]) -> FlowSummary:
"""Build FlowSummary with grouped data and statistics."""
tabs = [f for f in raw_flows if f.get("type") == "tab"]
nodes = [f for f in raw_flows if f.get("type") not in ("tab", "subflow")]
subflows = [f for f in raw_flows if f.get("type") == "subflow"]
node_types: dict[str, int] = {}
for node in nodes:
node_type = node.get("type", "")
node_types[node_type] = node_types.get(node_type, 0) + 1
summary = f"Node-RED project: {len(tabs)} tabs, {len(nodes)} nodes, {len(subflows)} subflows"
stats = {
"tabCount": len(tabs),
"nodeCount": len(nodes),
"subflowCount": len(subflows),
"nodeTypes": node_types,
}
data = {"tabs": tabs, "nodes": nodes, "subflows": subflows}
return FlowSummary(summary=summary, statistics=stats, data=data)
@mcp.tool()
async def get_flows() -> FlowList:
"""Get all flows with summary statistics.
Returns:
FlowList with flows grouped into tabs/nodes/subflows and summary stats.
"""
client = get_client()
raw_flows = await client.get_flows()
return _build_flow_list(raw_flows)
@mcp.tool()
async def update_flows(flows_json: str, deployment_type: str = "full") -> str:
"""Update all flows with deployment type.
Args:
flows_json: Flow configuration as JSON string
deployment_type: Deployment type - valid values: full, nodes, flows, reload (default: full)
Returns:
Confirmation message.
"""
client = get_client()
flows = json.loads(flows_json)
await client.update_flows(flows, deployment_type=deployment_type)
return f"Flows updated with deployment type: {deployment_type}"
@mcp.tool()
async def get_flow(flow_id: str) -> Flow:
"""Get a single flow by ID.
Args:
flow_id: Flow ID
Returns:
Flow with nodes, configs, and subflows.
"""
client = get_client()
raw_flow = await client.get_flow(flow_id)
return Flow.from_api(raw_flow)
@mcp.tool()
async def update_flow(flow_id: str, flow_json: str) -> str:
"""Update a single flow.
Args:
flow_id: Flow ID
flow_json: Flow configuration as JSON string
Returns:
Confirmation message.
"""
client = get_client()
flow = json.loads(flow_json)
await client.update_flow(flow_id, flow)
result = f"Flow {flow_id} updated"
# Auto-lint: re-fetch the flow and check layout
from nodered_mcp.tools.layout import _lint_nodes, format_lint_report
raw_flow = await client.get_flow(flow_id)
report = _lint_nodes(raw_flow.get("nodes", []))
result += format_lint_report(report)
return result
@mcp.tool()
async def list_tabs() -> list[FlowTab]:
"""List all flow tabs.
Returns:
List of FlowTab objects (workspaces).
"""
client = get_client()
raw_flows = await client.get_flows()
tabs = [f for f in raw_flows if f.get("type") == "tab"]
return [FlowTab.from_api(t) for t in tabs]
@mcp.tool()
async def create_flow(flow_json: str) -> FlowCreateResult:
"""Create a new flow.
Args:
flow_json: Flow configuration as JSON string
Returns:
FlowCreateResult with the new flow ID.
"""
client = get_client()
flow = json.loads(flow_json)
result = await client.create_flow(flow)
return FlowCreateResult.from_api(result)
@mcp.tool()
async def delete_flow(flow_id: str) -> str:
"""Delete a flow.
Args:
flow_id: Flow ID to delete
Returns:
Confirmation message.
"""
client = get_client()
await client.delete_flow(flow_id)
return f"Flow {flow_id} deleted"
@mcp.tool()
async def get_flows_state() -> FlowState:
"""Get the current flows runtime state.
Returns:
FlowState with current state (start/stop).
"""
client = get_client()
raw_state = await client.get_flows_state()
return FlowState.from_api(raw_state)
@mcp.tool()
async def set_flows_state(state: str) -> FlowState:
"""Set the flows runtime state.
Args:
state: Target state - valid values: start, stop
Returns:
FlowState with new state.
"""
client = get_client()
raw_state = await client.set_flows_state(state)
return FlowState.from_api(raw_state)
@mcp.tool()
async def get_flows_formatted() -> FlowSummary:
"""Get flows with formatted summary and grouped data.
Returns:
FlowSummary with tabs/nodes/subflows grouped and counted.
"""
client = get_client()
raw_flows = await client.get_flows()
return _build_flow_summary(raw_flows)
@mcp.tool()
async def visualize_flows() -> str:
"""Visualize flow structure as markdown with per-tab breakdown.
Returns:
Markdown-formatted flow structure showing node counts and types per tab.
"""
client = get_client()
raw_flows = await client.get_flows()
tabs = [f for f in raw_flows if f.get("type") == "tab"]
nodes_by_tab: dict[str, list[dict]] = {}
for tab in tabs:
tab_id = tab.get("id", "")
nodes_by_tab[tab_id] = [f for f in raw_flows if f.get("z") == tab_id]
output = ["# Node-RED Flow Structure", "", "## Tabs", ""]
for tab in tabs:
tab_id = tab.get("id", "")
tab_name = tab.get("label") or tab.get("name") or "Unnamed"
nodes = nodes_by_tab.get(tab_id, [])
node_types: dict[str, int] = {}
for node in nodes:
node_type = node.get("type", "")
node_types[node_type] = node_types.get(node_type, 0) + 1
node_types_str = ", ".join(f"{t}: {c}" for t, c in node_types.items())
output.append(f"### {tab_name} (ID: {tab_id})")
output.append(f"- Number of nodes: {len(nodes)}")
output.append(f"- Node types: {node_types_str}")
output.append("")
return "\n".join(output)
def _apply_patch_ops(flow: dict, operations: list[dict]) -> tuple[dict, list[str]]:
"""Apply patch operations to a flow dict.
Returns:
Tuple of (modified_flow, log_messages).
"""
log: list[str] = []
for op_def in operations:
op = op_def.get("op", "")
if op == "remove_nodes":
ids_to_remove = set(op_def["ids"])
before = len(flow.get("nodes", []))
flow["nodes"] = [n for n in flow.get("nodes", []) if n.get("id") not in ids_to_remove]
removed = before - len(flow["nodes"])
# Clean wires referencing removed nodes
for node in flow.get("nodes", []):
if "wires" in node:
node["wires"] = [
[w for w in output if w not in ids_to_remove]
for output in node["wires"]
]
log.append(f"removed {removed} nodes")
elif op == "add_nodes":
nodes = op_def["nodes"]
flow_id = flow.get("id", "")
for node in nodes:
node.setdefault("z", flow_id)
flow.setdefault("nodes", []).extend(nodes)
log.append(f"added {len(nodes)} nodes")
elif op == "update_node":
target_id = op_def["id"]
fields = op_def["set"]
for node in flow.get("nodes", []):
if node.get("id") == target_id:
node.update(fields)
log.append(f"updated node {target_id}")
break
else:
log.append(f"node {target_id} not found")
elif op == "rewire":
target_id = op_def["id"]
output_idx = op_def["output"]
targets = op_def["targets"]
mode = op_def.get("mode", "replace")
for node in flow.get("nodes", []):
if node.get("id") == target_id:
wires = node.setdefault("wires", [])
while len(wires) <= output_idx:
wires.append([])
if mode == "append":
wires[output_idx] = [
w for w in wires[output_idx] if w not in targets
] + targets
else:
wires[output_idx] = targets
log.append(f"rewired node {target_id} output {output_idx}")
break
else:
log.append(f"node {target_id} not found")
elif op == "set_label":
old = flow.get("label", "")
flow["label"] = op_def["label"]
log.append(f"label: '{old}' -> '{op_def['label']}'")
elif op == "set_info":
flow["info"] = op_def["info"]
log.append("info updated")
elif op == "set_disabled":
flow["disabled"] = op_def["disabled"]
state = "disabled" if op_def["disabled"] else "enabled"
log.append(f"flow {state}")
else:
raise ValueError(f"Unknown patch op: {op}")
return flow, log
@mcp.tool()
async def patch_flow(flow_id: str, operations: str) -> str:
"""Apply incremental patch operations to a flow.
Fetches the current flow, applies operations, and updates it.
Much more efficient than update_flow for small changes.
Args:
flow_id: Flow ID to patch
operations: JSON array of patch operations. Supported ops:
- {"op": "remove_nodes", "ids": ["nodeId1", ...]}
- {"op": "add_nodes", "nodes": [{node}, ...]}
- {"op": "update_node", "id": "nodeId", "set": {"name": "New", ...}}
- {"op": "rewire", "id": "nodeId", "output": 0, "targets": ["id1", ...], "mode": "replace|append"}
- {"op": "set_label", "label": "New Name"}
- {"op": "set_info", "info": "Description text"}
- {"op": "set_disabled", "disabled": true}
Returns:
Summary of changes applied.
"""
client = get_client()
raw_flow = await client.get_flow(flow_id)
ops = json.loads(operations)
patched_flow, log = _apply_patch_ops(raw_flow, ops)
await client.update_flow(flow_id, patched_flow)
result = f"Flow {flow_id} patched: " + "; ".join(log)
# Auto-lint: check layout of the patched flow (already in memory)
from nodered_mcp.tools.layout import _lint_nodes, format_lint_report
report = _lint_nodes(patched_flow.get("nodes", []))
result += format_lint_report(report)
return result

View File

@@ -0,0 +1,217 @@
"""Layout linting tools for Node-RED flows."""
import json
from nodered_mcp.client import get_client
from nodered_mcp.models.layout import LayoutIssue, LayoutReport
from nodered_mcp.server import mcp
# Node-RED default node dimensions (px)
DEFAULT_NODE_WIDTH = 120
DEFAULT_NODE_HEIGHT = 30
MIN_SPACING = 20 # minimum gap between nodes (matches Node-RED grid)
def _is_placed(node: dict) -> bool:
"""Check if a node has a meaningful position (not unplaced/config)."""
x = node.get("x", 0)
y = node.get("y", 0)
return not (x == 0 and y == 0)
def _node_label(node: dict) -> str:
"""Get a human-readable label for a node."""
return node.get("name") or node.get("type") or node.get("id", "?")
def _segment_intersects_aabb(
x1: float, y1: float, x2: float, y2: float,
bx: float, by: float, bw: float, bh: float,
) -> bool:
"""Test if line segment (x1,y1)→(x2,y2) intersects axis-aligned bounding box.
The box is centered at (bx, by) with half-widths bw and bh.
Uses parametric clipping (Liang-Barsky).
"""
dx = x2 - x1
dy = y2 - y1
# Box edges relative to segment origin
p = [-dx, dx, -dy, dy]
q = [
x1 - (bx - bw),
(bx + bw) - x1,
y1 - (by - bh),
(by + bh) - y1,
]
t_enter = 0.0
t_exit = 1.0
for pi, qi in zip(p, q):
if pi == 0:
if qi < 0:
return False
else:
t = qi / pi
if pi < 0:
t_enter = max(t_enter, t)
else:
t_exit = min(t_exit, t)
if t_enter > t_exit:
return False
return True
def _lint_nodes(nodes: list[dict], node_ids: list[str] | None = None) -> LayoutReport:
"""Run layout lint checks on a list of raw node dicts.
Args:
nodes: Raw node dicts (from client).
node_ids: Optional list of node IDs to scope checks to.
When provided, only pairs involving at least one specified node
are checked, and only wires from/to specified nodes are checked.
Returns:
LayoutReport with any issues found.
"""
# Filter to placed nodes only
placed = [n for n in nodes if _is_placed(n)]
scope = set(node_ids) if node_ids else None
issues: list[LayoutIssue] = []
overlap_pairs: set[tuple[str, str]] = set()
hw = DEFAULT_NODE_WIDTH / 2
hh = DEFAULT_NODE_HEIGHT / 2
# Pairwise checks: overlap and spacing
for i, a in enumerate(placed):
aid = a.get("id", "")
ax, ay = a.get("x", 0), a.get("y", 0)
for b in placed[i + 1:]:
bid = b.get("id", "")
# If scoped, at least one node must be in scope
if scope and aid not in scope and bid not in scope:
continue
bx, by = b.get("x", 0), b.get("y", 0)
dx = abs(ax - bx)
dy = abs(ay - by)
# Overlap: bounding boxes intersect
if dx < DEFAULT_NODE_WIDTH and dy < DEFAULT_NODE_HEIGHT:
pair = (min(aid, bid), max(aid, bid))
overlap_pairs.add(pair)
issues.append(LayoutIssue(
severity="error",
rule="overlap",
message=(
f'"{_node_label(a)}" and "{_node_label(b)}" overlap '
f"at ({ax}, {ay}) — separate by at least "
f"{DEFAULT_NODE_WIDTH}px horizontally or "
f"{DEFAULT_NODE_HEIGHT}px vertically"
),
node_ids=[aid, bid],
))
# Spacing: too close but not overlapping
elif dx < DEFAULT_NODE_WIDTH + MIN_SPACING and dy < DEFAULT_NODE_HEIGHT + MIN_SPACING:
gap = min(
max(dx - DEFAULT_NODE_WIDTH, 0),
max(dy - DEFAULT_NODE_HEIGHT, 0),
)
issues.append(LayoutIssue(
severity="warning",
rule="spacing",
message=(
f'"{_node_label(a)}" and "{_node_label(b)}" are only '
f"{gap}px apart at ({ax}, {ay})"
),
node_ids=[aid, bid],
))
# Wire-through-node check
node_by_id = {n.get("id", ""): n for n in placed}
for node in placed:
nid = node.get("id", "")
if scope and nid not in scope:
continue
for output_wires in node.get("wires", []):
for target_id in output_wires:
target = node_by_id.get(target_id)
if not target:
continue
# Segment from source right edge to target left edge
sx = node.get("x", 0) + hw
sy = node.get("y", 0)
tx = target.get("x", 0) - hw
ty = target.get("y", 0)
# Check against all other placed nodes
for other in placed:
oid = other.get("id", "")
if oid in (nid, target_id):
continue
ox = other.get("x", 0)
oy = other.get("y", 0)
if _segment_intersects_aabb(sx, sy, tx, ty, ox, oy, hw, hh):
issues.append(LayoutIssue(
severity="warning",
rule="wire_crossing",
message=(
f'Wire from "{_node_label(node)}" to '
f'"{_node_label(target)}" passes through '
f'"{_node_label(other)}"'
),
node_ids=[nid, target_id, oid],
))
# Build summary
error_count = sum(1 for i in issues if i.severity == "error")
warning_count = sum(1 for i in issues if i.severity == "warning")
parts = []
if error_count:
parts.append(f"{error_count} error{'s' if error_count != 1 else ''}")
if warning_count:
parts.append(f"{warning_count} warning{'s' if warning_count != 1 else ''}")
summary = ", ".join(parts) if parts else "no issues"
return LayoutReport(
issues=issues,
nodes_checked=len(placed),
summary=summary,
)
def format_lint_report(report: LayoutReport) -> str:
"""Format a LayoutReport as a human-readable string for appending to tool output."""
if not report.issues:
return ""
lines = [f"\nLayout ({report.summary}):"]
for issue in report.issues:
lines.append(f"- [{issue.severity}] {issue.rule}: {issue.message}")
return "\n".join(lines)
@mcp.tool()
async def lint_flow(flow_id: str, node_ids: str | None = None) -> LayoutReport:
"""Lint a flow's node layout for overlaps, spacing, and wire crossings.
Args:
flow_id: Flow ID to lint.
node_ids: Optional JSON array of node IDs to scope the check.
Returns:
LayoutReport with issues, nodes checked count, and summary.
"""
client = get_client()
raw_flow = await client.get_flow(flow_id)
nodes = raw_flow.get("nodes", [])
scoped_ids = json.loads(node_ids) if node_ids else None
return _lint_nodes(nodes, scoped_ids)

View File

@@ -0,0 +1,111 @@
"""Node management tools for Node-RED."""
import json
from nodered_mcp.client import get_client
from nodered_mcp.models.node import NodeModule, NodeSet
from nodered_mcp.models.flow import Node
from nodered_mcp.server import mcp
@mcp.tool()
async def inject(node_id: str) -> str:
"""Trigger an inject node.
Args:
node_id: Inject node ID to trigger
Returns:
Confirmation message.
"""
client = get_client()
await client.inject(node_id)
return f"Inject node {node_id} triggered"
@mcp.tool()
async def get_nodes() -> list[NodeSet]:
"""Get list of installed node modules.
Returns:
List of NodeSet objects with node types and versions.
"""
client = get_client()
raw_nodes = await client.get_nodes()
return [NodeSet.from_api(n) for n in raw_nodes]
@mcp.tool()
async def get_node_info(module: str) -> NodeModule:
"""Get information about a specific node module.
Args:
module: Node module name
Returns:
NodeModule with version and node sets.
"""
client = get_client()
raw_info = await client.get_node_info(module)
return NodeModule.from_api(raw_info)
@mcp.tool()
async def toggle_node_module(module: str, enabled: bool) -> NodeModule:
"""Enable or disable a node module.
Args:
module: Node module name
enabled: True to enable, False to disable
Returns:
Updated NodeModule object.
"""
client = get_client()
raw_module = await client.toggle_node_module(module, enabled)
return NodeModule.from_api(raw_module)
@mcp.tool()
async def find_nodes_by_type(node_type: str) -> list[Node]:
"""Find all nodes of a specific type.
Args:
node_type: Node type to search for
Returns:
List of matching Node objects.
"""
client = get_client()
raw_flows = await client.get_flows()
matches = [
f for f in raw_flows
if f.get("type") == node_type and node_type not in ("tab", "subflow")
]
return [Node.from_api(n) for n in matches]
@mcp.tool()
async def search_nodes(query: str, property: str | None = None) -> list[Node]:
"""Search for nodes by name or properties.
Args:
query: String to search for in node properties
property: Specific property to search (optional, searches all properties if not specified)
Returns:
List of matching Node objects.
"""
client = get_client()
raw_flows = await client.get_flows()
matches = []
for node in raw_flows:
if property:
if property in node and query in str(node.get(property, "")):
matches.append(node)
else:
if query in json.dumps(node):
matches.append(node)
return [Node.from_api(n) for n in matches]

View File

@@ -0,0 +1,29 @@
"""Settings and diagnostics tools for Node-RED."""
from nodered_mcp.client import get_client
from nodered_mcp.models.responses import DiagnosticsResult, Settings
from nodered_mcp.server import mcp
@mcp.tool()
async def get_settings() -> Settings:
"""Get Node-RED runtime settings.
Returns:
Settings with runtime configuration.
"""
client = get_client()
raw_settings = await client.get_settings()
return Settings.from_api(raw_settings)
@mcp.tool()
async def get_diagnostics() -> DiagnosticsResult:
"""Get Node-RED diagnostics information.
Returns:
DiagnosticsResult with system diagnostics data.
"""
client = get_client()
raw_diagnostics = await client.get_diagnostics()
return DiagnosticsResult(data=raw_diagnostics)

View File

@@ -0,0 +1,40 @@
"""Utility tools for Node-RED MCP server."""
from nodered_mcp.server import mcp
@mcp.tool()
async def api_help() -> str:
"""Get Node-RED API endpoint reference with implementation status.
Returns:
Markdown table of API endpoints and their MCP implementation status.
"""
endpoints = [
("GET", "/flows", "Get all flows", True),
("POST", "/flows", "Update all flows", True),
("GET", "/flow/:id", "Get a specific flow", True),
("PUT", "/flow/:id", "Update a specific flow", True),
("DELETE", "/flow/:id", "Delete a specific flow", True),
("POST", "/flow", "Create a new flow", True),
("GET", "/flows/state", "Get the state of flows", True),
("POST", "/flows/state", "Set the state of flows", True),
("GET", "/nodes", "Get list of installed nodes", True),
("POST", "/nodes", "Install a new node module", False),
("GET", "/settings", "Get runtime settings", True),
("GET", "/diagnostics", "Get diagnostics information", True),
("POST", "/inject/:id", "Trigger an inject node", True),
]
output = [
"# Node-RED API Help",
"",
"| Method | Path | Description | Implemented in MCP |",
"|--------|------|-------------|---------------------|",
]
for method, path, description, implemented in endpoints:
status = "" if implemented else ""
output.append(f"| {method} | {path} | {description} | {status} |")
return "\n".join(output)