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:
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`.
|
||||
Reference in New Issue
Block a user