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

34
.gitignore vendored Normal file
View File

@@ -0,0 +1,34 @@
# Python
__pycache__/
*.py[cod]
*.egg-info/
dist/
build/
# Virtual environment
.venv/
# IDE
.idea/
.vscode/
*.swp
*.swo
# Environment
.env
# Testing
.pytest_cache/
.coverage
htmlcov/
# uv
.python-version
# Local artifacts
backups/
logs/
node-red-mcp-server/
# OMC state
.omc/

174
CLAUDE.md Normal file
View File

@@ -0,0 +1,174 @@
# CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
## What This Is
Node-RED MCP is a FastMCP server that exposes Node-RED flow management as MCP tools. It communicates with a Node-RED instance via the Admin HTTP API, allowing language models to read, create, update, and delete flows, manage nodes, trigger inject nodes, and inspect runtime settings. Unlike the original TypeScript `node-red-mcp-server`, this is a Python implementation using the mailmcp project conventions (FastMCP, Pydantic models, async httpx client).
## Commands
```bash
make all # format + lint + test
make check # lint + test (no format)
make test # uv run pytest tests/ -v
make lint # uv run ruff check src/ tests/
make format # uv run ruff format src/ tests/
make fix # uv run ruff check --fix --unsafe-fixes src/ tests/
```
Run a single test: `uv run pytest tests/test_models.py -v`
Run with coverage: `uv run pytest tests/ -v --cov=src/nodered_mcp --cov-report=term-missing`
Always use `uv run` to execute Python tools — never bare `python` or `pytest`.
Start the server: `uv run nodered-mcp`
## Architecture
```
src/nodered_mcp/
├── server.py # FastMCP server, config loading, client init, tool imports
├── config.py # Pydantic Settings (env vars: NODE_RED_URL, NODE_RED_TOKEN, NODE_RED_API_VERSION, auth fields)
├── client.py # NodeRedClient: async httpx wrapper with auth
├── models/
│ ├── base.py # BaseApiModel with from_api() classmethod
│ ├── flow.py # Node, FlowTab, Flow, FlowState
│ ├── node.py # NodeModule, NodeSet
│ ├── responses.py # FlowList, FlowSummary, FlowCreateResult, Settings, DiagnosticsResult
│ └── layout.py # LayoutIssue, LayoutReport (lint results)
└── tools/
├── flows.py # 12 flow tools (get_flows, update_flows, list_tabs, etc.) + auto-lint on mutations
├── nodes.py # 6 node tools (inject, get_nodes, find_nodes_by_type, etc.)
├── settings.py # 2 settings tools (get_settings, get_diagnostics)
├── layout.py # 1 layout tool (lint_flow) + pure lint helpers
└── utility.py # 1 api_help tool (static reference)
```
### Layer Diagram
```
Tools (thin @mcp.tool() functions)
Client (async httpx wrapper, returns raw dicts)
Node-RED Admin HTTP API
```
Tools convert raw dicts to Pydantic models using `Model.from_api()`.
### Key Pattern: Model Layering
Base models define the canonical schema. All models inherit from `BaseApiModel`:
```
BaseApiModel (from_api → model_validate)
├── Node (extra="allow" for type-specific fields)
├── FlowTab (tab workspace)
├── Flow (complete flow with nodes/configs/subflows)
├── FlowState (start/stop state)
├── NodeSet (installed node module)
├── NodeModule (node module info)
├── Response wrappers (FlowList, FlowSummary, Settings, DiagnosticsResult, etc.)
└── Layout models (LayoutIssue, LayoutReport)
```
Tools accept/return base types. No provider hierarchy needed (single backend).
### Key Pattern: Client Singleton
`NodeRedClient` is a global singleton managed by `get_client()` / `reset_client()`:
```python
# Tools call get_client()
client = get_client()
data = await client.get_flows()
return FlowList.from_api(data)
# Tests call reset_client() to get fresh instances
await reset_client() # closes old client to prevent connection leaks
```
Similar to mailmcp's `get_provider_manager()` pattern, but simpler (no provider abstraction).
### Key Pattern: Authentication
Three auth methods configured via `NODE_RED_AUTH_METHOD`:
| Method | Env Vars | Use Case |
|--------|----------|----------|
| `token` (default) | `NODE_RED_TOKEN` | Static bearer token |
| `basic` | `NODE_RED_AUTH_USERNAME`, `NODE_RED_AUTH_PASSWORD` | Forward auth proxy (e.g. authentik) |
| `oauth` | `NODE_RED_AUTH_USERNAME`, `NODE_RED_AUTH_PASSWORD`, `NODE_RED_OAUTH_TOKEN_URL`, `NODE_RED_OAUTH_CLIENT_ID` | OAuth2 client credentials grant |
- **token**: Sets `Authorization: Bearer {token}` header (existing behavior, backward compatible)
- **basic**: Uses `httpx.BasicAuth` — proxy validates credentials, sets cookies
- **oauth**: Lazy token fetch via client credentials grant, cached with 30s expiry buffer, single 401 retry
### Key Pattern: Heterogeneous Flow Arrays
`GET /flows` returns a flat array mixing tabs (`type=tab`), subflows (`type=subflow`), and nodes (all other types). **Tools must filter by `type` before parsing**:
```python
# Correct
flows_data = await client.get_flows()
tabs = [item for item in flows_data if item.get("type") == "tab"]
return FlowTab.from_api(tabs)
# Wrong - crashes on non-tab items
flows_data = await client.get_flows()
return FlowTab.from_api(flows_data) # ERROR
```
Client returns the raw `list[dict]`; type-aware parsing happens in tool helpers.
## Tool List (22 tools)
### Flow Tools (12)
1. `get_flows` — GET /flows → FlowList
2. `update_flows` — POST /flows (deployment_type header)
3. `get_flow` — GET /flow/:id → Flow
4. `update_flow` — PUT /flow/:id → str
5. `list_tabs` — GET /flows, filter type=tab → list[FlowTab]
6. `create_flow` — POST /flow → FlowCreateResult
7. `delete_flow` — DELETE /flow/:id → str
8. `get_flows_state` — GET /flows/state → FlowState
9. `set_flows_state` — POST /flows/state → FlowState
10. `get_flows_formatted` — GET /flows, format client-side → FlowSummary
11. `visualize_flows` — GET /flows, format as markdown → str
12. `patch_flow` — GET /flow/:id, apply ops, PUT /flow/:id → str
### Node Tools (6)
12. `inject` — POST /inject/:id → str
13. `get_nodes` — GET /nodes → list[NodeSet]
14. `get_node_info` — GET /nodes/:module → NodeModule
15. `toggle_node_module` — PUT /nodes/:module → NodeModule
16. `find_nodes_by_type` — GET /flows, filter by type → list[Node]
17. `search_nodes` — GET /flows, search by query → list[Node]
### Settings Tools (2)
18. `get_settings` — GET /settings → Settings
19. `get_diagnostics` — GET /diagnostics → DiagnosticsResult
### Layout Tools (1)
20. `lint_flow` — GET /flow/:id, run layout checks → LayoutReport
### Utility Tools (1)
21. `api_help` — static reference → str
## Conventions (see conventions.md for full details)
- **All models inherit BaseApiModel**, never plain `BaseModel` or raw dicts
- **Never override `from_api()`** — use `validation_alias`, `@field_validator`, `@model_validator` instead
- **Transformation hierarchy**: `validation_alias` (key renames) → `@field_validator` (type conversions) → `@model_validator` (cross-field structural reshaping only)
- **MCP tools return Pydantic models**, raise exceptions on failure (no error dicts). **Exception**: simple confirmation strings for inject, delete, visualize.
- **Tools are thin wrappers** — business logic lives in client or helper functions
- **Client returns raw dicts** — tools convert to models via `Model.from_api()`
- **Heterogeneous flow arrays** — filter by `type` before calling `from_api()`
- **Node model uses `extra="allow"`** — Node-RED nodes have many type-specific fields
- **Client singleton** — `get_client()` / `reset_client()` manage global instance; `reset_client()` closes old client
- **Settings uses `validation_alias`** — `http_node_root: str = Field("", validation_alias="httpNodeRoot")`
## Quality Gates
All must pass: `ruff check`, `pytest` (22 tools covered), server starts cleanly via `uv run nodered-mcp`.

17
Makefile Normal file
View File

@@ -0,0 +1,17 @@
.PHONY: all check test lint format fix
all: format lint test
check: lint test
test:
uv run pytest tests/ -v
lint:
uv run ruff check src/ tests/
format:
uv run ruff format src/ tests/
fix:
uv run ruff check --fix --unsafe-fixes src/ tests/

260
README.md Normal file
View File

@@ -0,0 +1,260 @@
# nodered-mcp
A Python MCP server for Node-RED flow management, built on [FastMCP](https://github.com/jlowin/fastmcp) and [Pydantic](https://docs.pydantic.dev/).
Gives language models full read/write access to a Node-RED instance through the [Admin HTTP API](https://nodered.org/docs/api/admin/), with structured Pydantic responses, incremental flow patching, layout linting, and flexible authentication (token, basic, OAuth2).
## Compared to node-red-mcp-server
This project was inspired by [karavaev-evgeniy/node-red-mcp-server](https://github.com/karavaev-evgeniy/node-red-mcp-server) (TypeScript/npm). Key differences:
| | node-red-mcp-server (TS) | nodered-mcp (Python) |
|---|---|---|
| Runtime | Node.js / npm | Python 3.11+ / uv |
| Framework | Custom MCP SDK | FastMCP |
| Responses | Raw JSON strings | Typed Pydantic models |
| Auth | Token only | Token, Basic, OAuth2 |
| Deployment types | `full` only | `full`, `nodes`, `flows`, `reload` |
| Flow mutations | Replace entire flow | `patch_flow` with granular ops (add/remove/update/rewire) |
| Layout checks | None | `lint_flow` detects overlaps, spacing, wire crossings |
| Auto-lint | N/A | `update_flow` and `patch_flow` append warnings automatically |
| Tool count | 19 | 22 |
## Installation
Requires Python 3.11+ and [uv](https://docs.astral.sh/uv/):
```bash
git clone <repo-url> && cd nodered-mcp
uv sync
```
## Quick Start
```bash
# Minimal — token auth (default)
export NODE_RED_URL=http://localhost:1880
export NODE_RED_TOKEN=your-api-token
uv run nodered-mcp
```
## Configuration
All configuration is via environment variables with the `NODE_RED_` prefix.
| Variable | Default | Description |
|----------|---------|-------------|
| `NODE_RED_URL` | `http://localhost:1880` | Node-RED instance URL |
| `NODE_RED_TOKEN` | `""` | Bearer token (for `token` auth method) |
| `NODE_RED_API_VERSION` | `v1` | API version header |
| `NODE_RED_AUTH_METHOD` | `token` | Auth method: `token`, `basic`, or `oauth` |
| `NODE_RED_AUTH_USERNAME` | `""` | Username for `basic` or `oauth` auth |
| `NODE_RED_AUTH_PASSWORD` | `""` | Password/app password for `basic` or `oauth` auth |
| `NODE_RED_OAUTH_TOKEN_URL` | `""` | OAuth2 token endpoint URL |
| `NODE_RED_OAUTH_CLIENT_ID` | `""` | OAuth2 client ID |
## Authentication
### Token (default)
Static bearer token sent with every request. This is the simplest method and matches how `node-red-mcp-server` works:
```bash
NODE_RED_URL=http://localhost:1880
NODE_RED_TOKEN=your-api-token
```
### Basic Auth
HTTP Basic authentication, useful when Node-RED sits behind a forward auth proxy.
```bash
NODE_RED_AUTH_METHOD=basic
NODE_RED_AUTH_USERNAME=admin
NODE_RED_AUTH_PASSWORD=your-password
```
### Basic Auth with Authentik
A common setup is Node-RED behind [Authentik](https://goauthentik.io/) as a forward auth proxy. Authentik intercepts requests, validates credentials, and sets session cookies before forwarding to Node-RED.
1. **Create an Authentik application** for your Node-RED instance using the Forward Auth (single application) provider.
2. **Configure your reverse proxy** (Traefik, nginx, Caddy) to use Authentik's forward auth endpoint. For example, with Traefik:
```yaml
# docker-compose.yml (Traefik labels on the Node-RED service)
labels:
- "traefik.http.routers.nodered.middlewares=authentik@docker"
```
3. **Create an Authentik service account** (or use an existing user) and generate an app password under the user's token settings. This avoids MFA prompts that would block API access.
4. **Configure nodered-mcp**:
```bash
NODE_RED_URL=https://nodered.yourdomain.com
NODE_RED_AUTH_METHOD=basic
NODE_RED_AUTH_USERNAME=service-account
NODE_RED_AUTH_PASSWORD=the-app-password-from-authentik
```
The basic auth credentials are sent to Authentik's proxy, which validates them and sets cookies. Subsequent requests use the session cookie, so Node-RED itself doesn't need auth enabled.
### OAuth2 Client Credentials
Fetches a short-lived JWT via OAuth2 client credentials grant. The token is cached and auto-refreshed with a 30-second expiry buffer. On a 401 response, the server retries once with a fresh token.
```bash
NODE_RED_AUTH_METHOD=oauth
NODE_RED_AUTH_USERNAME=service-account
NODE_RED_AUTH_PASSWORD=service-password
NODE_RED_OAUTH_TOKEN_URL=https://auth.example.com/application/o/token/
NODE_RED_OAUTH_CLIENT_ID=your-client-id
```
## MCP Client Configuration
### Claude Desktop
```json
{
"mcpServers": {
"nodered": {
"command": "uv",
"args": ["--directory", "/path/to/nodered-mcp", "run", "nodered-mcp"],
"env": {
"NODE_RED_URL": "http://localhost:1880",
"NODE_RED_TOKEN": "your-token"
}
}
}
}
```
### Claude Code
```json
{
"mcpServers": {
"nodered": {
"command": "uv",
"args": ["--directory", "/path/to/nodered-mcp", "run", "nodered-mcp"],
"env": {
"NODE_RED_URL": "http://localhost:1880",
"NODE_RED_TOKEN": "your-token"
}
}
}
}
```
## Tools
### Flow Tools (12)
| Tool | Description |
|------|-------------|
| `get_flows` | Get all flows with summary statistics |
| `update_flows` | Replace all flows with deployment type control (`full`/`nodes`/`flows`/`reload`) |
| `get_flow` | Get a single flow by ID |
| `update_flow` | Update a single flow (auto-lints after save) |
| `list_tabs` | List all flow tabs (workspaces) |
| `create_flow` | Create a new flow tab |
| `delete_flow` | Delete a flow tab |
| `get_flows_state` | Get runtime state (started/stopped) |
| `set_flows_state` | Start or stop the flow runtime |
| `get_flows_formatted` | Get flows grouped by tabs/nodes/subflows with statistics |
| `visualize_flows` | Markdown-formatted per-tab structural overview |
| `patch_flow` | Incremental operations: `add_nodes`, `remove_nodes`, `update_node`, `rewire`, `set_label`, `set_info`, `set_disabled` (auto-lints after save) |
### Node Tools (6)
| Tool | Description |
|------|-------------|
| `inject` | Trigger an inject node |
| `get_nodes` | List installed node modules |
| `get_node_info` | Detailed info about a node module |
| `toggle_node_module` | Enable or disable a node module |
| `find_nodes_by_type` | Find all nodes of a given type |
| `search_nodes` | Search nodes by name or any property |
### Layout Tools (1)
| Tool | Description |
|------|-------------|
| `lint_flow` | Check a flow for node overlaps, insufficient spacing, and wire-through-node crossings. Optionally scoped to specific node IDs. |
### Settings Tools (2)
| Tool | Description |
|------|-------------|
| `get_settings` | Get Node-RED runtime settings |
| `get_diagnostics` | Get runtime diagnostics (Node.js version, OS, modules) |
### Utility Tools (1)
| Tool | Description |
|------|-------------|
| `api_help` | Node-RED Admin API endpoint reference |
## Architecture
```
src/nodered_mcp/
├── server.py # FastMCP server, config, client init
├── config.py # Pydantic Settings (env vars)
├── client.py # Async httpx wrapper with auth
├── models/
│ ├── base.py # BaseApiModel with from_api()
│ ├── flow.py # Node, FlowTab, Flow, FlowState
│ ├── node.py # NodeModule, NodeSet
│ ├── responses.py # FlowList, FlowSummary, Settings, etc.
│ └── layout.py # LayoutIssue, LayoutReport
└── tools/
├── flows.py # Flow CRUD + patch + auto-lint
├── nodes.py # Node management + search
├── layout.py # Layout lint checks
├── settings.py # Settings + diagnostics
└── utility.py # API help reference
```
The layer diagram is simple:
```
MCP Tools (thin async functions, return Pydantic models)
NodeRedClient (async httpx, returns raw dicts)
Node-RED Admin HTTP API
```
## Development
```bash
make all # format + lint + test
make check # lint + test (no format)
make test # uv run pytest tests/ -v
make lint # uv run ruff check src/ tests/
make format # uv run ruff format src/ tests/
```
Run a single test file:
```bash
uv run pytest tests/tools/test_layout.py -v
```
Run with coverage:
```bash
uv run pytest tests/ -v --cov=src/nodered_mcp --cov-report=term-missing
```
Always use `uv run` — never bare `python` or `pytest`.
See [conventions.md](conventions.md) for detailed code patterns (model layering, tool conventions, client architecture).
## License
MIT

352
conventions.md Normal file
View File

@@ -0,0 +1,352 @@
# nodered-mcp Code Conventions
## Model Architecture
### BaseApiModel
All API response models inherit from `BaseApiModel` which provides a single `from_api()` classmethod. This method is **never overridden** - it simply calls `model_validate()`.
```python
class BaseApiModel(BaseModel):
model_config = ConfigDict(populate_by_name=True)
@classmethod
def from_api(cls, data: dict | list) -> Self | list[Self]:
if isinstance(data, list):
return [cls.model_validate(item) for item in data]
return cls.model_validate(data)
```
### Base Models (Thin Data Contracts)
Base models (`Node`, `FlowTab`, `Flow`, `FlowState`, `NodeSet`, `NodeModule`) are pure field definitions with minimal transformation logic. They define the canonical schema for Node-RED API responses.
```python
class FlowTab(BaseApiModel):
id: str = ""
label: str = ""
disabled: bool = False
info: str = ""
```
### Node Model: Permissive Extra Fields
The `Node` model uses `extra="allow"` because Node-RED nodes have many type-specific fields that vary by node type (inject nodes have different fields than function nodes, debug nodes, etc.). Strict modeling would require dozens of subclasses.
```python
class Node(BaseApiModel):
model_config = ConfigDict(populate_by_name=True, extra="allow")
id: str = ""
type: str = ""
z: str = "" # parent flow ID
name: str = ""
wires: list[list[str]] = Field(default_factory=list)
x: int = 0
y: int = 0
# All other type-specific fields captured via extra="allow"
```
### Transformation Layer
Unlike mailmcp (which has provider-specific subclasses), nodered-mcp has a single backend (Node-RED Admin HTTP API). Transformation happens via Pydantic's declarative tools in the base models themselves.
#### 1. `Field(validation_alias=...)` for key renames
Use when the API key is just a different name for the same value (typically camelCase → snake_case).
```python
class Settings(BaseApiModel):
http_node_root: str = Field("", validation_alias="httpNodeRoot")
version: str = ""
user: dict | None = None
```
#### 2. `@field_validator` for individual type conversions
Use when a single field's value needs transformation (type conversion, format parsing, nested object wrapping). Currently not needed in nodered-mcp since the Node-RED API returns well-formed JSON with consistent types.
#### 3. `@model_validator(mode="before")` ONLY for cross-field structural reshaping
Use **only** when the API structure genuinely differs from the model structure in a way that requires cross-field access. Currently not needed in nodered-mcp.
### Response Models
Response wrappers (`FlowList`, `FlowSummary`, `FlowCreateResult`, `Settings`, `DiagnosticsResult`) also inherit `BaseApiModel` and follow the same conventions. Use `validation_alias` for camelCase API keys.
`DiagnosticsResult` is a thin wrapper around `data: dict` because the diagnostics response structure is too variable for strict modeling:
```python
class DiagnosticsResult(BaseApiModel):
"""Diagnostics result.
Thin wrapper - diagnostics response structure is too variable
for strict modeling.
"""
data: dict = Field(default_factory=dict)
```
### Decision Hierarchy
When adding a new field or model, choose the **simplest** tool that works:
1. **Same value, different key?**`Field(validation_alias="apiKey")` — zero code, pure declaration
2. **Reusable type coercion?** → Type alias with `Annotated[T, PlainSerializer(...)]` — defined once, used everywhere
3. **Single field needs transformation?**`@field_validator("field", mode="before")` — isolated, testable
4. **Multiple fields need cross-referencing?**`@model_validator(mode="before")` — minimal reshaping only
5. **Derived from other fields?**`@computed_field`
Example from this project: `Settings.http_node_root` uses `validation_alias="httpNodeRoot"` (step 1) rather than a validator.
**Never** write a model_validator that manually maps every field into a new dict. If you find yourself writing `result["x"] = data.get("x")` for 10+ fields, you're doing it wrong.
---
## MCP Tool Conventions
### Return Pydantic models, never dicts
```python
# Good
return Settings.from_api(data)
# Bad
return {"http_node_root": data["httpNodeRoot"], "version": data["version"]}
```
**Exception**: Simple confirmation strings are allowed for operations with no meaningful response body:
```python
# Acceptable for inject, delete, visualize
return f"Successfully injected node {node_id}"
return "Flow deleted successfully"
return markdown_visualization # str
```
On failure, **raise exceptions** (ValueError, RuntimeError) - don't return error dicts.
### Keep tools thin
Tool functions validate parameters and call a single client method. Business logic (formatting, filtering, aggregation) belongs in helper functions or the client, not the tool function itself.
```python
# Good - tool validates, client does the work, helper formats
@mcp.tool()
async def get_flows_formatted() -> FlowSummary:
"""Get flows with formatted summary and statistics."""
client = get_client()
flows_data = await client.get_flows()
return _format_flows(flows_data) # helper function handles grouping
# Bad - business logic in the tool
@mcp.tool()
async def get_flows_formatted() -> FlowSummary:
client = get_client()
flows_data = await client.get_flows()
# 50 lines of grouping/formatting logic here
```
### Use `Model.from_api()` pattern
```python
# Good - handles both single and list
tabs = FlowTab.from_api(tabs_data)
settings = Settings.from_api(settings_data)
# Bad - manual construction
FlowTab(id=obj["id"], label=obj["label"])
# Bad - manual list comprehension (from_api handles lists)
[FlowTab.from_api(x) for x in items]
```
### Example: get_flows tool
```python
@mcp.tool()
async def get_flows() -> FlowList:
"""Get all flows from Node-RED."""
client = get_client()
flows_data = await client.get_flows()
tabs = [item for item in flows_data if item.get("type") == "tab"]
return FlowList(
flows=flows_data,
tabs=tabs,
summary=f"Found {len(flows_data)} total items, {len(tabs)} tabs",
statistics={"total": len(flows_data), "tabs": len(tabs)}
)
```
---
## Client Architecture
### NodeRedClient: Single Async HTTP Client
Unlike mailmcp (which has a provider abstraction for multiple backends), nodered-mcp has a single backend (Node-RED Admin HTTP API), so we use a single client class instead of a provider hierarchy.
```python
class NodeRedClient:
def __init__(self, base_url: str, token: str = "", api_version: str = "v1"):
headers = {"Node-RED-API-Version": api_version}
if token:
headers["Authorization"] = f"Bearer {token}"
self._http = httpx.AsyncClient(
base_url=base_url,
headers=headers,
timeout=30.0,
)
```
### Centralized Request Handler
All HTTP calls go through `_request()` which handles:
- Auth headers (set on httpx.AsyncClient initialization)
- Per-request header overrides (needed for `Node-RED-Deployment-Type`)
- Error handling (raises RuntimeError on 4xx/5xx)
- JSON parsing (returns parsed response or None for 204)
```python
async def _request(
self,
method: str,
path: str,
data: Any = None,
headers: dict[str, str] | None = None,
) -> Any:
request_headers = headers or {}
response = await self._http.request(method, path, json=data, headers=request_headers)
if response.status_code == 204:
return None
if response.status_code >= 400:
raise RuntimeError(f"Node-RED API error {response.status_code}: {response.text}")
return response.json()
```
### Public Methods Return Raw Dicts
Client methods are thin wrappers around HTTP calls. They return raw `dict` or `list[dict]` - tools convert to models:
```python
# Client returns raw dict
async def get_settings(self) -> dict:
return await self._request("GET", "/settings")
# Tool converts to model
@mcp.tool()
async def get_settings() -> Settings:
client = get_client()
data = await client.get_settings()
return Settings.from_api(data)
```
### Global Singleton Pattern
`get_client()` / `reset_client()` manage a global `_client` instance:
```python
_client: NodeRedClient | None = None
def get_client() -> NodeRedClient:
global _client
if _client is None:
config = get_config()
_client = NodeRedClient(
base_url=config.url,
token=config.token,
api_version=config.api_version,
)
return _client
async def reset_client() -> NodeRedClient:
global _client
if _client is not None:
await _client.close() # prevent connection leaks
config = get_config()
_client = NodeRedClient(...)
return _client
```
**Important**: `reset_client()` calls `await close()` on the old client to prevent connection leaks in tests.
---
## Heterogeneous Flow Arrays
### The Problem
`GET /flows` returns a flat array mixing three types of objects:
- **Tabs** (`type=tab`): Flow workspaces
- **Subflows** (`type=subflow`): Reusable flow templates
- **Nodes** (all other types): Individual nodes
```json
[
{"id": "abc", "type": "tab", "label": "Flow 1"},
{"id": "def", "type": "inject", "z": "abc", "name": "Trigger"},
{"id": "ghi", "type": "debug", "z": "abc", "name": "Output"}
]
```
### The Solution
**Client returns the raw `list[dict]`**. Type-aware parsing happens in tool-level helpers:
```python
# Client - returns heterogeneous array unchanged
async def get_flows(self) -> list[dict]:
return await self._request("GET", "/flows")
# Tool helper - filters by type before parsing
def _get_tabs(flows_data: list[dict]) -> list[FlowTab]:
tab_items = [item for item in flows_data if item.get("type") == "tab"]
return FlowTab.from_api(tab_items)
def _get_nodes_by_type(flows_data: list[dict], node_type: str) -> list[Node]:
node_items = [item for item in flows_data if item.get("type") == node_type]
return Node.from_api(node_items)
```
### Tools Must Filter Before Parsing
**Never** call `FlowTab.from_api()` or `Node.from_api()` on the raw heterogeneous array. Always filter first:
```python
# Good
@mcp.tool()
async def list_tabs() -> list[FlowTab]:
client = get_client()
flows_data = await client.get_flows()
tabs = [item for item in flows_data if item.get("type") == "tab"]
return FlowTab.from_api(tabs)
# Bad - crashes on non-tab items
@mcp.tool()
async def list_tabs() -> list[FlowTab]:
client = get_client()
flows_data = await client.get_flows()
return FlowTab.from_api(flows_data) # ERROR: inject nodes don't have 'label'
```
---
## Quality Gates
All must pass before merge:
```bash
ruff check src/ tests/ # Fast linting
pytest tests/ -v # Tests (all 20 tools covered)
uv run nodered-mcp # Server starts cleanly
```
Run single test: `uv run pytest tests/test_models.py -v`
Always use `uv run` to execute Python tools — never bare `python` or `pytest`.

34
pyproject.toml Normal file
View File

@@ -0,0 +1,34 @@
[project]
name = "nodered-mcp"
version = "0.1.0"
description = "MCP server for Node-RED — AI-optimized interface for flow management"
readme = "README.md"
requires-python = ">=3.11"
dependencies = [
"fastmcp>=2.0.0,<3",
"pydantic>=2.0.0",
"pydantic-settings>=2.0.0",
"httpx>=0.27.0",
]
[project.scripts]
nodered-mcp = "nodered_mcp.server:main"
[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"
[tool.hatch.build.targets.wheel]
packages = ["src/nodered_mcp"]
[tool.ruff]
line-length = 100
target-version = "py311"
[dependency-groups]
dev = [
"pytest>=9.0.0",
"pytest-asyncio>=1.3.0",
"pytest-mock>=3.0.0",
"ruff>=0.15.0",
]

View File

343
src/nodered_mcp/client.py Normal file
View File

@@ -0,0 +1,343 @@
"""Node-RED HTTP API client."""
import time
from typing import Any
import httpx
from nodered_mcp.config import get_config
class NodeRedClient:
"""Async HTTP client for Node-RED Admin HTTP API.
Supports three authentication methods:
- token: Static bearer token (default)
- basic: HTTP Basic auth (for forward auth proxies)
- oauth: OAuth2 client credentials grant with auto-refresh
"""
def __init__(
self,
base_url: str,
api_version: str = "v1",
auth_method: str = "token",
token: str = "",
auth_username: str = "",
auth_password: str = "",
oauth_token_url: str = "",
oauth_client_id: str = "",
) -> None:
"""Initialize the Node-RED client.
Args:
base_url: Base URL of the Node-RED instance (e.g. http://localhost:1880)
api_version: API version header value (default: v1)
auth_method: Authentication method - "token", "basic", or "oauth"
token: Bearer token for auth_method="token"
auth_username: Username for auth_method="basic" or "oauth"
auth_password: Password/app password for auth_method="basic" or "oauth"
oauth_token_url: OAuth2 token endpoint URL for auth_method="oauth"
oauth_client_id: OAuth2 client ID for auth_method="oauth"
"""
self._auth_method = auth_method
headers: dict[str, str] = {"Node-RED-API-Version": api_version}
auth: httpx.BasicAuth | None = None
if auth_method == "token" and token:
headers["Authorization"] = f"Bearer {token}"
elif auth_method == "basic":
auth = httpx.BasicAuth(auth_username, auth_password)
elif auth_method == "oauth":
self._oauth_token_url = oauth_token_url
self._oauth_client_id = oauth_client_id
self._auth_username = auth_username
self._auth_password = auth_password
self._oauth_token: str | None = None
self._oauth_expiry: float = 0.0
self._http = httpx.AsyncClient(
base_url=base_url,
headers=headers,
auth=auth,
timeout=30.0,
)
async def _ensure_oauth_token(self) -> None:
"""Fetch or refresh OAuth2 token via client credentials grant."""
if self._oauth_token and time.time() < self._oauth_expiry - 30:
return
async with httpx.AsyncClient(timeout=10.0) as auth_client:
resp = await auth_client.post(
self._oauth_token_url,
data={
"grant_type": "client_credentials",
"client_id": self._oauth_client_id,
"username": self._auth_username,
"password": self._auth_password,
},
)
if resp.status_code != 200:
raise RuntimeError(
f"OAuth token error {resp.status_code}: {resp.text}"
)
token_data = resp.json()
self._oauth_token = token_data["access_token"]
self._oauth_expiry = time.time() + token_data.get("expires_in", 600)
self._http.headers["Authorization"] = f"Bearer {self._oauth_token}"
async def _request(
self,
method: str,
path: str,
data: Any = None,
headers: dict[str, str] | None = None,
) -> Any:
"""Centralized HTTP request handler.
Args:
method: HTTP method (GET, POST, PUT, DELETE)
path: API path (e.g. /flows)
data: Optional request body (will be JSON-encoded)
headers: Optional per-request headers to merge
Returns:
Parsed JSON response, or None for 204 No Content
Raises:
RuntimeError: On HTTP error status
"""
if self._auth_method == "oauth":
await self._ensure_oauth_token()
request_headers = headers or {}
response = await self._http.request(
method,
path,
json=data,
headers=request_headers,
)
# OAuth 401 retry: token may have been revoked server-side
if response.status_code == 401 and self._auth_method == "oauth":
self._oauth_token = None
await self._ensure_oauth_token()
response = await self._http.request(
method,
path,
json=data,
headers=request_headers,
)
if response.status_code == 204:
return None
if response.status_code >= 400:
raise RuntimeError(
f"Node-RED API error {response.status_code}: {response.text}"
)
return response.json()
async def close(self) -> None:
"""Close the HTTP client."""
await self._http.aclose()
# Flow endpoints
async def get_flows(self) -> list[dict]:
"""Get all flows (flat array of tabs/subflows/nodes).
Returns:
List of flow objects (heterogeneous: tabs, subflows, nodes)
"""
return await self._request("GET", "/flows")
async def update_flows(
self, flows: list[dict], deployment_type: str = "full"
) -> None:
"""Update all flows with deployment type.
Args:
flows: Full flow configuration
deployment_type: Deployment type (full, nodes, flows, reload)
"""
headers = {"Node-RED-Deployment-Type": deployment_type}
await self._request("POST", "/flows", data=flows, headers=headers)
async def get_flow(self, flow_id: str) -> dict:
"""Get a single flow by ID.
Args:
flow_id: Flow ID
Returns:
Flow object with nodes, configs, subflows
"""
return await self._request("GET", f"/flow/{flow_id}")
async def update_flow(self, flow_id: str, flow: dict) -> None:
"""Update a single flow.
Args:
flow_id: Flow ID
flow: Flow configuration
"""
await self._request("PUT", f"/flow/{flow_id}", data=flow)
async def create_flow(self, flow: dict) -> dict:
"""Create a new flow.
Args:
flow: Flow configuration
Returns:
Created flow with id field
"""
return await self._request("POST", "/flow", data=flow)
async def delete_flow(self, flow_id: str) -> None:
"""Delete a flow.
Args:
flow_id: Flow ID
"""
await self._request("DELETE", f"/flow/{flow_id}")
async def get_flows_state(self) -> dict:
"""Get the current flows runtime state.
Returns:
State object with state field
"""
return await self._request("GET", "/flows/state")
async def set_flows_state(self, state: str) -> dict:
"""Set the flows runtime state.
Args:
state: Target state (start, stop)
Returns:
New state object
"""
return await self._request("POST", "/flows/state", data={"state": state})
# Node endpoints
async def get_nodes(self) -> list[dict]:
"""Get list of installed node modules.
Returns:
List of NodeSet objects
"""
return await self._request("GET", "/nodes")
async def get_node_info(self, module: str) -> dict:
"""Get info about a specific node module.
Args:
module: Module name
Returns:
NodeModule object
"""
return await self._request("GET", f"/nodes/{module}")
async def toggle_node_module(self, module: str, enabled: bool) -> dict:
"""Enable or disable a node module.
Args:
module: Module name
enabled: True to enable, False to disable
Returns:
Updated NodeModule object
"""
return await self._request("PUT", f"/nodes/{module}", data={"enabled": enabled})
async def inject(self, node_id: str) -> None:
"""Trigger an inject node.
Args:
node_id: Node ID to inject
"""
await self._request("POST", f"/inject/{node_id}")
# Settings endpoints
async def get_settings(self) -> dict:
"""Get Node-RED runtime settings.
Returns:
Settings object
"""
return await self._request("GET", "/settings")
async def get_diagnostics(self) -> dict:
"""Get Node-RED diagnostics information.
Returns:
Diagnostics object (structure varies)
"""
return await self._request("GET", "/diagnostics")
def _build_client(config: Any) -> NodeRedClient:
"""Build a NodeRedClient from a config instance."""
return NodeRedClient(
base_url=config.url,
api_version=config.api_version,
auth_method=config.auth_method,
token=config.token,
auth_username=config.auth_username,
auth_password=config.auth_password,
oauth_token_url=config.oauth_token_url,
oauth_client_id=config.oauth_client_id,
)
# Global client instance
_client: NodeRedClient | None = None
def get_client() -> NodeRedClient:
"""Get the global NodeRedClient instance.
Returns:
Singleton NodeRedClient instance.
"""
global _client
if _client is None:
_client = _build_client(get_config())
return _client
async def reset_client() -> NodeRedClient:
"""Reset and return a fresh client instance (for testing).
Closes the old client if it exists to prevent connection leaks.
Returns:
New NodeRedClient instance.
"""
global _client
if _client is not None:
await _client.close()
_client = _build_client(get_config())
return _client
def initialize_client(config: Any) -> NodeRedClient:
"""Explicit client initialization with a config instance.
Args:
config: Config instance with url, token, api_version, auth fields
Returns:
Initialized NodeRedClient instance.
"""
global _client
_client = _build_client(config)
return _client

58
src/nodered_mcp/config.py Normal file
View File

@@ -0,0 +1,58 @@
"""Configuration management for Node-RED MCP."""
from pydantic_settings import BaseSettings, SettingsConfigDict
class Config(BaseSettings):
"""Node-RED MCP configuration from environment variables.
Environment variables use NODE_RED_ prefix:
NODE_RED_URL, NODE_RED_TOKEN, NODE_RED_API_VERSION
"""
url: str = "http://localhost:1880"
token: str = ""
api_version: str = "v1"
# Auth method: "token" (default), "basic", "oauth"
auth_method: str = "token"
# For basic and oauth: username
auth_username: str = ""
# For basic and oauth: password (app password)
auth_password: str = ""
# For oauth: token endpoint URL
oauth_token_url: str = ""
# For oauth: client ID
oauth_client_id: str = ""
model_config = SettingsConfigDict(
env_prefix="NODE_RED_",
case_sensitive=False,
)
# Global config instance
_config: Config | None = None
def get_config() -> Config:
"""Get the global config instance.
Returns:
Singleton Config instance.
"""
global _config
if _config is None:
_config = Config()
return _config
def reset_config() -> Config:
"""Reset and return a fresh config instance (for testing).
Returns:
New Config instance.
"""
global _config
_config = Config()
return _config

View File

View File

@@ -0,0 +1,38 @@
"""Base API model with generic from_api() class method."""
from typing import Any, Self, overload
from pydantic import BaseModel, ConfigDict
class BaseApiModel(BaseModel):
"""Base model for all API response objects.
Provides a generic from_api() that delegates to model_validate().
Provider-specific subclasses override field mapping via
@field_validator(mode='before') and @model_validator(mode='before').
"""
model_config = ConfigDict(populate_by_name=True)
@overload
@classmethod
def from_api(cls, data: dict[str, Any]) -> Self: ...
@overload
@classmethod
def from_api(cls, data: list[dict[str, Any]]) -> list[Self]: ...
@classmethod
def from_api(cls, data: dict[str, Any] | list[dict[str, Any]]) -> "Self | list[Self]":
"""Create model instance(s) from API response data.
Args:
data: Single dict or list of dicts from provider API.
Returns:
Single model or list of models.
"""
if isinstance(data, list):
return [cls.model_validate(item) for item in data]
return cls.model_validate(data)

View File

@@ -0,0 +1,48 @@
"""Flow-related models for Node-RED."""
from pydantic import ConfigDict, Field
from nodered_mcp.models.base import BaseApiModel
class Node(BaseApiModel):
"""Node-RED node.
Node-RED nodes have many type-specific fields that vary by node type,
so we use extra="allow" to capture them without strict modeling.
"""
model_config = ConfigDict(populate_by_name=True, extra="allow")
id: str = ""
type: str = ""
z: str = "" # parent flow ID
name: str = ""
wires: list[list[str]] = Field(default_factory=list)
x: int = 0
y: int = 0
class FlowTab(BaseApiModel):
"""Flow tab (workspace) in Node-RED."""
id: str = ""
label: str = ""
disabled: bool = False
info: str = ""
class Flow(BaseApiModel):
"""Complete flow representation."""
id: str = ""
label: str = ""
nodes: list[Node] = Field(default_factory=list)
configs: list[Node] = Field(default_factory=list)
subflows: list[dict] = Field(default_factory=list)
class FlowState(BaseApiModel):
"""Flow state (start/stop)."""
state: str = ""

View File

@@ -0,0 +1,20 @@
"""Layout lint result models for Node-RED flow visualization."""
from nodered_mcp.models.base import BaseApiModel
class LayoutIssue(BaseApiModel):
"""A single layout issue found by the linter."""
severity: str = "" # "error" | "warning"
rule: str = "" # "overlap" | "spacing" | "wire_crossing"
message: str = ""
node_ids: list[str] = []
class LayoutReport(BaseApiModel):
"""Layout lint report for a flow."""
issues: list[LayoutIssue] = []
nodes_checked: int = 0
summary: str = ""

View File

@@ -0,0 +1,24 @@
"""Node module models for Node-RED."""
from pydantic import Field
from nodered_mcp.models.base import BaseApiModel
class NodeSet(BaseApiModel):
"""Node-RED node set (collection of node types in a module)."""
id: str = ""
name: str = ""
types: list[str] = Field(default_factory=list)
enabled: bool = True
module: str = ""
version: str = ""
class NodeModule(BaseApiModel):
"""Node-RED module containing node sets."""
name: str = ""
version: str = ""
nodes: list[NodeSet] = Field(default_factory=list)

View File

@@ -0,0 +1,52 @@
"""Response models for MCP tools.
All MCP tools must return Pydantic models, never dicts.
These models wrap paginated and structured responses.
"""
from typing import Any
from pydantic import Field
from nodered_mcp.models.base import BaseApiModel
class FlowList(BaseApiModel):
"""List of flows with summary statistics."""
flows: list[Any] = Field(default_factory=list) # raw flow data (heterogeneous)
tabs: list[Any] = Field(default_factory=list) # raw tab data
summary: str = ""
statistics: dict = Field(default_factory=dict)
class FlowSummary(BaseApiModel):
"""Formatted flow summary with grouped data."""
summary: str = ""
statistics: dict = Field(default_factory=dict)
data: dict = Field(default_factory=dict)
class FlowCreateResult(BaseApiModel):
"""Result of flow creation."""
id: str = ""
class Settings(BaseApiModel):
"""Node-RED settings."""
http_node_root: str = Field("", validation_alias="httpNodeRoot")
version: str = ""
user: dict | None = None
class DiagnosticsResult(BaseApiModel):
"""Diagnostics result.
Thin wrapper - diagnostics response structure is too variable
for strict modeling.
"""
data: dict = Field(default_factory=dict)

43
src/nodered_mcp/server.py Normal file
View File

@@ -0,0 +1,43 @@
"""FastMCP server for Node-RED operations."""
import logging
from fastmcp import FastMCP
from nodered_mcp.client import initialize_client
from nodered_mcp.config import get_config
logger = logging.getLogger(__name__)
# Create the MCP server
mcp = FastMCP(
name="nodered-mcp",
instructions="""Node-RED MCP server providing comprehensive access to Node-RED flows, nodes, and settings.
Supports reading/updating flows, managing nodes, runtime control, and diagnostics.
Responses are optimized for AI consumption with structured data and clear error messages.""",
)
# Load configuration and initialize client
config = get_config()
initialize_client(config)
logger.info(
"Initialized Node-RED client for %s (API version: %s)",
config.url,
config.api_version,
)
# Import all tool modules - tools are registered via @mcp.tool() decorators
from nodered_mcp.tools import flows # noqa: F401, E402
from nodered_mcp.tools import nodes # noqa: F401, E402
from nodered_mcp.tools import settings # noqa: F401, E402
from nodered_mcp.tools import utility # noqa: F401, E402
from nodered_mcp.tools import layout # noqa: F401, E402
def main():
"""Run the MCP server."""
mcp.run()
if __name__ == "__main__":
main()

View File

View File

@@ -0,0 +1,365 @@
"""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.
"""
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.
"""
client = get_client()
flow = json.loads(flow_json)
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.
"""
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

@@ -0,0 +1,217 @@
"""Layout linting tools for Node-RED flows."""
import json
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)
DEFAULT_NODE_WIDTH = 120
DEFAULT_NODE_HEIGHT = 30
MIN_SPACING = 20 # minimum gap between nodes (matches Node-RED grid)
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", "?")
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),
(bx + bw) - x1,
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:
return False
else:
t = qi / pi
if pi < 0:
t_enter = max(t_enter, t)
else:
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
# 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)
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)
dx = abs(ax - bx)
dy = abs(ay - by)
# Overlap: bounding boxes intersect
if dx < DEFAULT_NODE_WIDTH and dy < DEFAULT_NODE_HEIGHT:
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"
),
node_ids=[aid, bid],
))
# Spacing: too close but not overlapping
elif dx < DEFAULT_NODE_WIDTH + MIN_SPACING and dy < DEFAULT_NODE_HEIGHT + MIN_SPACING:
gap = min(
max(dx - DEFAULT_NODE_WIDTH, 0),
max(dy - DEFAULT_NODE_HEIGHT, 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})"
),
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
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
sy = node.get("y", 0)
tx = target.get("x", 0) - hw
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):
continue
ox = other.get("x", 0)
oy = other.get("y", 0)
if _segment_intersects_aabb(sx, sy, tx, ty, ox, oy, hw, hh):
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)}"'
),
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 = []
if error_count:
parts.append(f"{error_count} error{'s' if error_count != 1 else ''}")
if warning_count:
parts.append(f"{warning_count} warning{'s' if warning_count != 1 else ''}")
summary = ", ".join(parts) if parts else "no issues"
return LayoutReport(
issues=issues,
nodes_checked=len(placed),
summary=summary,
)
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}):"]
for issue in report.issues:
lines.append(f"- [{issue.severity}] {issue.rule}: {issue.message}")
return "\n".join(lines)
@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", [])
scoped_ids = json.loads(node_ids) if node_ids else None
return _lint_nodes(nodes, scoped_ids)

View File

@@ -0,0 +1,111 @@
"""Node management tools for Node-RED."""
import json
from nodered_mcp.client import get_client
from nodered_mcp.models.node import NodeModule, NodeSet
from nodered_mcp.models.flow import Node
from nodered_mcp.server import mcp
@mcp.tool()
async def inject(node_id: str) -> str:
"""Trigger an inject node.
Args:
node_id: Inject node ID to trigger
Returns:
Confirmation message.
"""
client = get_client()
await client.inject(node_id)
return f"Inject node {node_id} triggered"
@mcp.tool()
async def get_nodes() -> list[NodeSet]:
"""Get list of installed node modules.
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]
@mcp.tool()
async def get_node_info(module: str) -> NodeModule:
"""Get information about a specific node module.
Args:
module: Node module name
Returns:
NodeModule with version and node sets.
"""
client = get_client()
raw_info = await client.get_node_info(module)
return NodeModule.from_api(raw_info)
@mcp.tool()
async def toggle_node_module(module: str, enabled: bool) -> NodeModule:
"""Enable or disable a node module.
Args:
module: Node module name
enabled: True to enable, False to disable
Returns:
Updated NodeModule object.
"""
client = get_client()
raw_module = await client.toggle_node_module(module, enabled)
return NodeModule.from_api(raw_module)
@mcp.tool()
async def find_nodes_by_type(node_type: str) -> list[Node]:
"""Find all nodes of a specific type.
Args:
node_type: Node type to search for
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]
@mcp.tool()
async def search_nodes(query: str, property: str | None = None) -> list[Node]:
"""Search for nodes by name or properties.
Args:
query: String to search for in node properties
property: Specific property to search (optional, searches all properties if not specified)
Returns:
List of matching Node objects.
"""
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)
return [Node.from_api(n) for n in matches]

View File

@@ -0,0 +1,29 @@
"""Settings and diagnostics tools for Node-RED."""
from nodered_mcp.client import get_client
from nodered_mcp.models.responses import DiagnosticsResult, Settings
from nodered_mcp.server import mcp
@mcp.tool()
async def get_settings() -> Settings:
"""Get Node-RED runtime settings.
Returns:
Settings with runtime configuration.
"""
client = get_client()
raw_settings = await client.get_settings()
return Settings.from_api(raw_settings)
@mcp.tool()
async def get_diagnostics() -> DiagnosticsResult:
"""Get Node-RED diagnostics information.
Returns:
DiagnosticsResult with system diagnostics data.
"""
client = get_client()
raw_diagnostics = await client.get_diagnostics()
return DiagnosticsResult(data=raw_diagnostics)

View File

@@ -0,0 +1,40 @@
"""Utility tools for Node-RED MCP server."""
from nodered_mcp.server import mcp
@mcp.tool()
async def api_help() -> str:
"""Get Node-RED API endpoint reference with implementation status.
Returns:
Markdown table of API endpoints and their MCP implementation status.
"""
endpoints = [
("GET", "/flows", "Get all flows", True),
("POST", "/flows", "Update all flows", True),
("GET", "/flow/:id", "Get a specific flow", True),
("PUT", "/flow/:id", "Update a specific flow", True),
("DELETE", "/flow/:id", "Delete a specific flow", True),
("POST", "/flow", "Create a new flow", True),
("GET", "/flows/state", "Get the state of flows", True),
("POST", "/flows/state", "Set the state of flows", True),
("GET", "/nodes", "Get list of installed nodes", True),
("POST", "/nodes", "Install a new node module", False),
("GET", "/settings", "Get runtime settings", True),
("GET", "/diagnostics", "Get diagnostics information", True),
("POST", "/inject/:id", "Trigger an inject node", True),
]
output = [
"# Node-RED API Help",
"",
"| Method | Path | Description | Implemented in MCP |",
"|--------|------|-------------|---------------------|",
]
for method, path, description, implemented in endpoints:
status = "" if implemented else ""
output.append(f"| {method} | {path} | {description} | {status} |")
return "\n".join(output)

0
tests/__init__.py Normal file
View File

251
tests/conftest.py Normal file
View File

@@ -0,0 +1,251 @@
"""Shared test fixtures for nodered_mcp tests."""
from typing import Any
from unittest.mock import AsyncMock, patch
import pytest
from nodered_mcp.client import NodeRedClient
# ── Sample API data fixtures ──────────────────────────────────────
@pytest.fixture
def sample_flows_data() -> list[dict[str, Any]]:
"""Raw Node-RED flows API response (heterogeneous array of tabs, nodes, subflows)."""
return [
# Two tabs
{
"id": "tab1",
"type": "tab",
"label": "Main Flow",
"disabled": False,
"info": "Primary workspace",
},
{
"id": "tab2",
"type": "tab",
"label": "Test Flow",
"disabled": True,
"info": "",
},
# Several nodes of different types
{
"id": "inject1",
"type": "inject",
"z": "tab1",
"name": "Test Inject",
"topic": "",
"payload": "test",
"payloadType": "str",
"repeat": "",
"crontab": "",
"once": False,
"onceDelay": 0.1,
"x": 100,
"y": 100,
"wires": [["function1"]],
},
{
"id": "function1",
"type": "function",
"z": "tab1",
"name": "Process Data",
"func": "return msg;",
"outputs": 1,
"noerr": 0,
"x": 300,
"y": 100,
"wires": [["debug1"]],
},
{
"id": "debug1",
"type": "debug",
"z": "tab1",
"name": "Output",
"active": True,
"tosidebar": True,
"console": False,
"tostatus": False,
"complete": "payload",
"x": 500,
"y": 100,
"wires": [],
},
{
"id": "inject2",
"type": "inject",
"z": "tab2",
"name": "Another Inject",
"payload": "",
"payloadType": "date",
"repeat": "",
"crontab": "",
"once": False,
"x": 100,
"y": 200,
"wires": [[]],
},
# One subflow
{
"id": "subflow1",
"type": "subflow",
"name": "Test Subflow",
"info": "",
"in": [{"x": 50, "y": 50, "wires": [{"id": "sf-node1"}]}],
"out": [{"x": 250, "y": 50, "wires": [{"id": "sf-node1", "port": 0}]}],
},
]
@pytest.fixture
def sample_flow_data() -> dict[str, Any]:
"""Single flow API response (GET /flow/:id)."""
return {
"id": "tab1",
"label": "Main Flow",
"nodes": [
{
"id": "inject1",
"type": "inject",
"name": "Test",
"x": 100,
"y": 100,
"wires": [["debug1"]],
},
{
"id": "debug1",
"type": "debug",
"name": "Output",
"x": 300,
"y": 100,
"wires": [],
},
],
"configs": [],
"subflows": [],
}
@pytest.fixture
def sample_node_module_data() -> dict[str, Any]:
"""NodeModule API response (GET /nodes/:module)."""
return {
"name": "node-red-contrib-test",
"version": "1.0.0",
"nodes": [
{
"id": "node-red-contrib-test/test-node",
"name": "test-node",
"types": ["test-input", "test-output"],
"enabled": True,
"module": "node-red-contrib-test",
"version": "1.0.0",
}
],
}
@pytest.fixture
def sample_node_set_data() -> dict[str, Any]:
"""NodeSet API response."""
return {
"id": "node-red/inject",
"name": "inject",
"types": ["inject"],
"enabled": True,
"module": "node-red",
"version": "3.1.0",
}
@pytest.fixture
def sample_settings_data() -> dict[str, Any]:
"""Settings API response (GET /settings)."""
return {
"httpNodeRoot": "/api",
"version": "3.1.0",
"user": {
"username": "admin",
"permissions": "*",
},
"flowFilePretty": True,
"editorTheme": {
"page": {"title": "Node-RED"},
},
}
@pytest.fixture
def sample_diagnostics_data() -> dict[str, Any]:
"""Diagnostics API response (GET /diagnostics)."""
return {
"report": {
"versions": {
"node-red": "3.1.0",
"nodejs": "18.17.0",
"npm": "9.6.7",
},
"platform": {
"os": "darwin",
"arch": "arm64",
"cpus": 8,
"mem": 17179869184,
},
"modules": {
"node-red-contrib-test": "1.0.0",
},
},
}
@pytest.fixture
def sample_flows_state_data() -> dict[str, Any]:
"""Flow state API response (GET /flows/state)."""
return {"state": "start"}
# ── Mock client fixture ───────────────────────────────────────────
@pytest.fixture
def mock_client(
sample_flows_data: list[dict[str, Any]],
sample_flow_data: dict[str, Any],
sample_node_module_data: dict[str, Any],
sample_node_set_data: dict[str, Any],
sample_settings_data: dict[str, Any],
sample_diagnostics_data: dict[str, Any],
sample_flows_state_data: dict[str, Any],
) -> NodeRedClient:
"""Mock NodeRedClient with all methods stubbed."""
client = AsyncMock(spec=NodeRedClient)
# Flow endpoints
client.get_flows.return_value = sample_flows_data
client.get_flow.return_value = sample_flow_data
client.update_flows.return_value = None
client.update_flow.return_value = None
client.create_flow.return_value = {"id": "new-flow-id"}
client.delete_flow.return_value = None
client.get_flows_state.return_value = sample_flows_state_data
client.set_flows_state.return_value = sample_flows_state_data
# Node endpoints
client.get_nodes.return_value = [sample_node_set_data]
client.get_node_info.return_value = sample_node_module_data
client.toggle_node_module.return_value = sample_node_module_data
client.inject.return_value = None
# Settings endpoints
client.get_settings.return_value = sample_settings_data
client.get_diagnostics.return_value = sample_diagnostics_data
with (
patch("nodered_mcp.tools.flows.get_client", return_value=client),
patch("nodered_mcp.tools.nodes.get_client", return_value=client),
patch("nodered_mcp.tools.settings.get_client", return_value=client),
patch("nodered_mcp.tools.layout.get_client", return_value=client),
):
yield client

422
tests/test_client.py Normal file
View File

@@ -0,0 +1,422 @@
"""Tests for NodeRedClient."""
from unittest.mock import AsyncMock, patch
import httpx
import pytest
from nodered_mcp.client import NodeRedClient, get_client, reset_client
class TestNodeRedClient:
def test_init_without_token(self) -> None:
client = NodeRedClient(base_url="http://localhost:1880", api_version="v1")
assert client._http.base_url == httpx.URL("http://localhost:1880")
assert client._http.headers["Node-RED-API-Version"] == "v1"
assert "Authorization" not in client._http.headers
def test_init_with_token(self) -> None:
client = NodeRedClient(
base_url="http://localhost:1880",
token="test-token",
api_version="v1",
)
assert client._http.headers["Authorization"] == "Bearer test-token"
assert client._http.headers["Node-RED-API-Version"] == "v1"
def test_init_with_custom_api_version(self) -> None:
client = NodeRedClient(
base_url="http://localhost:1880",
api_version="v2",
)
assert client._http.headers["Node-RED-API-Version"] == "v2"
@pytest.mark.asyncio
async def test_request_success_with_json(self) -> None:
from unittest.mock import MagicMock
client = NodeRedClient(base_url="http://localhost:1880")
mock_response = MagicMock()
mock_response.status_code = 200
mock_response.json.return_value = {"id": "test"}
with patch.object(client._http, "request", new_callable=AsyncMock, return_value=mock_response):
result = await client._request("GET", "/flows")
assert result == {"id": "test"}
@pytest.mark.asyncio
async def test_request_204_no_content(self) -> None:
client = NodeRedClient(base_url="http://localhost:1880")
mock_response = AsyncMock()
mock_response.status_code = 204
with patch.object(client._http, "request", return_value=mock_response):
result = await client._request("DELETE", "/flow/test")
assert result is None
@pytest.mark.asyncio
async def test_request_400_error(self) -> None:
client = NodeRedClient(base_url="http://localhost:1880")
mock_response = AsyncMock()
mock_response.status_code = 400
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"):
await client._request("GET", "/flows")
@pytest.mark.asyncio
async def test_request_404_error(self) -> None:
client = NodeRedClient(base_url="http://localhost:1880")
mock_response = AsyncMock()
mock_response.status_code = 404
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"):
await client._request("GET", "/flow/nonexistent")
@pytest.mark.asyncio
async def test_request_500_error(self) -> None:
client = NodeRedClient(base_url="http://localhost:1880")
mock_response = AsyncMock()
mock_response.status_code = 500
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"):
await client._request("POST", "/flows")
@pytest.mark.asyncio
async def test_request_with_data(self) -> None:
from unittest.mock import MagicMock
client = NodeRedClient(base_url="http://localhost:1880")
mock_response = MagicMock()
mock_response.status_code = 200
mock_response.json.return_value = {"success": True}
with patch.object(client._http, "request", new_callable=AsyncMock, return_value=mock_response) as mock_req:
await client._request("POST", "/flow", data={"label": "Test"})
mock_req.assert_called_once_with(
"POST", "/flow", json={"label": "Test"}, headers={}
)
@pytest.mark.asyncio
async def test_request_with_extra_headers(self) -> None:
from unittest.mock import MagicMock
client = NodeRedClient(base_url="http://localhost:1880")
mock_response = MagicMock()
mock_response.status_code = 200
mock_response.json.return_value = {}
with patch.object(client._http, "request", new_callable=AsyncMock, return_value=mock_response) as mock_req:
await client._request(
"POST", "/flows", data=[], headers={"Node-RED-Deployment-Type": "full"}
)
mock_req.assert_called_once_with(
"POST",
"/flows",
json=[],
headers={"Node-RED-Deployment-Type": "full"},
)
@pytest.mark.asyncio
async def test_update_flows_passes_deployment_type_header(self) -> None:
client = NodeRedClient(base_url="http://localhost:1880")
mock_response = AsyncMock()
mock_response.status_code = 204
with patch.object(client._http, "request", return_value=mock_response) as mock_req:
await client.update_flows(flows=[{"id": "test"}], deployment_type="nodes")
mock_req.assert_called_once()
call_args = mock_req.call_args
assert call_args[1]["headers"]["Node-RED-Deployment-Type"] == "nodes"
@pytest.mark.asyncio
async def test_close(self) -> None:
client = NodeRedClient(base_url="http://localhost:1880")
with patch.object(client._http, "aclose", new_callable=AsyncMock) as mock_close:
await client.close()
mock_close.assert_called_once()
class TestGetClient:
@pytest.mark.asyncio
async def test_get_client_returns_singleton(self) -> None:
# Reset to ensure clean state
await reset_client()
c1 = get_client()
c2 = get_client()
assert c1 is c2
@pytest.mark.asyncio
async def test_get_client_returns_noderedclient(self) -> None:
await reset_client()
client = get_client()
assert isinstance(client, NodeRedClient)
@pytest.mark.asyncio
async def test_get_client_uses_config(self) -> None:
with patch("nodered_mcp.client.get_config") as mock_get_config:
mock_config = AsyncMock()
mock_config.url = "http://test:1880"
mock_config.token = "test-token"
mock_config.api_version = "v1"
mock_config.auth_method = "token"
mock_config.auth_username = ""
mock_config.auth_password = ""
mock_config.oauth_token_url = ""
mock_config.oauth_client_id = ""
mock_get_config.return_value = mock_config
await reset_client()
client = get_client()
assert client._http.base_url == httpx.URL("http://test:1880")
assert client._http.headers["Authorization"] == "Bearer test-token"
class TestResetClient:
@pytest.mark.asyncio
async def test_reset_client_returns_fresh_instance(self) -> None:
c1 = get_client()
c2 = await reset_client()
assert c1 is not c2
@pytest.mark.asyncio
async def test_reset_client_closes_old_client(self) -> None:
client = get_client()
with patch.object(client, "close", new_callable=AsyncMock) as mock_close:
await reset_client()
mock_close.assert_called_once()
@pytest.mark.asyncio
async def test_reset_client_returns_noderedclient(self) -> None:
client = await reset_client()
assert isinstance(client, NodeRedClient)
@pytest.mark.asyncio
async def test_reset_client_updates_singleton(self) -> None:
await reset_client()
c1 = get_client()
c2 = await reset_client()
c3 = get_client()
# After reset, get_client should return the new instance
assert c1 is not c3
assert c2 is c3
class TestBasicAuth:
def test_init_basic_auth(self) -> None:
"""Basic auth sets httpx.BasicAuth on the client."""
client = NodeRedClient(
base_url="http://localhost:1880",
auth_method="basic",
auth_username="user",
auth_password="secret",
)
assert client._auth_method == "basic"
assert client._http._auth is not None
# No static Authorization header — BasicAuth sends it per-request
assert "Authorization" not in client._http.headers
def test_init_basic_auth_no_token_header(self) -> None:
"""Basic auth mode ignores static token."""
client = NodeRedClient(
base_url="http://localhost:1880",
auth_method="basic",
auth_username="user",
auth_password="pass",
token="should-be-ignored",
)
assert "Authorization" not in client._http.headers
class TestOAuthAuth:
def test_init_oauth_no_initial_auth(self) -> None:
"""OAuth mode has no Authorization header at init."""
client = NodeRedClient(
base_url="http://localhost:1880",
auth_method="oauth",
auth_username="user",
auth_password="pass",
oauth_token_url="https://auth.example.com/application/o/token/",
oauth_client_id="my-client-id",
)
assert client._auth_method == "oauth"
assert client._oauth_token is None
assert "Authorization" not in client._http.headers
@pytest.mark.asyncio
async def test_oauth_token_fetch(self) -> None:
"""OAuth fetches token on first request."""
from unittest.mock import MagicMock
client = NodeRedClient(
base_url="http://localhost:1880",
auth_method="oauth",
auth_username="user",
auth_password="pass",
oauth_token_url="https://auth.example.com/token",
oauth_client_id="client-id",
)
# Mock the token endpoint response
token_response = MagicMock()
token_response.status_code = 200
token_response.json.return_value = {
"access_token": "jwt-token-123",
"expires_in": 600,
}
with patch("nodered_mcp.client.httpx.AsyncClient") as mock_auth_cls:
mock_auth_client = AsyncMock()
mock_auth_client.post.return_value = token_response
mock_auth_client.__aenter__ = AsyncMock(return_value=mock_auth_client)
mock_auth_client.__aexit__ = AsyncMock(return_value=None)
mock_auth_cls.return_value = mock_auth_client
await client._ensure_oauth_token()
assert client._oauth_token == "jwt-token-123"
assert client._oauth_expiry > 0
assert client._http.headers["Authorization"] == "Bearer jwt-token-123"
@pytest.mark.asyncio
async def test_oauth_token_cached(self) -> None:
"""OAuth doesn't re-fetch valid token."""
import time
client = NodeRedClient(
base_url="http://localhost:1880",
auth_method="oauth",
auth_username="user",
auth_password="pass",
oauth_token_url="https://auth.example.com/token",
oauth_client_id="client-id",
)
# Pre-set a valid token
client._oauth_token = "cached-token"
client._oauth_expiry = time.time() + 300 # 5 min from now
with patch("nodered_mcp.client.httpx.AsyncClient") as mock_cls:
await client._ensure_oauth_token()
# Should NOT have created a new client (no fetch needed)
mock_cls.assert_not_called()
assert client._oauth_token == "cached-token"
@pytest.mark.asyncio
async def test_oauth_token_refresh_when_expired(self) -> None:
"""OAuth re-fetches when token is expired."""
from unittest.mock import MagicMock
client = NodeRedClient(
base_url="http://localhost:1880",
auth_method="oauth",
auth_username="user",
auth_password="pass",
oauth_token_url="https://auth.example.com/token",
oauth_client_id="client-id",
)
# Set an expired token
client._oauth_token = "old-token"
client._oauth_expiry = 0.0
token_response = MagicMock()
token_response.status_code = 200
token_response.json.return_value = {
"access_token": "new-token",
"expires_in": 600,
}
with patch("nodered_mcp.client.httpx.AsyncClient") as mock_auth_cls:
mock_auth_client = AsyncMock()
mock_auth_client.post.return_value = token_response
mock_auth_client.__aenter__ = AsyncMock(return_value=mock_auth_client)
mock_auth_client.__aexit__ = AsyncMock(return_value=None)
mock_auth_cls.return_value = mock_auth_client
await client._ensure_oauth_token()
assert client._oauth_token == "new-token"
@pytest.mark.asyncio
async def test_oauth_token_error(self) -> None:
"""OAuth raises RuntimeError on token endpoint failure."""
from unittest.mock import MagicMock
client = NodeRedClient(
base_url="http://localhost:1880",
auth_method="oauth",
auth_username="user",
auth_password="pass",
oauth_token_url="https://auth.example.com/token",
oauth_client_id="client-id",
)
error_response = MagicMock()
error_response.status_code = 401
error_response.text = "invalid_client"
with patch("nodered_mcp.client.httpx.AsyncClient") as mock_auth_cls:
mock_auth_client = AsyncMock()
mock_auth_client.post.return_value = error_response
mock_auth_client.__aenter__ = AsyncMock(return_value=mock_auth_client)
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"):
await client._ensure_oauth_token()
@pytest.mark.asyncio
async def test_oauth_401_retry(self) -> None:
"""OAuth retries once on 401 from Node-RED."""
from unittest.mock import MagicMock
import time
client = NodeRedClient(
base_url="http://localhost:1880",
auth_method="oauth",
auth_username="user",
auth_password="pass",
oauth_token_url="https://auth.example.com/token",
oauth_client_id="client-id",
)
# Pre-set a "valid" but actually revoked token
client._oauth_token = "revoked-token"
client._oauth_expiry = time.time() + 300
# First response: 401, second response: 200
response_401 = MagicMock()
response_401.status_code = 401
response_401.text = "Unauthorized"
response_200 = MagicMock()
response_200.status_code = 200
response_200.json.return_value = {"id": "test"}
with patch.object(
client._http, "request", new_callable=AsyncMock,
side_effect=[response_401, response_200],
):
# Mock the token refresh
token_response = MagicMock()
token_response.status_code = 200
token_response.json.return_value = {
"access_token": "new-token",
"expires_in": 600,
}
with patch("nodered_mcp.client.httpx.AsyncClient") as mock_auth_cls:
mock_auth_client = AsyncMock()
mock_auth_client.post.return_value = token_response
mock_auth_client.__aenter__ = AsyncMock(return_value=mock_auth_client)
mock_auth_client.__aexit__ = AsyncMock(return_value=None)
mock_auth_cls.return_value = mock_auth_client
result = await client._request("GET", "/flows")
assert result == {"id": "test"}
assert client._oauth_token == "new-token"

141
tests/test_config.py Normal file
View File

@@ -0,0 +1,141 @@
"""Tests for configuration management."""
import os
from nodered_mcp.config import Config, get_config, reset_config
class TestConfig:
def test_defaults(self) -> None:
"""Test default values without env vars."""
old_url = os.environ.pop("NODE_RED_URL", None)
old_token = os.environ.pop("NODE_RED_TOKEN", None)
old_version = os.environ.pop("NODE_RED_API_VERSION", None)
try:
cfg = Config()
assert cfg.url == "http://localhost:1880"
assert cfg.token == ""
assert cfg.api_version == "v1"
finally:
if old_url:
os.environ["NODE_RED_URL"] = old_url
if old_token:
os.environ["NODE_RED_TOKEN"] = old_token
if old_version:
os.environ["NODE_RED_API_VERSION"] = old_version
def test_env_var_override_url(self, monkeypatch) -> None:
"""Test that Config reads NODE_RED_URL from environment."""
monkeypatch.setenv("NODE_RED_URL", "http://example.com:1880")
cfg = Config(_env_file=None)
assert cfg.url == "http://example.com:1880"
def test_env_var_override_token(self, monkeypatch) -> None:
"""Test that Config reads NODE_RED_TOKEN from environment."""
monkeypatch.setenv("NODE_RED_TOKEN", "test-token-123")
cfg = Config(_env_file=None)
assert cfg.token == "test-token-123"
def test_env_var_override_api_version(self, monkeypatch) -> None:
"""Test that Config reads NODE_RED_API_VERSION from environment."""
monkeypatch.setenv("NODE_RED_API_VERSION", "v2")
cfg = Config(_env_file=None)
assert cfg.api_version == "v2"
def test_env_var_override_all(self, monkeypatch) -> None:
"""Test that Config reads all env vars."""
monkeypatch.setenv("NODE_RED_URL", "http://custom:8080")
monkeypatch.setenv("NODE_RED_TOKEN", "custom-token")
monkeypatch.setenv("NODE_RED_API_VERSION", "v2")
cfg = Config(_env_file=None)
assert cfg.url == "http://custom:8080"
assert cfg.token == "custom-token"
assert cfg.api_version == "v2"
def test_env_prefix_case_insensitive(self, monkeypatch) -> None:
"""Verify case_sensitive=False setting works."""
monkeypatch.setenv("node_red_url", "http://lowercase:1880")
cfg = Config(_env_file=None)
assert cfg.url == "http://lowercase:1880"
class TestGetConfig:
def test_get_config_returns_singleton(self) -> None:
cfg1 = get_config()
cfg2 = get_config()
assert cfg1 is cfg2
def test_get_config_returns_config_instance(self) -> None:
cfg = get_config()
assert isinstance(cfg, Config)
def test_get_config_with_env_vars(self, monkeypatch) -> None:
"""Test get_config returns instance that respects env vars."""
monkeypatch.setenv("NODE_RED_URL", "http://singleton-test:1880")
reset_config()
cfg = get_config()
assert cfg.url == "http://singleton-test:1880"
class TestResetConfig:
def test_reset_config_returns_fresh_instance(self) -> None:
cfg1 = get_config()
cfg2 = reset_config()
assert cfg1 is not cfg2
def test_reset_config_returns_config_instance(self) -> None:
cfg = reset_config()
assert isinstance(cfg, Config)
def test_reset_config_updates_singleton(self) -> None:
reset_config()
cfg1 = get_config()
reset_config()
cfg2 = get_config()
# After reset, get_config should return the new instance
assert cfg1 is not cfg2
def test_reset_config_applies_new_env_vars(self, monkeypatch) -> None:
"""Test reset_config picks up changed env vars."""
monkeypatch.setenv("NODE_RED_URL", "http://first:1880")
reset_config()
cfg1 = get_config()
assert cfg1.url == "http://first:1880"
monkeypatch.setenv("NODE_RED_URL", "http://second:1880")
cfg2 = reset_config()
assert cfg2.url == "http://second:1880"
class TestAuthConfig:
def test_auth_defaults(self) -> None:
"""Auth fields default to token mode with empty credentials."""
cfg = Config(_env_file=None)
assert cfg.auth_method == "token"
assert cfg.auth_username == ""
assert cfg.auth_password == ""
assert cfg.oauth_token_url == ""
assert cfg.oauth_client_id == ""
def test_auth_method_basic(self, monkeypatch) -> None:
monkeypatch.setenv("NODE_RED_AUTH_METHOD", "basic")
monkeypatch.setenv("NODE_RED_AUTH_USERNAME", "admin")
monkeypatch.setenv("NODE_RED_AUTH_PASSWORD", "app-password-123")
cfg = Config(_env_file=None)
assert cfg.auth_method == "basic"
assert cfg.auth_username == "admin"
assert cfg.auth_password == "app-password-123"
def test_auth_method_oauth(self, monkeypatch) -> None:
monkeypatch.setenv("NODE_RED_AUTH_METHOD", "oauth")
monkeypatch.setenv("NODE_RED_AUTH_USERNAME", "svc-account")
monkeypatch.setenv("NODE_RED_AUTH_PASSWORD", "svc-password")
monkeypatch.setenv("NODE_RED_OAUTH_TOKEN_URL", "https://auth.example.com/application/o/token/")
monkeypatch.setenv("NODE_RED_OAUTH_CLIENT_ID", "my-client-id")
cfg = Config(_env_file=None)
assert cfg.auth_method == "oauth"
assert cfg.auth_username == "svc-account"
assert cfg.auth_password == "svc-password"
assert cfg.oauth_token_url == "https://auth.example.com/application/o/token/"
assert cfg.oauth_client_id == "my-client-id"

379
tests/test_models.py Normal file
View File

@@ -0,0 +1,379 @@
"""Tests for Pydantic data models and their from_api() methods."""
from nodered_mcp.models.flow import Flow, FlowState, FlowTab, Node
from nodered_mcp.models.node import NodeModule, NodeSet
from nodered_mcp.models.responses import (
DiagnosticsResult,
FlowCreateResult,
FlowList,
FlowSummary,
Settings,
)
# ── BaseApiModel tests ────────────────────────────────────────────
class TestBaseApiModel:
def test_from_api_single_dict(self) -> None:
result = Node.from_api({"id": "test1", "type": "inject"})
assert isinstance(result, Node)
assert result.id == "test1"
assert result.type == "inject"
def test_from_api_list_of_dicts(self) -> None:
results = Node.from_api([
{"id": "n1", "type": "inject"},
{"id": "n2", "type": "debug"},
])
assert isinstance(results, list)
assert len(results) == 2
assert all(isinstance(n, Node) for n in results)
# ── Node model tests ──────────────────────────────────────────────
class TestNode:
def test_from_api_basic(self) -> None:
node = Node.from_api({
"id": "node1",
"type": "inject",
"z": "tab1",
"name": "Test Node",
"x": 100,
"y": 200,
"wires": [["node2"]],
})
assert node.id == "node1"
assert node.type == "inject"
assert node.z == "tab1"
assert node.name == "Test Node"
assert node.x == 100
assert node.y == 200
assert node.wires == [["node2"]]
def test_from_api_preserves_extra_fields(self) -> None:
"""Verify extra='allow' captures type-specific fields."""
node = Node.from_api({
"id": "inject1",
"type": "inject",
"payload": "test",
"payloadType": "str",
"repeat": "",
"crontab": "",
"once": False,
"topic": "test/topic",
})
assert node.id == "inject1"
# Extra fields should be accessible as attributes
assert hasattr(node, "payload")
assert node.payload == "test" # type: ignore[attr-defined]
assert node.payloadType == "str" # type: ignore[attr-defined]
assert node.topic == "test/topic" # type: ignore[attr-defined]
def test_from_api_minimal(self) -> None:
node = Node.from_api({"id": "n1"})
assert node.id == "n1"
assert node.type == ""
assert node.z == ""
assert node.name == ""
assert node.wires == []
assert node.x == 0
assert node.y == 0
def test_from_api_list(self) -> None:
nodes = Node.from_api([
{"id": "n1", "type": "inject"},
{"id": "n2", "type": "debug"},
])
assert isinstance(nodes, list)
assert len(nodes) == 2
# ── FlowTab model tests ───────────────────────────────────────────
class TestFlowTab:
def test_from_api_complete(self) -> None:
tab = FlowTab.from_api({
"id": "tab1",
"label": "Main Flow",
"disabled": False,
"info": "Primary workspace",
})
assert tab.id == "tab1"
assert tab.label == "Main Flow"
assert tab.disabled is False
assert tab.info == "Primary workspace"
def test_from_api_minimal(self) -> None:
tab = FlowTab.from_api({"id": "tab1"})
assert tab.id == "tab1"
assert tab.label == ""
assert tab.disabled is False
assert tab.info == ""
def test_from_api_list(self) -> None:
tabs = FlowTab.from_api([
{"id": "tab1", "label": "Flow 1"},
{"id": "tab2", "label": "Flow 2"},
])
assert isinstance(tabs, list)
assert len(tabs) == 2
# ── Flow model tests ──────────────────────────────────────────────
class TestFlow:
def test_from_api_complete(self) -> None:
flow = Flow.from_api({
"id": "flow1",
"label": "Test Flow",
"nodes": [
{"id": "n1", "type": "inject"},
{"id": "n2", "type": "debug"},
],
"configs": [
{"id": "c1", "type": "mqtt-broker"},
],
"subflows": [],
})
assert flow.id == "flow1"
assert flow.label == "Test Flow"
assert len(flow.nodes) == 2
assert isinstance(flow.nodes[0], Node)
assert len(flow.configs) == 1
assert isinstance(flow.configs[0], Node)
assert flow.subflows == []
def test_from_api_minimal(self) -> None:
flow = Flow.from_api({"id": "flow1"})
assert flow.id == "flow1"
assert flow.label == ""
assert flow.nodes == []
assert flow.configs == []
assert flow.subflows == []
def test_from_api_with_nested_nodes(self) -> None:
flow = Flow.from_api({
"id": "flow1",
"label": "Test",
"nodes": [
{"id": "n1", "type": "inject", "name": "Input"},
],
})
assert flow.nodes[0].id == "n1"
assert flow.nodes[0].name == "Input"
# ── FlowState model tests ─────────────────────────────────────────
class TestFlowState:
def test_from_api_start(self) -> None:
state = FlowState.from_api({"state": "start"})
assert state.state == "start"
def test_from_api_stop(self) -> None:
state = FlowState.from_api({"state": "stop"})
assert state.state == "stop"
def test_from_api_minimal(self) -> None:
state = FlowState.from_api({})
assert state.state == ""
# ── NodeSet model tests ───────────────────────────────────────────
class TestNodeSet:
def test_from_api_complete(self) -> None:
node_set = NodeSet.from_api({
"id": "node-red/inject",
"name": "inject",
"types": ["inject"],
"enabled": True,
"module": "node-red",
"version": "3.1.0",
})
assert node_set.id == "node-red/inject"
assert node_set.name == "inject"
assert node_set.types == ["inject"]
assert node_set.enabled is True
assert node_set.module == "node-red"
assert node_set.version == "3.1.0"
def test_from_api_minimal(self) -> None:
node_set = NodeSet.from_api({})
assert node_set.id == ""
assert node_set.name == ""
assert node_set.types == []
assert node_set.enabled is True
assert node_set.module == ""
assert node_set.version == ""
def test_from_api_list(self) -> None:
node_sets = NodeSet.from_api([
{"id": "ns1", "name": "test1"},
{"id": "ns2", "name": "test2"},
])
assert isinstance(node_sets, list)
assert len(node_sets) == 2
# ── NodeModule model tests ────────────────────────────────────────
class TestNodeModule:
def test_from_api_complete(self) -> None:
module = NodeModule.from_api({
"name": "node-red-contrib-test",
"version": "1.0.0",
"nodes": [
{
"id": "test/node1",
"name": "node1",
"types": ["test-input"],
"enabled": True,
"module": "node-red-contrib-test",
"version": "1.0.0",
},
],
})
assert module.name == "node-red-contrib-test"
assert module.version == "1.0.0"
assert len(module.nodes) == 1
assert isinstance(module.nodes[0], NodeSet)
assert module.nodes[0].name == "node1"
def test_from_api_with_nested_node_sets(self) -> None:
module = NodeModule.from_api({
"name": "test-module",
"version": "2.0.0",
"nodes": [
{"id": "ns1", "name": "set1"},
{"id": "ns2", "name": "set2"},
],
})
assert len(module.nodes) == 2
assert all(isinstance(ns, NodeSet) for ns in module.nodes)
def test_from_api_minimal(self) -> None:
module = NodeModule.from_api({})
assert module.name == ""
assert module.version == ""
assert module.nodes == []
# ── Settings model tests ──────────────────────────────────────────
class TestSettings:
def test_from_api_with_validation_alias(self) -> None:
"""Verify validation_alias converts httpNodeRoot to http_node_root."""
settings = Settings.from_api({
"httpNodeRoot": "/api",
"version": "3.1.0",
"user": {"username": "admin"},
})
assert settings.http_node_root == "/api"
assert settings.version == "3.1.0"
assert settings.user == {"username": "admin"}
def test_from_api_minimal(self) -> None:
settings = Settings.from_api({})
assert settings.http_node_root == ""
assert settings.version == ""
assert settings.user is None
def test_from_api_snake_case_direct(self) -> None:
"""Verify snake_case keys also work (populate_by_name=True)."""
settings = Settings.from_api({
"http_node_root": "/custom",
"version": "3.0.0",
})
assert settings.http_node_root == "/custom"
# ── DiagnosticsResult model tests ─────────────────────────────────
class TestDiagnosticsResult:
def test_from_api_with_nested_data(self) -> None:
diag = DiagnosticsResult.from_api({
"data": {
"report": {
"versions": {"node-red": "3.1.0"},
"platform": {"os": "darwin"},
},
}
})
assert "report" in diag.data
assert diag.data["report"]["versions"]["node-red"] == "3.1.0"
def test_from_api_minimal(self) -> None:
diag = DiagnosticsResult.from_api({})
assert diag.data == {}
# ── FlowCreateResult model tests ──────────────────────────────────
class TestFlowCreateResult:
def test_from_api(self) -> None:
result = FlowCreateResult.from_api({"id": "new-flow-123"})
assert result.id == "new-flow-123"
def test_from_api_minimal(self) -> None:
result = FlowCreateResult.from_api({})
assert result.id == ""
# ── FlowList model tests ──────────────────────────────────────────
class TestFlowList:
def test_from_api_complete(self) -> None:
flow_list = FlowList.from_api({
"flows": [{"id": "n1"}, {"id": "n2"}],
"tabs": [{"id": "tab1"}],
"summary": "2 flows, 1 tab",
"statistics": {"total_nodes": 2, "total_tabs": 1},
})
assert len(flow_list.flows) == 2
assert len(flow_list.tabs) == 1
assert flow_list.summary == "2 flows, 1 tab"
assert flow_list.statistics["total_nodes"] == 2
def test_from_api_minimal(self) -> None:
flow_list = FlowList.from_api({})
assert flow_list.flows == []
assert flow_list.tabs == []
assert flow_list.summary == ""
assert flow_list.statistics == {}
# ── FlowSummary model tests ───────────────────────────────────────
class TestFlowSummary:
def test_from_api_complete(self) -> None:
summary = FlowSummary.from_api({
"summary": "Total: 5 nodes across 2 tabs",
"statistics": {"nodes": 5, "tabs": 2},
"data": {
"tabs": [{"id": "tab1"}],
"nodes": [{"id": "n1"}],
},
})
assert summary.summary == "Total: 5 nodes across 2 tabs"
assert summary.statistics["nodes"] == 5
assert "tabs" in summary.data
def test_from_api_minimal(self) -> None:
summary = FlowSummary.from_api({})
assert summary.summary == ""
assert summary.statistics == {}
assert summary.data == {}

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

1601
uv.lock generated Normal file

File diff suppressed because it is too large Load Diff