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>
11 KiB
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().
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.
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.
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).
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:
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:
- Same value, different key? →
Field(validation_alias="apiKey")— zero code, pure declaration - Reusable type coercion? → Type alias with
Annotated[T, PlainSerializer(...)]— defined once, used everywhere - Single field needs transformation? →
@field_validator("field", mode="before")— isolated, testable - Multiple fields need cross-referencing? →
@model_validator(mode="before")— minimal reshaping only - 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
# 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:
# 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.
# 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
# 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
@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.
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)
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:
# 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:
_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
[
{"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:
# 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:
# 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:
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.