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,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()

View 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

View File

@@ -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."""

View File

@@ -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

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", [])

View File

@@ -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__}

View File

@@ -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__}

View File

@@ -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