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

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

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

352
conventions.md Normal file
View File

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