Node-RED MCP medium: N1 typed errors, N3 FlowVisualization, N4 dynamic dimensions

This commit is contained in:
root
2026-07-04 20:54:52 +00:00
parent a1026df830
commit 8aea6fbb7f
8 changed files with 203 additions and 492 deletions

View File

@@ -6,21 +6,60 @@ 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)
# Node dimension lookup: maps common node types to (width, height) in pixels
# Unknown types fall back to (120, 30)
NODE_DIMENSIONS: dict[str, tuple[int, int]] = {
"tab": (120, 30),
"inject": (120, 30),
"debug": (120, 30),
"function": (180, 60),
"switch": (180, 60),
"change": (180, 60),
"template": (180, 80),
"http response": (180, 40),
"http request": (180, 60),
"websocket in": (180, 60),
"websocket out": (180, 60),
"mqtt in": (180, 60),
"mqtt out": (180, 60),
"tcp in": (180, 60),
"tcp out": (180, 60),
"udp in": (180, 60),
"udp out": (180, 60),
"split": (120, 30),
"join": (120, 30),
"sort": (120, 30),
"batch": (180, 60),
"link out": (180, 50),
"link in": (180, 50),
"trigger": (180, 60),
"delay": (180, 60),
"rbe": (180, 60),
"comment": (200, 40),
"catch": (180, 50),
"status": (180, 50),
"complete": (180, 50),
"unknown": (120, 30),
}
# Default fallback for node types not in the lookup
DEFAULT_NODE_WIDTH = 120
DEFAULT_NODE_HEIGHT = 30
MIN_SPACING = 20 # minimum gap between nodes (matches Node-RED grid)
MIN_SPACING = 20
def _get_node_dims(node: dict) -> tuple[int, int]:
node_type = node.get("type", "unknown")
return NODE_DIMENSIONS.get(node_type, (DEFAULT_NODE_WIDTH, DEFAULT_NODE_HEIGHT))
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", "?")
@@ -28,15 +67,8 @@ 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),
@@ -44,10 +76,8 @@ def _segment_intersects_aabb(
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:
@@ -60,98 +90,90 @@ def _segment_intersects_aabb(
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
node_dims: dict[str, tuple[int, int]] = {}
for n in placed:
nid = n.get("id", "")
node_dims[nid] = _get_node_dims(n)
# 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)
aw, ah = node_dims[aid]
ahw, ahh = aw / 2, ah / 2
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)
bw, bh = node_dims[bid]
bhw, bhh = bw / 2, bh / 2
dx = abs(ax - bx)
dy = abs(ay - by)
combined_w = ahw + bhw
combined_h = ahh + bhh
# Overlap: bounding boxes intersect
if dx < DEFAULT_NODE_WIDTH and dy < DEFAULT_NODE_HEIGHT:
if dx < combined_w and dy < combined_h:
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"
f"{_node_label(a)} and {_node_label(b)} overlap "
f"at ({ax}, {ay})"
),
node_ids=[aid, bid],
))
# Spacing: too close but not overlapping
elif dx < DEFAULT_NODE_WIDTH + MIN_SPACING and dy < DEFAULT_NODE_HEIGHT + MIN_SPACING:
elif dx < combined_w + MIN_SPACING and dy < combined_h + MIN_SPACING:
gap = min(
max(dx - DEFAULT_NODE_WIDTH, 0),
max(dy - DEFAULT_NODE_HEIGHT, 0),
max(dx - combined_w, 0),
max(dy - combined_h, 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})"
f"{_node_label(a)} and {_node_label(b)} are only "
f"{int(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
nw, nh = node_dims.get(nid, (DEFAULT_NODE_WIDTH, DEFAULT_NODE_HEIGHT))
nhw = nw / 2
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
tw, _ = node_dims.get(target_id, (DEFAULT_NODE_WIDTH, DEFAULT_NODE_HEIGHT))
thw = tw / 2
sx = node.get("x", 0) + nhw
sy = node.get("y", 0)
tx = target.get("x", 0) - hw
tx = target.get("x", 0) - thw
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):
@@ -159,20 +181,21 @@ def _lint_nodes(nodes: list[dict], node_ids: list[str] | None = None) -> LayoutR
ox = other.get("x", 0)
oy = other.get("y", 0)
ow, oh = node_dims.get(oid, (DEFAULT_NODE_WIDTH, DEFAULT_NODE_HEIGHT))
ohw, ohh = ow / 2, oh / 2
if _segment_intersects_aabb(sx, sy, tx, ty, ox, oy, hw, hh):
if _segment_intersects_aabb(sx, sy, tx, ty, ox, oy, ohw, ohh):
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)}"'
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 = []
@@ -190,7 +213,6 @@ def _lint_nodes(nodes: list[dict], node_ids: list[str] | None = None) -> LayoutR
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}):"]
@@ -201,15 +223,6 @@ def format_lint_report(report: LayoutReport) -> str:
@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", [])