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:
34
.gitignore
vendored
Normal file
34
.gitignore
vendored
Normal 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
174
CLAUDE.md
Normal 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
17
Makefile
Normal 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
260
README.md
Normal 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
352
conventions.md
Normal 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
34
pyproject.toml
Normal 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",
|
||||
]
|
||||
0
src/nodered_mcp/__init__.py
Normal file
0
src/nodered_mcp/__init__.py
Normal file
343
src/nodered_mcp/client.py
Normal file
343
src/nodered_mcp/client.py
Normal 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
58
src/nodered_mcp/config.py
Normal 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
|
||||
0
src/nodered_mcp/models/__init__.py
Normal file
0
src/nodered_mcp/models/__init__.py
Normal file
38
src/nodered_mcp/models/base.py
Normal file
38
src/nodered_mcp/models/base.py
Normal 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)
|
||||
48
src/nodered_mcp/models/flow.py
Normal file
48
src/nodered_mcp/models/flow.py
Normal 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 = ""
|
||||
20
src/nodered_mcp/models/layout.py
Normal file
20
src/nodered_mcp/models/layout.py
Normal 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 = ""
|
||||
24
src/nodered_mcp/models/node.py
Normal file
24
src/nodered_mcp/models/node.py
Normal 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)
|
||||
52
src/nodered_mcp/models/responses.py
Normal file
52
src/nodered_mcp/models/responses.py
Normal 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
43
src/nodered_mcp/server.py
Normal 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()
|
||||
0
src/nodered_mcp/tools/__init__.py
Normal file
0
src/nodered_mcp/tools/__init__.py
Normal file
365
src/nodered_mcp/tools/flows.py
Normal file
365
src/nodered_mcp/tools/flows.py
Normal 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
|
||||
217
src/nodered_mcp/tools/layout.py
Normal file
217
src/nodered_mcp/tools/layout.py
Normal 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)
|
||||
111
src/nodered_mcp/tools/nodes.py
Normal file
111
src/nodered_mcp/tools/nodes.py
Normal 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]
|
||||
29
src/nodered_mcp/tools/settings.py
Normal file
29
src/nodered_mcp/tools/settings.py
Normal 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)
|
||||
40
src/nodered_mcp/tools/utility.py
Normal file
40
src/nodered_mcp/tools/utility.py
Normal 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
0
tests/__init__.py
Normal file
251
tests/conftest.py
Normal file
251
tests/conftest.py
Normal 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
422
tests/test_client.py
Normal 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
141
tests/test_config.py
Normal 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
379
tests/test_models.py
Normal 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
0
tests/tools/__init__.py
Normal file
409
tests/tools/test_flows.py
Normal file
409
tests/tools/test_flows.py
Normal 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
222
tests/tools/test_layout.py
Normal 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
140
tests/tools/test_nodes.py
Normal 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
|
||||
53
tests/tools/test_settings.py
Normal file
53
tests/tools/test_settings.py
Normal 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"]
|
||||
46
tests/tools/test_utility.py
Normal file
46
tests/tools/test_utility.py
Normal 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()
|
||||
Reference in New Issue
Block a user