diff --git a/src/nodered_mcp/client.py b/src/nodered_mcp/client.py index 1ea3b99..557a472 100644 --- a/src/nodered_mcp/client.py +++ b/src/nodered_mcp/client.py @@ -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() diff --git a/src/nodered_mcp/exceptions.py b/src/nodered_mcp/exceptions.py new file mode 100644 index 0000000..ad7647a --- /dev/null +++ b/src/nodered_mcp/exceptions.py @@ -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 diff --git a/src/nodered_mcp/models/responses.py b/src/nodered_mcp/models/responses.py index 8b6da41..a044beb 100644 --- a/src/nodered_mcp/models/responses.py +++ b/src/nodered_mcp/models/responses.py @@ -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.""" diff --git a/src/nodered_mcp/tools/flows.py b/src/nodered_mcp/tools/flows.py index 4f864d8..e69de29 100644 --- a/src/nodered_mcp/tools/flows.py +++ b/src/nodered_mcp/tools/flows.py @@ -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 diff --git a/src/nodered_mcp/tools/layout.py b/src/nodered_mcp/tools/layout.py index 3ba5ff1..772bb80 100644 --- a/src/nodered_mcp/tools/layout.py +++ b/src/nodered_mcp/tools/layout.py @@ -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", []) diff --git a/src/nodered_mcp/tools/nodes.py b/src/nodered_mcp/tools/nodes.py index 9d87529..9882772 100644 --- a/src/nodered_mcp/tools/nodes.py +++ b/src/nodered_mcp/tools/nodes.py @@ -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__} diff --git a/src/nodered_mcp/tools/settings.py b/src/nodered_mcp/tools/settings.py index 6726c0a..a885646 100644 --- a/src/nodered_mcp/tools/settings.py +++ b/src/nodered_mcp/tools/settings.py @@ -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__} diff --git a/tests/test_client.py b/tests/test_client.py index 95dcc8b..c31b450 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -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