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