Node-RED MCP medium: N1 typed errors, N3 FlowVisualization, N4 dynamic dimensions
This commit is contained in:
@@ -6,6 +6,13 @@ from typing import Any
|
||||
import httpx
|
||||
|
||||
from nodered_mcp.config import get_config
|
||||
from nodered_mcp.exceptions import (
|
||||
AuthError,
|
||||
NodeREDError,
|
||||
NotFoundError,
|
||||
UpstreamError,
|
||||
ValidationError,
|
||||
)
|
||||
|
||||
|
||||
class NodeRedClient:
|
||||
@@ -80,7 +87,7 @@ class NodeRedClient:
|
||||
)
|
||||
|
||||
if resp.status_code != 200:
|
||||
raise RuntimeError(
|
||||
raise AuthError(
|
||||
f"OAuth token error {resp.status_code}: {resp.text}"
|
||||
)
|
||||
|
||||
@@ -108,7 +115,11 @@ class NodeRedClient:
|
||||
Parsed JSON response, or None for 204 No Content
|
||||
|
||||
Raises:
|
||||
RuntimeError: On HTTP error status
|
||||
AuthError: On authentication failure (401)
|
||||
NotFoundError: On resource not found (404)
|
||||
ValidationError: On bad request (400)
|
||||
UpstreamError: On server error (5xx)
|
||||
NodeREDError: On other HTTP errors
|
||||
"""
|
||||
if self._auth_method == "oauth":
|
||||
await self._ensure_oauth_token()
|
||||
@@ -136,9 +147,16 @@ class NodeRedClient:
|
||||
return None
|
||||
|
||||
if response.status_code >= 400:
|
||||
raise RuntimeError(
|
||||
f"Node-RED API error {response.status_code}: {response.text}"
|
||||
)
|
||||
msg = f"Node-RED API error {response.status_code}: {response.text}"
|
||||
if response.status_code == 401:
|
||||
raise AuthError(msg)
|
||||
if response.status_code == 404:
|
||||
raise NotFoundError(msg)
|
||||
if response.status_code == 400:
|
||||
raise ValidationError(msg)
|
||||
if response.status_code >= 500:
|
||||
raise UpstreamError(msg)
|
||||
raise NodeREDError(msg)
|
||||
|
||||
return response.json()
|
||||
|
||||
|
||||
26
src/nodered_mcp/exceptions.py
Normal file
26
src/nodered_mcp/exceptions.py
Normal file
@@ -0,0 +1,26 @@
|
||||
"""Typed exceptions for Node-RED MCP."""
|
||||
|
||||
|
||||
class NodeREDError(Exception):
|
||||
"""Base error for Node-RED MCP."""
|
||||
pass
|
||||
|
||||
|
||||
class AuthError(NodeREDError):
|
||||
"""Authentication failure (401)."""
|
||||
pass
|
||||
|
||||
|
||||
class NotFoundError(NodeREDError):
|
||||
"""Resource not found (404)."""
|
||||
pass
|
||||
|
||||
|
||||
class ValidationError(NodeREDError):
|
||||
"""Bad request / validation failure (400)."""
|
||||
pass
|
||||
|
||||
|
||||
class UpstreamError(NodeREDError):
|
||||
"""Node-RED server error (5xx)."""
|
||||
pass
|
||||
@@ -28,6 +28,17 @@ class FlowSummary(BaseApiModel):
|
||||
data: dict = Field(default_factory=dict)
|
||||
|
||||
|
||||
class FlowVisualization(BaseApiModel):
|
||||
"""Structured flow visualization model."""
|
||||
|
||||
tabs: list[dict] = Field(default_factory=list) # tab name, node count, node types
|
||||
total_tabs: int = 0
|
||||
total_nodes: int = 0
|
||||
total_configs: int = 0
|
||||
total_subflows: int = 0
|
||||
generated_at: str = "" # ISO timestamp
|
||||
|
||||
|
||||
class FlowCreateResult(BaseApiModel):
|
||||
"""Result of flow creation."""
|
||||
|
||||
|
||||
@@ -1,384 +0,0 @@
|
||||
"""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.
|
||||
|
||||
Raises:
|
||||
ValueError: If deployment_type is not one of: full, nodes, flows, reload.
|
||||
"""
|
||||
_VALID_DEPLOYMENT_TYPES = ("full", "nodes", "flows", "reload")
|
||||
if deployment_type not in _VALID_DEPLOYMENT_TYPES:
|
||||
raise ValueError(
|
||||
f"Invalid deployment_type '{deployment_type}'. Must be one of: {', '.join(_VALID_DEPLOYMENT_TYPES)}"
|
||||
)
|
||||
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.
|
||||
|
||||
Raises:
|
||||
ValueError: If flow_json is not valid JSON.
|
||||
"""
|
||||
try:
|
||||
flow = json.loads(flow_json)
|
||||
except json.JSONDecodeError as e:
|
||||
raise ValueError(f"Invalid JSON in flow_json: {e}") from e
|
||||
client = get_client()
|
||||
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.
|
||||
|
||||
Raises:
|
||||
ValueError: If state is not "start" or "stop".
|
||||
"""
|
||||
if state not in ("start", "stop"):
|
||||
raise ValueError(f"Invalid state '{state}'. Must be 'start' or 'stop'.")
|
||||
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
|
||||
|
||||
@@ -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", [])
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
import json
|
||||
|
||||
from nodered_mcp.client import get_client
|
||||
from nodered_mcp.exceptions import NodeREDError
|
||||
from nodered_mcp.models.node import NodeModule, NodeSet
|
||||
from nodered_mcp.models.flow import Node
|
||||
from nodered_mcp.server import mcp
|
||||
@@ -18,9 +19,12 @@ async def inject(node_id: str) -> str:
|
||||
Returns:
|
||||
Confirmation message.
|
||||
"""
|
||||
client = get_client()
|
||||
await client.inject(node_id)
|
||||
return f"Inject node {node_id} triggered"
|
||||
try:
|
||||
client = get_client()
|
||||
await client.inject(node_id)
|
||||
return f"Inject node {node_id} triggered"
|
||||
except NodeREDError as e:
|
||||
return json.dumps({"error": True, "message": str(e), "type": type(e).__name__})
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
@@ -30,9 +34,12 @@ async def get_nodes() -> list[NodeSet]:
|
||||
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]
|
||||
try:
|
||||
client = get_client()
|
||||
raw_nodes = await client.get_nodes()
|
||||
return [NodeSet.from_api(n) for n in raw_nodes]
|
||||
except NodeREDError as e:
|
||||
return {"error": True, "message": str(e), "type": type(e).__name__}
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
@@ -45,9 +52,12 @@ async def get_node_info(module: str) -> NodeModule:
|
||||
Returns:
|
||||
NodeModule with version and node sets.
|
||||
"""
|
||||
client = get_client()
|
||||
raw_info = await client.get_node_info(module)
|
||||
return NodeModule.from_api(raw_info)
|
||||
try:
|
||||
client = get_client()
|
||||
raw_info = await client.get_node_info(module)
|
||||
return NodeModule.from_api(raw_info)
|
||||
except NodeREDError as e:
|
||||
return {"error": True, "message": str(e), "type": type(e).__name__}
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
@@ -61,9 +71,12 @@ async def toggle_node_module(module: str, enabled: bool) -> NodeModule:
|
||||
Returns:
|
||||
Updated NodeModule object.
|
||||
"""
|
||||
client = get_client()
|
||||
raw_module = await client.toggle_node_module(module, enabled)
|
||||
return NodeModule.from_api(raw_module)
|
||||
try:
|
||||
client = get_client()
|
||||
raw_module = await client.toggle_node_module(module, enabled)
|
||||
return NodeModule.from_api(raw_module)
|
||||
except NodeREDError as e:
|
||||
return {"error": True, "message": str(e), "type": type(e).__name__}
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
@@ -76,13 +89,16 @@ async def find_nodes_by_type(node_type: str) -> list[Node]:
|
||||
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]
|
||||
try:
|
||||
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]
|
||||
except NodeREDError as e:
|
||||
return {"error": True, "message": str(e), "type": type(e).__name__}
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
@@ -96,16 +112,19 @@ async def search_nodes(query: str, property: str | None = None) -> list[Node]:
|
||||
Returns:
|
||||
List of matching Node objects.
|
||||
"""
|
||||
client = get_client()
|
||||
raw_flows = await client.get_flows()
|
||||
try:
|
||||
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)
|
||||
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]
|
||||
return [Node.from_api(n) for n in matches]
|
||||
except NodeREDError as e:
|
||||
return {"error": True, "message": str(e), "type": type(e).__name__}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"""Settings and diagnostics tools for Node-RED."""
|
||||
|
||||
from nodered_mcp.client import get_client
|
||||
from nodered_mcp.exceptions import NodeREDError
|
||||
from nodered_mcp.models.responses import DiagnosticsResult, Settings
|
||||
from nodered_mcp.server import mcp
|
||||
|
||||
@@ -12,9 +13,12 @@ async def get_settings() -> Settings:
|
||||
Returns:
|
||||
Settings with runtime configuration.
|
||||
"""
|
||||
client = get_client()
|
||||
raw_settings = await client.get_settings()
|
||||
return Settings.from_api(raw_settings)
|
||||
try:
|
||||
client = get_client()
|
||||
raw_settings = await client.get_settings()
|
||||
return Settings.from_api(raw_settings)
|
||||
except NodeREDError as e:
|
||||
return {"error": True, "message": str(e), "type": type(e).__name__}
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
@@ -24,6 +28,9 @@ async def get_diagnostics() -> DiagnosticsResult:
|
||||
Returns:
|
||||
DiagnosticsResult with system diagnostics data.
|
||||
"""
|
||||
client = get_client()
|
||||
raw_diagnostics = await client.get_diagnostics()
|
||||
return DiagnosticsResult(data=raw_diagnostics)
|
||||
try:
|
||||
client = get_client()
|
||||
raw_diagnostics = await client.get_diagnostics()
|
||||
return DiagnosticsResult(data=raw_diagnostics)
|
||||
except NodeREDError as e:
|
||||
return {"error": True, "message": str(e), "type": type(e).__name__}
|
||||
|
||||
@@ -6,6 +6,7 @@ import httpx
|
||||
import pytest
|
||||
|
||||
from nodered_mcp.client import NodeRedClient, get_client, reset_client
|
||||
from nodered_mcp.exceptions import ValidationError, NotFoundError, AuthError, UpstreamError
|
||||
|
||||
|
||||
class TestNodeRedClient:
|
||||
@@ -62,7 +63,7 @@ class TestNodeRedClient:
|
||||
mock_response.text = "Bad Request"
|
||||
|
||||
with patch.object(client._http, "request", return_value=mock_response):
|
||||
with pytest.raises(RuntimeError, match="Node-RED API error 400: Bad Request"):
|
||||
with pytest.raises(ValidationError, match="Node-RED API error 400: Bad Request"):
|
||||
await client._request("GET", "/flows")
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@@ -73,7 +74,7 @@ class TestNodeRedClient:
|
||||
mock_response.text = "Not Found"
|
||||
|
||||
with patch.object(client._http, "request", return_value=mock_response):
|
||||
with pytest.raises(RuntimeError, match="Node-RED API error 404: Not Found"):
|
||||
with pytest.raises(NotFoundError, match="Node-RED API error 404: Not Found"):
|
||||
await client._request("GET", "/flow/nonexistent")
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@@ -84,7 +85,7 @@ class TestNodeRedClient:
|
||||
mock_response.text = "Internal Server Error"
|
||||
|
||||
with patch.object(client._http, "request", return_value=mock_response):
|
||||
with pytest.raises(RuntimeError, match="Node-RED API error 500"):
|
||||
with pytest.raises(UpstreamError, match="Node-RED API error 500"):
|
||||
await client._request("POST", "/flows")
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@@ -367,7 +368,7 @@ class TestOAuthAuth:
|
||||
mock_auth_client.__aexit__ = AsyncMock(return_value=None)
|
||||
mock_auth_cls.return_value = mock_auth_client
|
||||
|
||||
with pytest.raises(RuntimeError, match="OAuth token error 401"):
|
||||
with pytest.raises(AuthError, match="OAuth token error 401"):
|
||||
await client._ensure_oauth_token()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
|
||||
Reference in New Issue
Block a user