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

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

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

0
tests/tools/__init__.py Normal file
View File

409
tests/tools/test_flows.py Normal file
View File

@@ -0,0 +1,409 @@
"""Tests for flow tools."""
import json
import pytest
from nodered_mcp.client import NodeRedClient
@pytest.mark.asyncio
class TestGetFlows:
async def test_get_flows(self, mock_client: NodeRedClient) -> None:
from nodered_mcp.tools.flows import get_flows
result = await get_flows.fn()
# Should return FlowList with flows and tabs separated
from nodered_mcp.models.responses import FlowList
assert isinstance(result, FlowList)
assert len(result.flows) > 0
assert len(result.tabs) > 0
assert result.summary != ""
assert "statistics" in result.__dict__
mock_client.get_flows.assert_called_once()
@pytest.mark.asyncio
class TestUpdateFlows:
async def test_update_flows_default_deployment(self, mock_client: NodeRedClient) -> None:
from nodered_mcp.tools.flows import update_flows
flows_json = json.dumps([{"id": "test", "type": "tab"}])
result = await update_flows.fn(flows_json)
assert isinstance(result, str)
assert "updated" in result.lower() or "success" in result.lower()
mock_client.update_flows.assert_called_once()
# Verify default deployment_type is "full"
call_args = mock_client.update_flows.call_args
assert call_args[1]["deployment_type"] == "full"
async def test_update_flows_custom_deployment(self, mock_client: NodeRedClient) -> None:
from nodered_mcp.tools.flows import update_flows
flows_json = json.dumps([{"id": "test"}])
await update_flows.fn(flows_json, deployment_type="nodes")
call_args = mock_client.update_flows.call_args
assert call_args[1]["deployment_type"] == "nodes"
async def test_update_flows_parses_json(self, mock_client: NodeRedClient) -> None:
from nodered_mcp.tools.flows import update_flows
flows_json = json.dumps([{"id": "flow1"}, {"id": "flow2"}])
await update_flows.fn(flows_json)
# Verify JSON was parsed and passed to client
call_args = mock_client.update_flows.call_args
flows_list = call_args[0][0]
assert isinstance(flows_list, list)
assert len(flows_list) == 2
@pytest.mark.asyncio
class TestGetFlow:
async def test_get_flow(self, mock_client: NodeRedClient) -> None:
from nodered_mcp.tools.flows import get_flow
result = await get_flow.fn("tab1")
from nodered_mcp.models.flow import Flow
assert isinstance(result, Flow)
assert result.id != ""
mock_client.get_flow.assert_called_once_with("tab1")
@pytest.mark.asyncio
class TestUpdateFlow:
async def test_update_flow(self, mock_client: NodeRedClient) -> None:
from nodered_mcp.tools.flows import update_flow
flow_json = json.dumps({"id": "tab1", "label": "Updated"})
result = await update_flow.fn("tab1", flow_json)
assert isinstance(result, str)
assert "updated" in result.lower() or "success" in result.lower()
mock_client.update_flow.assert_called_once()
@pytest.mark.asyncio
class TestListTabs:
async def test_list_tabs(self, mock_client: NodeRedClient) -> None:
from nodered_mcp.tools.flows import list_tabs
result = await list_tabs.fn()
# Should return list of FlowTab (filtered from heterogeneous array)
assert isinstance(result, list)
from nodered_mcp.models.flow import FlowTab
# All items should be FlowTab instances
assert all(isinstance(tab, FlowTab) for tab in result)
# Should have found the tabs from sample data (2 tabs)
assert len(result) == 2
mock_client.get_flows.assert_called_once()
@pytest.mark.asyncio
class TestCreateFlow:
async def test_create_flow(self, mock_client: NodeRedClient) -> None:
from nodered_mcp.tools.flows import create_flow
flow_json = json.dumps({"label": "New Flow", "type": "tab"})
result = await create_flow.fn(flow_json)
from nodered_mcp.models.responses import FlowCreateResult
assert isinstance(result, FlowCreateResult)
assert result.id == "new-flow-id"
mock_client.create_flow.assert_called_once()
@pytest.mark.asyncio
class TestDeleteFlow:
async def test_delete_flow(self, mock_client: NodeRedClient) -> None:
from nodered_mcp.tools.flows import delete_flow
result = await delete_flow.fn("tab1")
assert isinstance(result, str)
assert "deleted" in result.lower() or "success" in result.lower()
mock_client.delete_flow.assert_called_once_with("tab1")
@pytest.mark.asyncio
class TestGetFlowsState:
async def test_get_flows_state(self, mock_client: NodeRedClient) -> None:
from nodered_mcp.tools.flows import get_flows_state
result = await get_flows_state.fn()
from nodered_mcp.models.flow import FlowState
assert isinstance(result, FlowState)
assert result.state != ""
mock_client.get_flows_state.assert_called_once()
@pytest.mark.asyncio
class TestSetFlowsState:
async def test_set_flows_state_start(self, mock_client: NodeRedClient) -> None:
from nodered_mcp.tools.flows import set_flows_state
result = await set_flows_state.fn("start")
from nodered_mcp.models.flow import FlowState
assert isinstance(result, FlowState)
mock_client.set_flows_state.assert_called_once_with("start")
async def test_set_flows_state_stop(self, mock_client: NodeRedClient) -> None:
from nodered_mcp.tools.flows import set_flows_state
await set_flows_state.fn("stop")
mock_client.set_flows_state.assert_called_once_with("stop")
@pytest.mark.asyncio
class TestGetFlowsFormatted:
async def test_get_flows_formatted(self, mock_client: NodeRedClient) -> None:
from nodered_mcp.tools.flows import get_flows_formatted
result = await get_flows_formatted.fn()
from nodered_mcp.models.responses import FlowSummary
assert isinstance(result, FlowSummary)
assert result.summary != ""
assert "statistics" in result.__dict__
# Should have grouped data
assert "data" in result.__dict__
mock_client.get_flows.assert_called_once()
@pytest.mark.asyncio
class TestVisualizeFlows:
async def test_visualize_flows(self, mock_client: NodeRedClient) -> None:
from nodered_mcp.tools.flows import visualize_flows
result = await visualize_flows.fn()
# Should return markdown string
assert isinstance(result, str)
assert len(result) > 0
# Should contain tab names from sample data
assert "Main Flow" in result or "tab" in result.lower()
mock_client.get_flows.assert_called_once()
@pytest.mark.asyncio
class TestPatchFlow:
"""Tests for patch_flow tool and _apply_patch_ops helper."""
def _make_flow(self) -> dict:
"""Build a minimal flow dict for patching tests."""
return {
"id": "flow1",
"label": "Test Flow",
"info": "",
"disabled": False,
"nodes": [
{"id": "n1", "type": "inject", "z": "flow1", "name": "trigger", "wires": [["n2", "n3"]]},
{"id": "n2", "type": "function", "z": "flow1", "name": "process", "wires": [["n3"]]},
{"id": "n3", "type": "debug", "z": "flow1", "name": "output", "wires": []},
],
"configs": [],
"subflows": [],
}
async def test_remove_nodes(self, mock_client: NodeRedClient) -> None:
from nodered_mcp.tools.flows import patch_flow
flow = self._make_flow()
mock_client.get_flow.return_value = flow
ops = json.dumps([{"op": "remove_nodes", "ids": ["n3"]}])
result = await patch_flow.fn("flow1", ops)
assert "removed 1 nodes" in result
call_args = mock_client.update_flow.call_args
patched = call_args[0][1]
node_ids = [n["id"] for n in patched["nodes"]]
assert "n3" not in node_ids
assert len(patched["nodes"]) == 2
async def test_remove_nodes_wire_cleanup(self, mock_client: NodeRedClient) -> None:
from nodered_mcp.tools.flows import patch_flow
flow = self._make_flow()
mock_client.get_flow.return_value = flow
ops = json.dumps([{"op": "remove_nodes", "ids": ["n2"]}])
await patch_flow.fn("flow1", ops)
call_args = mock_client.update_flow.call_args
patched = call_args[0][1]
# n1 originally wired to ["n2", "n3"] — n2 should be stripped
n1 = next(n for n in patched["nodes"] if n["id"] == "n1")
assert n1["wires"] == [["n3"]]
async def test_add_nodes(self, mock_client: NodeRedClient) -> None:
from nodered_mcp.tools.flows import patch_flow
flow = self._make_flow()
mock_client.get_flow.return_value = flow
new_node = {"id": "n4", "type": "debug", "name": "new debug", "wires": []}
ops = json.dumps([{"op": "add_nodes", "nodes": [new_node]}])
result = await patch_flow.fn("flow1", ops)
assert "added 1 nodes" in result
call_args = mock_client.update_flow.call_args
patched = call_args[0][1]
n4 = next(n for n in patched["nodes"] if n["id"] == "n4")
assert n4["z"] == "flow1"
assert len(patched["nodes"]) == 4
async def test_update_node(self, mock_client: NodeRedClient) -> None:
from nodered_mcp.tools.flows import patch_flow
flow = self._make_flow()
mock_client.get_flow.return_value = flow
ops = json.dumps([{"op": "update_node", "id": "n2", "set": {"name": "Renamed", "x": 500}}])
result = await patch_flow.fn("flow1", ops)
assert "updated node n2" in result
call_args = mock_client.update_flow.call_args
patched = call_args[0][1]
n2 = next(n for n in patched["nodes"] if n["id"] == "n2")
assert n2["name"] == "Renamed"
assert n2["x"] == 500
assert n2["type"] == "function" # unchanged
async def test_update_node_not_found(self, mock_client: NodeRedClient) -> None:
from nodered_mcp.tools.flows import patch_flow
flow = self._make_flow()
mock_client.get_flow.return_value = flow
ops = json.dumps([{"op": "update_node", "id": "missing", "set": {"name": "X"}}])
result = await patch_flow.fn("flow1", ops)
assert "not found" in result
async def test_rewire(self, mock_client: NodeRedClient) -> None:
from nodered_mcp.tools.flows import patch_flow
flow = self._make_flow()
mock_client.get_flow.return_value = flow
ops = json.dumps([{"op": "rewire", "id": "n1", "output": 0, "targets": ["n3"]}])
result = await patch_flow.fn("flow1", ops)
assert "rewired node n1 output 0" in result
call_args = mock_client.update_flow.call_args
patched = call_args[0][1]
n1 = next(n for n in patched["nodes"] if n["id"] == "n1")
assert n1["wires"][0] == ["n3"]
async def test_rewire_append(self, mock_client: NodeRedClient) -> None:
from nodered_mcp.tools.flows import patch_flow
flow = self._make_flow()
mock_client.get_flow.return_value = flow
# n1 starts with wires [["n2", "n3"]] — append "n4"
ops = json.dumps([{"op": "rewire", "id": "n1", "output": 0, "targets": ["n4"], "mode": "append"}])
result = await patch_flow.fn("flow1", ops)
assert "rewired node n1 output 0" in result
call_args = mock_client.update_flow.call_args
patched = call_args[0][1]
n1 = next(n for n in patched["nodes"] if n["id"] == "n1")
assert n1["wires"][0] == ["n2", "n3", "n4"]
async def test_rewire_append_deduplicates(self, mock_client: NodeRedClient) -> None:
from nodered_mcp.tools.flows import patch_flow
flow = self._make_flow()
mock_client.get_flow.return_value = flow
# n1 starts with wires [["n2", "n3"]] — append "n2" (already present)
ops = json.dumps([{"op": "rewire", "id": "n1", "output": 0, "targets": ["n2"], "mode": "append"}])
await patch_flow.fn("flow1", ops)
call_args = mock_client.update_flow.call_args
patched = call_args[0][1]
n1 = next(n for n in patched["nodes"] if n["id"] == "n1")
assert n1["wires"][0] == ["n3", "n2"] # n2 moved to end, no duplicate
async def test_set_label(self, mock_client: NodeRedClient) -> None:
from nodered_mcp.tools.flows import patch_flow
flow = self._make_flow()
mock_client.get_flow.return_value = flow
ops = json.dumps([{"op": "set_label", "label": "Office Automation"}])
result = await patch_flow.fn("flow1", ops)
assert "Office Automation" in result
call_args = mock_client.update_flow.call_args
patched = call_args[0][1]
assert patched["label"] == "Office Automation"
async def test_set_info(self, mock_client: NodeRedClient) -> None:
from nodered_mcp.tools.flows import patch_flow
flow = self._make_flow()
mock_client.get_flow.return_value = flow
ops = json.dumps([{"op": "set_info", "info": "Controls lights and fans"}])
result = await patch_flow.fn("flow1", ops)
assert "info updated" in result
call_args = mock_client.update_flow.call_args
patched = call_args[0][1]
assert patched["info"] == "Controls lights and fans"
async def test_set_disabled(self, mock_client: NodeRedClient) -> None:
from nodered_mcp.tools.flows import patch_flow
flow = self._make_flow()
mock_client.get_flow.return_value = flow
ops = json.dumps([{"op": "set_disabled", "disabled": True}])
result = await patch_flow.fn("flow1", ops)
assert "disabled" in result
call_args = mock_client.update_flow.call_args
patched = call_args[0][1]
assert patched["disabled"] is True
async def test_multiple_ops(self, mock_client: NodeRedClient) -> None:
from nodered_mcp.tools.flows import patch_flow
flow = self._make_flow()
mock_client.get_flow.return_value = flow
ops = json.dumps([
{"op": "set_label", "label": "Renamed"},
{"op": "remove_nodes", "ids": ["n3"]},
{"op": "update_node", "id": "n1", "set": {"name": "Start"}},
])
result = await patch_flow.fn("flow1", ops)
assert "Renamed" in result
assert "removed 1" in result
assert "updated node n1" in result
call_args = mock_client.update_flow.call_args
patched = call_args[0][1]
assert patched["label"] == "Renamed"
assert len(patched["nodes"]) == 2
n1 = next(n for n in patched["nodes"] if n["id"] == "n1")
assert n1["name"] == "Start"
async def test_unknown_op(self, mock_client: NodeRedClient) -> None:
from nodered_mcp.tools.flows import patch_flow
flow = self._make_flow()
mock_client.get_flow.return_value = flow
ops = json.dumps([{"op": "explode"}])
with pytest.raises(ValueError, match="Unknown patch op: explode"):
await patch_flow.fn("flow1", ops)

222
tests/tools/test_layout.py Normal file
View File

@@ -0,0 +1,222 @@
"""Tests for layout linting tools."""
import json
import pytest
from nodered_mcp.client import NodeRedClient
from nodered_mcp.models.layout import LayoutIssue, LayoutReport
from nodered_mcp.tools.layout import _lint_nodes, format_lint_report
class TestLintNodesNoIssues:
def test_well_spaced_nodes(self) -> None:
nodes = [
{"id": "n1", "type": "inject", "name": "A", "x": 100, "y": 100, "wires": [["n2"]]},
{"id": "n2", "type": "debug", "name": "B", "x": 400, "y": 100, "wires": []},
]
report = _lint_nodes(nodes)
assert isinstance(report, LayoutReport)
assert report.issues == []
assert report.nodes_checked == 2
assert report.summary == "no issues"
class TestOverlapDetection:
def test_same_position(self) -> None:
nodes = [
{"id": "n1", "type": "inject", "name": "A", "x": 300, "y": 100, "wires": []},
{"id": "n2", "type": "debug", "name": "B", "x": 300, "y": 100, "wires": []},
]
report = _lint_nodes(nodes)
assert len(report.issues) == 1
issue = report.issues[0]
assert issue.severity == "error"
assert issue.rule == "overlap"
assert "n1" in issue.node_ids
assert "n2" in issue.node_ids
assert "1 error" in report.summary
def test_partial_overlap(self) -> None:
# Nodes 50px apart horizontally (< DEFAULT_NODE_WIDTH=120), same y
nodes = [
{"id": "n1", "type": "inject", "name": "A", "x": 100, "y": 100, "wires": []},
{"id": "n2", "type": "debug", "name": "B", "x": 150, "y": 100, "wires": []},
]
report = _lint_nodes(nodes)
overlap_issues = [i for i in report.issues if i.rule == "overlap"]
assert len(overlap_issues) == 1
class TestSpacingDetection:
def test_nodes_too_close(self) -> None:
# 130px apart horizontally: > 120 (no overlap) but < 140 (120+20 spacing)
# Same y so dy=0 < 30+20=50
nodes = [
{"id": "n1", "type": "inject", "name": "A", "x": 100, "y": 100, "wires": []},
{"id": "n2", "type": "debug", "name": "B", "x": 230, "y": 100, "wires": []},
]
report = _lint_nodes(nodes)
spacing_issues = [i for i in report.issues if i.rule == "spacing"]
assert len(spacing_issues) == 1
assert spacing_issues[0].severity == "warning"
assert "n1" in spacing_issues[0].node_ids
assert "n2" in spacing_issues[0].node_ids
def test_no_double_report_with_overlap(self) -> None:
# Same position: should only get overlap, not also spacing
nodes = [
{"id": "n1", "type": "inject", "name": "A", "x": 100, "y": 100, "wires": []},
{"id": "n2", "type": "debug", "name": "B", "x": 100, "y": 100, "wires": []},
]
report = _lint_nodes(nodes)
spacing_issues = [i for i in report.issues if i.rule == "spacing"]
assert len(spacing_issues) == 0
class TestWireCrossing:
def test_wire_through_node(self) -> None:
# A at x=100 wires to C at x=500; B sits at x=300 in the middle, same y
nodes = [
{"id": "A", "type": "inject", "name": "A", "x": 100, "y": 100, "wires": [["C"]]},
{"id": "B", "type": "function", "name": "B", "x": 300, "y": 100, "wires": []},
{"id": "C", "type": "debug", "name": "C", "x": 500, "y": 100, "wires": []},
]
report = _lint_nodes(nodes)
wire_issues = [i for i in report.issues if i.rule == "wire_crossing"]
assert len(wire_issues) >= 1
issue = wire_issues[0]
assert "B" in issue.node_ids
assert issue.severity == "warning"
def test_no_crossing_when_wire_avoids_node(self) -> None:
# A wires to C; B is far off the path
nodes = [
{"id": "A", "type": "inject", "name": "A", "x": 100, "y": 100, "wires": [["C"]]},
{"id": "B", "type": "function", "name": "B", "x": 300, "y": 500, "wires": []},
{"id": "C", "type": "debug", "name": "C", "x": 500, "y": 100, "wires": []},
]
report = _lint_nodes(nodes)
wire_issues = [i for i in report.issues if i.rule == "wire_crossing"]
assert len(wire_issues) == 0
class TestScopedCheck:
def test_node_ids_filter(self) -> None:
# n1 and n2 overlap, n3 is well-separated
nodes = [
{"id": "n1", "type": "inject", "name": "A", "x": 100, "y": 100, "wires": []},
{"id": "n2", "type": "debug", "name": "B", "x": 100, "y": 100, "wires": []},
{"id": "n3", "type": "function", "name": "C", "x": 500, "y": 500, "wires": []},
]
# Scope to n3 only — should find no issues since n3 doesn't overlap with anyone
report = _lint_nodes(nodes, node_ids=["n3"])
assert report.issues == []
def test_scoped_finds_relevant_issues(self) -> None:
nodes = [
{"id": "n1", "type": "inject", "name": "A", "x": 100, "y": 100, "wires": []},
{"id": "n2", "type": "debug", "name": "B", "x": 100, "y": 100, "wires": []},
]
# Scope to n1 — should still find the overlap with n2
report = _lint_nodes(nodes, node_ids=["n1"])
assert len(report.issues) == 1
assert report.issues[0].rule == "overlap"
class TestSkipsUnplacedNodes:
def test_nodes_at_origin_skipped(self) -> None:
nodes = [
{"id": "n1", "type": "inject", "name": "A", "x": 0, "y": 0, "wires": []},
{"id": "n2", "type": "debug", "name": "B", "x": 0, "y": 0, "wires": []},
]
report = _lint_nodes(nodes)
assert report.issues == []
assert report.nodes_checked == 0
def test_config_nodes_without_position_skipped(self) -> None:
nodes = [
{"id": "n1", "type": "mqtt-broker", "name": "MQTT"},
{"id": "n2", "type": "inject", "name": "A", "x": 100, "y": 100, "wires": []},
]
report = _lint_nodes(nodes)
assert report.issues == []
assert report.nodes_checked == 1
class TestFormatLintReport:
def test_empty_report(self) -> None:
report = LayoutReport(issues=[], nodes_checked=5, summary="no issues")
assert format_lint_report(report) == ""
def test_report_with_issues(self) -> None:
report = LayoutReport(
issues=[
LayoutIssue(
severity="error",
rule="overlap",
message='"A" and "B" overlap',
node_ids=["n1", "n2"],
),
],
nodes_checked=2,
summary="1 error",
)
formatted = format_lint_report(report)
assert "Layout (1 error):" in formatted
assert "[error] overlap:" in formatted
@pytest.mark.asyncio
class TestLintFlowTool:
async def test_lint_flow_returns_report(self, mock_client: NodeRedClient) -> None:
from nodered_mcp.tools.layout import lint_flow
result = await lint_flow.fn("tab1")
assert isinstance(result, LayoutReport)
mock_client.get_flow.assert_called_once_with("tab1")
async def test_lint_flow_with_node_ids(self, mock_client: NodeRedClient) -> None:
from nodered_mcp.tools.layout import lint_flow
mock_client.get_flow.return_value = {
"id": "tab1",
"nodes": [
{"id": "n1", "type": "inject", "name": "A", "x": 100, "y": 100, "wires": []},
{"id": "n2", "type": "debug", "name": "B", "x": 100, "y": 100, "wires": []},
],
"configs": [],
"subflows": [],
}
# Scoped to n1 — should find overlap
result = await lint_flow.fn("tab1", node_ids=json.dumps(["n1"]))
assert len(result.issues) == 1
async def test_lint_flow_detects_overlap(self, mock_client: NodeRedClient) -> None:
from nodered_mcp.tools.layout import lint_flow
mock_client.get_flow.return_value = {
"id": "flow1",
"nodes": [
{"id": "n1", "type": "inject", "name": "A", "x": 200, "y": 200, "wires": []},
{"id": "n2", "type": "debug", "name": "B", "x": 200, "y": 200, "wires": []},
],
"configs": [],
"subflows": [],
}
result = await lint_flow.fn("flow1")
assert len(result.issues) == 1
assert result.issues[0].rule == "overlap"
assert result.issues[0].severity == "error"

140
tests/tools/test_nodes.py Normal file
View File

@@ -0,0 +1,140 @@
"""Tests for node tools."""
import pytest
from nodered_mcp.client import NodeRedClient
@pytest.mark.asyncio
class TestInject:
async def test_inject(self, mock_client: NodeRedClient) -> None:
from nodered_mcp.tools.nodes import inject
result = await inject.fn("inject1")
# Should return confirmation string
assert isinstance(result, str)
assert "inject" in result.lower() or "triggered" in result.lower()
mock_client.inject.assert_called_once_with("inject1")
@pytest.mark.asyncio
class TestGetNodes:
async def test_get_nodes(self, mock_client: NodeRedClient) -> None:
from nodered_mcp.tools.nodes import get_nodes
result = await get_nodes.fn()
# Should return list of NodeSet
assert isinstance(result, list)
from nodered_mcp.models.node import NodeSet
assert all(isinstance(ns, NodeSet) for ns in result)
assert len(result) > 0
mock_client.get_nodes.assert_called_once()
@pytest.mark.asyncio
class TestGetNodeInfo:
async def test_get_node_info(self, mock_client: NodeRedClient) -> None:
from nodered_mcp.tools.nodes import get_node_info
result = await get_node_info.fn("node-red-contrib-test")
# Should return NodeModule
from nodered_mcp.models.node import NodeModule
assert isinstance(result, NodeModule)
assert result.name != ""
mock_client.get_node_info.assert_called_once_with("node-red-contrib-test")
@pytest.mark.asyncio
class TestToggleNodeModule:
async def test_toggle_node_module_enable(self, mock_client: NodeRedClient) -> None:
from nodered_mcp.tools.nodes import toggle_node_module
result = await toggle_node_module.fn("node-red-contrib-test", enabled=True)
# Should return NodeModule (actual API response)
from nodered_mcp.models.node import NodeModule
assert isinstance(result, NodeModule)
mock_client.toggle_node_module.assert_called_once_with(
"node-red-contrib-test", True
)
async def test_toggle_node_module_disable(self, mock_client: NodeRedClient) -> None:
from nodered_mcp.tools.nodes import toggle_node_module
result = await toggle_node_module.fn("node-red-contrib-test", enabled=False)
from nodered_mcp.models.node import NodeModule
assert isinstance(result, NodeModule)
mock_client.toggle_node_module.assert_called_once_with(
"node-red-contrib-test", False
)
@pytest.mark.asyncio
class TestFindNodesByType:
async def test_find_nodes_by_type(self, mock_client: NodeRedClient) -> None:
from nodered_mcp.tools.nodes import find_nodes_by_type
result = await find_nodes_by_type.fn("inject")
# Should return list of Node (filtered from flow data)
assert isinstance(result, list)
from nodered_mcp.models.flow import Node
assert all(isinstance(n, Node) for n in result)
# Sample data has 2 inject nodes
assert len(result) == 2
assert all(n.type == "inject" for n in result)
mock_client.get_flows.assert_called_once()
async def test_find_nodes_by_type_no_matches(self, mock_client: NodeRedClient) -> None:
from nodered_mcp.tools.nodes import find_nodes_by_type
result = await find_nodes_by_type.fn("nonexistent-type")
assert isinstance(result, list)
assert len(result) == 0
@pytest.mark.asyncio
class TestSearchNodes:
async def test_search_nodes_by_name(self, mock_client: NodeRedClient) -> None:
from nodered_mcp.tools.nodes import search_nodes
result = await search_nodes.fn("Test")
# Should return list of Node matching query
assert isinstance(result, list)
from nodered_mcp.models.flow import Node
assert all(isinstance(n, Node) for n in result)
# Should find nodes with "Test" in name
assert len(result) > 0
mock_client.get_flows.assert_called_once()
async def test_search_nodes_by_type(self, mock_client: NodeRedClient) -> None:
from nodered_mcp.tools.nodes import search_nodes
result = await search_nodes.fn("inject")
# Should match nodes with "inject" in type or name
assert isinstance(result, list)
assert len(result) > 0
async def test_search_nodes_no_matches(self, mock_client: NodeRedClient) -> None:
from nodered_mcp.tools.nodes import search_nodes
result = await search_nodes.fn("NONEXISTENT_QUERY_STRING")
assert isinstance(result, list)
assert len(result) == 0
async def test_search_nodes_case_insensitive(self, mock_client: NodeRedClient) -> None:
from nodered_mcp.tools.nodes import search_nodes
result = await search_nodes.fn("test")
# Should match regardless of case
assert isinstance(result, list)
assert len(result) > 0

View File

@@ -0,0 +1,53 @@
"""Tests for settings tools."""
import pytest
from nodered_mcp.client import NodeRedClient
@pytest.mark.asyncio
class TestGetSettings:
async def test_get_settings(self, mock_client: NodeRedClient) -> None:
from nodered_mcp.tools.settings import get_settings
result = await get_settings.fn()
# Should return Settings model
from nodered_mcp.models.responses import Settings
assert isinstance(result, Settings)
assert result.http_node_root != ""
assert result.version != ""
mock_client.get_settings.assert_called_once()
async def test_get_settings_with_user(self, mock_client: NodeRedClient) -> None:
from nodered_mcp.tools.settings import get_settings
result = await get_settings.fn()
# User field should be populated from sample data
assert result.user is not None
assert "username" in result.user
@pytest.mark.asyncio
class TestGetDiagnostics:
async def test_get_diagnostics(self, mock_client: NodeRedClient) -> None:
from nodered_mcp.tools.settings import get_diagnostics
result = await get_diagnostics.fn()
# Should return DiagnosticsResult wrapping data
from nodered_mcp.models.responses import DiagnosticsResult
assert isinstance(result, DiagnosticsResult)
assert isinstance(result.data, dict)
assert len(result.data) > 0
mock_client.get_diagnostics.assert_called_once()
async def test_get_diagnostics_preserves_structure(self, mock_client: NodeRedClient) -> None:
from nodered_mcp.tools.settings import get_diagnostics
result = await get_diagnostics.fn()
# Nested structure from sample_diagnostics_data should be preserved
assert "report" in result.data
assert "versions" in result.data["report"]

View File

@@ -0,0 +1,46 @@
"""Tests for utility tools."""
import pytest
@pytest.mark.asyncio
class TestApiHelp:
async def test_api_help_returns_string(self) -> None:
from nodered_mcp.tools.utility import api_help
result = await api_help.fn()
# Should return markdown/text string
assert isinstance(result, str)
assert len(result) > 0
async def test_api_help_contains_endpoints(self) -> None:
from nodered_mcp.tools.utility import api_help
result = await api_help.fn()
# Should document key API endpoints
assert "/flows" in result
assert "/flow" in result
assert "/nodes" in result
assert "/settings" in result
async def test_api_help_contains_http_methods(self) -> None:
from nodered_mcp.tools.utility import api_help
result = await api_help.fn()
# Should mention HTTP methods
assert "GET" in result
assert "POST" in result or "PUT" in result or "DELETE" in result
async def test_api_help_no_client_call(self, mock_client) -> None:
"""Verify api_help doesn't call the client (it's static content)."""
from nodered_mcp.tools.utility import api_help
await api_help.fn()
# Should not have called any client methods
mock_client.get_flows.assert_not_called()
mock_client.get_settings.assert_not_called()
mock_client.get_nodes.assert_not_called()