From ee1e09cb820042f6040da5eb64f8542da714648a Mon Sep 17 00:00:00 2001 From: Ariel Vernaza Date: Mon, 1 Jun 2026 22:31:19 +0200 Subject: [PATCH 01/15] feat(node): add Butterbase MCP client node (#1042) Clone of tool_mcp_client hardcoded to Butterbase's Streamable HTTP MCP server (https://api.butterbase.ai/mcp, Bearer auth). Discovers and exposes Butterbase's backend tools (init_app, schema, auth, storage, functions, RAG) to agents as butterbase.. Includes example pipe and unit tests (namespacing, tool cache, dispatch). --- examples/butterbase-agent.pipe | 67 ++++ nodes/src/nodes/tool_butterbase/IGlobal.py | 133 +++++++ nodes/src/nodes/tool_butterbase/IInstance.py | 73 ++++ nodes/src/nodes/tool_butterbase/README.md | 48 +++ nodes/src/nodes/tool_butterbase/__init__.py | 29 ++ .../src/nodes/tool_butterbase/butterbase.svg | 5 + .../mcp_streamable_http_client.py | 363 ++++++++++++++++++ .../nodes/tool_butterbase/requirements.txt | 2 + nodes/src/nodes/tool_butterbase/services.json | 67 ++++ nodes/test/tool_butterbase/__init__.py | 0 nodes/test/tool_butterbase/test_tools.py | 192 +++++++++ 11 files changed, 979 insertions(+) create mode 100644 examples/butterbase-agent.pipe create mode 100644 nodes/src/nodes/tool_butterbase/IGlobal.py create mode 100644 nodes/src/nodes/tool_butterbase/IInstance.py create mode 100644 nodes/src/nodes/tool_butterbase/README.md create mode 100644 nodes/src/nodes/tool_butterbase/__init__.py create mode 100644 nodes/src/nodes/tool_butterbase/butterbase.svg create mode 100644 nodes/src/nodes/tool_butterbase/mcp_streamable_http_client.py create mode 100644 nodes/src/nodes/tool_butterbase/requirements.txt create mode 100644 nodes/src/nodes/tool_butterbase/services.json create mode 100644 nodes/test/tool_butterbase/__init__.py create mode 100644 nodes/test/tool_butterbase/test_tools.py diff --git a/examples/butterbase-agent.pipe b/examples/butterbase-agent.pipe new file mode 100644 index 000000000..e846d10d9 --- /dev/null +++ b/examples/butterbase-agent.pipe @@ -0,0 +1,67 @@ +{ + "components": [ + { + "id": "chat_1", + "provider": "chat", + "config": { "hideForm": true, "mode": "Source", "parameters": {}, "type": "chat" }, + "ui": { "position": { "x": 20, "y": 200 }, "measured": { "width": 150, "height": 66 }, "nodeType": "default", "formDataValid": true } + }, + { + "id": "agent_rocketride_1", + "provider": "agent_rocketride", + "config": { + "instructions": ["You are a backend engineer agent. Use the butterbase tools to provision and manage the backend: create apps, define and apply schemas, configure auth and row-level rules, manage storage, and deploy functions.", "Inspect available butterbase tools before acting, prefer dry-run/preview where offered, and confirm destructive changes."], + "max_waves": 20, + "parameters": {} + }, + "input": [{ "lane": "questions", "from": "chat_1" }], + "ui": { "position": { "x": 240, "y": 200 }, "measured": { "width": 150, "height": 86 }, "nodeType": "default", "formDataValid": true } + }, + { + "id": "llm_anthropic_1", + "provider": "llm_anthropic", + "config": { + "profile": "claude", + "claude": { "apikey": "${ROCKETRIDE_ANTHROPIC_KEY}" }, + "parameters": {} + }, + "control": [ + { "classType": "llm", "from": "agent_rocketride_1" } + ], + "ui": { "position": { "x": 110, "y": 360 }, "measured": { "width": 150, "height": 66 }, "nodeType": "default", "formDataValid": true } + }, + { + "id": "memory_internal_1", + "provider": "memory_internal", + "config": { "type": "memory_internal" }, + "control": [ + { "classType": "memory", "from": "agent_rocketride_1" } + ], + "ui": { "position": { "x": 300, "y": 360 }, "measured": { "width": 150, "height": 66 }, "nodeType": "default", "formDataValid": true } + }, + { + "id": "tool_butterbase_1", + "provider": "tool_butterbase", + "config": { + "type": "tool_butterbase", + "api_key": "${BUTTERBASE_API_KEY}", + "endpoint": "https://api.butterbase.ai/mcp", + "serverName": "butterbase" + }, + "control": [ + { "classType": "tool", "from": "agent_rocketride_1" } + ], + "ui": { "position": { "x": 500, "y": 360 }, "measured": { "width": 150, "height": 66 }, "nodeType": "default", "formDataValid": true } + }, + { + "id": "response_answers_1", + "provider": "response_answers", + "config": { "laneName": "answers" }, + "input": [{ "lane": "answers", "from": "agent_rocketride_1" }], + "ui": { "position": { "x": 460, "y": 200 }, "measured": { "width": 150, "height": 66 }, "nodeType": "default", "formDataValid": true } + } + ], + "project_id": "b8d2f1e3-4c5a-4b67-8d9e-2f3a4b5c6d7e", + "viewport": { "x": 0, "y": 0, "zoom": 1 }, + "version": 1 +} diff --git a/nodes/src/nodes/tool_butterbase/IGlobal.py b/nodes/src/nodes/tool_butterbase/IGlobal.py new file mode 100644 index 000000000..76dd7a576 --- /dev/null +++ b/nodes/src/nodes/tool_butterbase/IGlobal.py @@ -0,0 +1,133 @@ +# ============================================================================= +# RocketRide Engine +# ============================================================================= +# MIT License +# Copyright (c) 2026 Aparavi Software AG +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in +# all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +# SOFTWARE. +# ============================================================================= + +""" +Butterbase tool node - global (shared) state. + +A purpose-built clone of the generic ``tool_mcp_client`` node, hardcoded to +Butterbase's Streamable HTTP MCP server (https://api.butterbase.ai/mcp). On +open it connects, performs the MCP initialize handshake, and discovers +Butterbase's tools (init_app, schema, auth, storage, functions, …) via +tools/list, caching them so the agent can call them as ``butterbase.``. + +Auth is a single Bearer API key (``bb_sk_...``). The only required config is +that key; the endpoint defaults to production and is overridable. +""" + +from __future__ import annotations + +import os +from typing import Any, Dict, List, Optional + +from ai.common.config import Config +from rocketlib import IGlobalBase, OPEN_MODE, error, warning + +from .mcp_streamable_http_client import McpStreamableHttpClient, McpToolDef + +_DEFAULT_ENDPOINT = 'https://api.butterbase.ai/mcp' +_SERVER_NAME = 'butterbase' + + +class IGlobal(IGlobalBase): + """Global state for tool_butterbase.""" + + serverName: str = _SERVER_NAME + endpoint: str = _DEFAULT_ENDPOINT + + def beginGlobal(self) -> None: + # Skip heavy initialization in CONFIG mode (matches other nodes). + if self.IEndpoint.endpoint.openMode == OPEN_MODE.CONFIG: + return + + cfg = Config.getNodeConfig(self.glb.logicalType, self.glb.connConfig) + + api_key = str(cfg.get('api_key') or os.environ.get('BUTTERBASE_API_KEY', '')).strip() + if not api_key: + error('tool_butterbase: api_key is required — set it in node config or BUTTERBASE_API_KEY env var') + raise ValueError('tool_butterbase: api_key is required') + + self.serverName = str(cfg.get('serverName') or _SERVER_NAME).strip() or _SERVER_NAME + self.endpoint = str(cfg.get('endpoint') or _DEFAULT_ENDPOINT).strip() or _DEFAULT_ENDPOINT + + headers: Dict[str, str] = {'Authorization': f'Bearer {api_key}'} + + try: + self._client = McpStreamableHttpClient(endpoint=self.endpoint, headers=headers) + self._client.start() + tools = self._client.list_tools() + self._cache_tools(tools) + except Exception as e: + warning(str(e)) + raise + + def validateConfig(self) -> None: + """Validate config at save-time with quick local checks.""" + try: + cfg = Config.getNodeConfig(self.glb.logicalType, self.glb.connConfig) + api_key = str(cfg.get('api_key') or os.environ.get('BUTTERBASE_API_KEY', '')).strip() + if not api_key: + warning('api_key is required (Butterbase bb_sk_... key)') + except Exception as e: + warning(str(e)) + + def endGlobal(self) -> None: + try: + client = getattr(self, '_client', None) + if client is not None: + client.stop() + finally: + self._client = None + self._tools_by_original = {} + self._tools_by_namespaced = {} + + # ------------------------------------------------------------------ + # Tool cache + accessors for IInstance hooks + # ------------------------------------------------------------------ + def _cache_tools(self, tools: List[McpToolDef]) -> None: + self._tools_by_original: Dict[str, McpToolDef] = {} + self._tools_by_namespaced: Dict[str, McpToolDef] = {} + for t in tools: + self._tools_by_original[t.name] = t + self._tools_by_namespaced[f'{self.serverName}.{t.name}'] = t + + def list_namespaced_tools(self) -> List[Dict[str, Any]]: + out: List[Dict[str, Any]] = [] + for namespaced, tool in (self._tools_by_namespaced or {}).items(): + out.append({'name': namespaced, 'description': tool.description, 'input_schema': tool.inputSchema}) + return out + + def get_tool(self, *, server_name: str, tool_name: str) -> Optional[McpToolDef]: + if server_name != self.serverName: + return None + return (self._tools_by_original or {}).get(tool_name) + + def call_tool(self, *, server_name: str, tool_name: str, arguments: Dict[str, Any]) -> Dict[str, Any]: + if server_name != self.serverName: + raise Exception( + f'Unknown Butterbase serverName {server_name!r} (this node configured as {self.serverName!r})' + ) + if self._client is None: + raise Exception('Butterbase MCP client is not connected') + return self._client.call_tool(name=tool_name, arguments=arguments or {}) diff --git a/nodes/src/nodes/tool_butterbase/IInstance.py b/nodes/src/nodes/tool_butterbase/IInstance.py new file mode 100644 index 000000000..346ae8ca0 --- /dev/null +++ b/nodes/src/nodes/tool_butterbase/IInstance.py @@ -0,0 +1,73 @@ +# ============================================================================= +# RocketRide Engine +# ============================================================================= +# MIT License +# Copyright (c) 2026 Aparavi Software AG +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in +# all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +# SOFTWARE. +# ============================================================================= + +""" +Butterbase tool node instance. + +Uses the dynamic tools escape hatch — Butterbase's tools are discovered at +runtime from its MCP server, not declared with @tool_function decorators. +""" + +from __future__ import annotations + +from typing import Any, Dict + +from rocketlib import IInstanceBase + +from .IGlobal import IGlobal + +_FRAMEWORK_KEYS = frozenset({'security_context'}) + + +class IInstance(IInstanceBase): + IGlobal: IGlobal + + def _tool_query_dynamic(self) -> list: + """Return tools discovered from the Butterbase MCP server.""" + return self.IGlobal.list_namespaced_tools() + + def _tool_invoke_dynamic(self, *, tool_name: str, input_obj: Any) -> Any: + """Dispatch a tool call to the Butterbase MCP server.""" + server_name, bare_tool = _split_tool_name(tool_name) + if input_obj is None: + arguments: Dict[str, Any] = {} + elif isinstance(input_obj, dict): + arguments = {k: v for k, v in input_obj.items() if k not in _FRAMEWORK_KEYS} + else: + raise ValueError('Tool input must be a JSON object (dict)') + return self.IGlobal.call_tool(server_name=server_name, tool_name=bare_tool, arguments=arguments) + + +def _split_tool_name(tool_name: str) -> tuple[str, str]: + """Split ``'server.tool'`` into ``('server', 'tool')``.""" + s = (tool_name or '').strip() + if '.' not in s: + raise ValueError(f'Tool name must be namespaced as `server.tool`; got {tool_name!r}') + server, bare = s.split('.', 1) + server = server.strip() + bare = bare.strip() + if not server or not bare: + raise ValueError(f'Tool name must be namespaced as `server.tool`; got {tool_name!r}') + return server, bare diff --git a/nodes/src/nodes/tool_butterbase/README.md b/nodes/src/nodes/tool_butterbase/README.md new file mode 100644 index 000000000..0a42e481c --- /dev/null +++ b/nodes/src/nodes/tool_butterbase/README.md @@ -0,0 +1,48 @@ +# Butterbase + +Connects an agent to the [Butterbase](https://butterbase.ai) MCP server and exposes its +backend tools for tool-calling. + +Butterbase is an AI-optimized Backend-as-a-Service: a managed database, authentication, +object storage, serverless functions, a model gateway, and native RAG. Through its MCP server the +agent can create apps, evolve schemas, configure auth/RLS, manage storage, deploy functions, +and deploy frontends — as tools named `butterbase.` (e.g. `butterbase.init_app`). + +This is a purpose-built clone of the generic `tool_mcp_client`, hardcoded to Butterbase's +Streamable HTTP endpoint so setup is a single API key. For other MCP servers, use +`tool_mcp_client`. + +## Connection + +| | | +| --- | --- | +| Endpoint | `https://api.butterbase.ai/mcp` (Streamable HTTP) | +| Auth | `Authorization: Bearer ` (`bb_sk_...`) | +| Tools | Discovered at runtime via `tools/list` (init_app, schema, auth, storage, functions, …) | + +Reference: [Butterbase MCP Setup](https://docs.butterbase.ai/getting-started/mcp-setup). + +## Configuration + +| Field | Required | Notes | +| --- | --- | --- | +| `api_key` | yes | Butterbase API key (`bb_sk_...`). Also reads `BUTTERBASE_API_KEY`. Stored encrypted. | +| `endpoint` | no | Defaults to `https://api.butterbase.ai/mcp`. | +| `serverName` | no | Tool namespace prefix. Defaults to `butterbase`. | + +## Wiring + +This is a `tool` node — wire it to an agent via `control` (class `tool`): + +```jsonc +{ + "id": "tool_butterbase_1", + "provider": "tool_butterbase", + "config": { "type": "tool_butterbase", "api_key": "${BUTTERBASE_API_KEY}" }, + "control": [{ "classType": "tool", "from": "agent_rocketride_1" }] +} +``` + +The node connects on open, discovers Butterbase's tools, and the agent calls them by their +namespaced names. Never commit keys — use node config (encrypted) or the `BUTTERBASE_API_KEY` +env var. diff --git a/nodes/src/nodes/tool_butterbase/__init__.py b/nodes/src/nodes/tool_butterbase/__init__.py new file mode 100644 index 000000000..37de11f28 --- /dev/null +++ b/nodes/src/nodes/tool_butterbase/__init__.py @@ -0,0 +1,29 @@ +# ============================================================================= +# RocketRide Engine +# ============================================================================= +# MIT License +# Copyright (c) 2026 Aparavi Software AG +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in +# all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +# SOFTWARE. +# ============================================================================= + +from .IGlobal import IGlobal +from .IInstance import IInstance + +__all__ = ['IGlobal', 'IInstance'] diff --git a/nodes/src/nodes/tool_butterbase/butterbase.svg b/nodes/src/nodes/tool_butterbase/butterbase.svg new file mode 100644 index 000000000..9c3e36c70 --- /dev/null +++ b/nodes/src/nodes/tool_butterbase/butterbase.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/nodes/src/nodes/tool_butterbase/mcp_streamable_http_client.py b/nodes/src/nodes/tool_butterbase/mcp_streamable_http_client.py new file mode 100644 index 000000000..2ff2881bf --- /dev/null +++ b/nodes/src/nodes/tool_butterbase/mcp_streamable_http_client.py @@ -0,0 +1,363 @@ +# ============================================================================= +# RocketRide Engine +# ============================================================================= +# MIT License +# Copyright (c) 2026 Aparavi Software AG +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in +# all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +# SOFTWARE. +# ============================================================================= + +""" +Minimal MCP Streamable HTTP client (no external SDK dependency). + +Cloned from the generic ``tool_mcp_client`` transport — Butterbase exposes a +Streamable HTTP MCP endpoint (GET/POST/DELETE on ``/mcp``), so this is the only +transport the Butterbase node needs. + +Spec reference: + MCP spec 2025-03-26: Streamable HTTP transport + - single MCP endpoint path supporting POST/GET + - client POSTs JSON-RPC messages to endpoint + - server responds with application/json OR text/event-stream + - server may return Mcp-Session-Id header at initialization + +This client supports: +- initialize + notifications/initialized +- tools/list +- tools/call + +It supports both response modes: +- Content-Type: application/json +- Content-Type: text/event-stream (SSE stream) +""" + +from __future__ import annotations + +import json +import time +import urllib.error +import urllib.parse +import urllib.request +from dataclasses import dataclass +from typing import Any, Dict, Iterable, List, Optional + + +class McpProtocolError(RuntimeError): + pass + + +class McpHttpStatusError(RuntimeError): + def __init__(self, *, status: int, body: bytes | None = None, url: str | None = None) -> None: + """Create an HTTP status error with response context.""" + super().__init__(f'HTTP status={status} url={url!r} body={(body or b"")[:200]!r}') + self.status = int(status) + self.body = body + self.url = url + + +@dataclass(frozen=True) +class McpToolDef: + name: str + description: str + inputSchema: Dict[str, Any] + + +class McpStreamableHttpClient: + def __init__( + self, + *, + endpoint: str, + headers: Optional[Dict[str, str]] = None, + protocol_version: str = '2025-11-25', + client_name: str = 'RocketRideButterbaseMcpClient', + client_version: str = '0.1.0', + timeout_s: float = 20.0, + ) -> None: + """Create an MCP streamable HTTP client.""" + self._endpoint = str(endpoint).strip() + if not self._endpoint: + raise ValueError('endpoint is required') + + self._timeout_s = float(timeout_s) + self._protocol_version = protocol_version + self._client_info = {'name': client_name, 'version': client_version} + + self._headers = {str(k): str(v) for k, v in (headers or {}).items()} + # Required by the Streamable HTTP spec: list both content types. + self._headers.setdefault('Accept', 'application/json, text/event-stream') + self._headers.setdefault('User-Agent', f'{client_name}/{client_version}') + + self._next_id = 1 + self._session_id: str | None = None + self._started = False + + # ------------------------------------------------------------------ + # Lifecycle + # ------------------------------------------------------------------ + def start(self) -> None: + if self._started: + raise RuntimeError('MCP streamable-http client already started') + + init_result, resp_headers = self._request_with_headers( + 'initialize', + { + 'protocolVersion': self._protocol_version, + 'capabilities': {'roots': {'listChanged': False}, 'sampling': {}}, + 'clientInfo': self._client_info, + }, + ) + if not isinstance(init_result, dict): + raise McpProtocolError(f'initialize result expected object, got {type(init_result)}') + + # Session management (optional). + sid = _get_header(resp_headers, 'Mcp-Session-Id') + if sid: + self._session_id = sid + + # notifications/initialized (a notification only; expect 202 if accepted). + self._notify('notifications/initialized', None) + self._started = True + + def stop(self) -> None: + # Optional: explicitly terminate session if supported. + sid = self._session_id + self._session_id = None + self._started = False + if not sid: + return + + headers = self._build_headers() + headers['Mcp-Session-Id'] = sid + req = urllib.request.Request(self._endpoint, headers=headers, method='DELETE') + try: + with urllib.request.urlopen(req, timeout=self._timeout_s) as resp: + _ = resp.read() + except urllib.error.HTTPError as e: + # 405 is allowed by spec (server may not allow clients to terminate). + if int(getattr(e, 'code', 0)) == 405: + return + except Exception: + # Best-effort only. + return + + # ------------------------------------------------------------------ + # MCP operations + # ------------------------------------------------------------------ + def list_tools(self) -> List[McpToolDef]: + result = self._request('tools/list', {}) + if not isinstance(result, dict): + raise McpProtocolError(f'tools/list result expected object, got {type(result)}') + tools = result.get('tools', []) + if not isinstance(tools, list): + raise McpProtocolError(f'tools/list result.tools expected list, got {type(tools)}') + + out: List[McpToolDef] = [] + for t in tools: + if not isinstance(t, dict): + continue + name = t.get('name') + if not isinstance(name, str) or not name: + continue + desc = t.get('description') if isinstance(t.get('description'), str) else '' + schema = t.get('inputSchema') if isinstance(t.get('inputSchema'), dict) else {'type': 'object'} + out.append(McpToolDef(name=name, description=desc, inputSchema=schema)) + return out + + def call_tool(self, *, name: str, arguments: Dict[str, Any]) -> Dict[str, Any]: + result = self._request('tools/call', {'name': name, 'arguments': arguments or {}}) + if not isinstance(result, dict): + raise McpProtocolError(f'tools/call result expected object, got {type(result)}') + return result + + # ------------------------------------------------------------------ + # JSON-RPC internals + # ------------------------------------------------------------------ + def _notify(self, method: str, params: Any) -> None: # noqa: ANN401 + msg: Dict[str, Any] = {'jsonrpc': '2.0', 'method': method} + if params is not None: + msg['params'] = params + self._post_notification(msg) + + def _request(self, method: str, params: Any) -> Any: # noqa: ANN401 + result, _headers = self._request_with_headers(method, params) + return result + + def _request_with_headers(self, method: str, params: Any) -> tuple[Any, Dict[str, str]]: # noqa: ANN401 + req_id = self._next_id + self._next_id += 1 + msg: Dict[str, Any] = {'jsonrpc': '2.0', 'id': req_id, 'method': method} + if params is not None: + msg['params'] = params + + return self._post_request_and_wait(req_id=req_id, payload=msg) + + def _post_notification(self, payload: Dict[str, Any]) -> None: + data = json.dumps(payload, ensure_ascii=False).encode('utf-8') + headers = self._build_headers() + headers['Content-Type'] = 'application/json' + if self._session_id: + headers['Mcp-Session-Id'] = self._session_id + + req = urllib.request.Request(self._endpoint, data=data, headers=headers, method='POST') + try: + with urllib.request.urlopen(req, timeout=self._timeout_s) as resp: + status = int(getattr(resp, 'status', 200)) + body = resp.read() or b'' + # For notifications/responses-only, spec requires 202 on accept (no body). + if status == 202: + return + if 200 <= status < 300 and not body: + return + # Some servers may return 200 with empty body; accept it. + if 200 <= status < 300 and body == b'': + return + except urllib.error.HTTPError as e: + raise McpHttpStatusError(status=int(e.code), body=_safe_read_http_error(e), url=self._endpoint) from e + + def _post_request_and_wait(self, *, req_id: int, payload: Dict[str, Any]) -> tuple[Any, Dict[str, str]]: # noqa: ANN401 + data = json.dumps(payload, ensure_ascii=False).encode('utf-8') + headers = self._build_headers() + headers['Content-Type'] = 'application/json' + if self._session_id: + headers['Mcp-Session-Id'] = self._session_id + + req = urllib.request.Request(self._endpoint, data=data, headers=headers, method='POST') + try: + with urllib.request.urlopen(req, timeout=self._timeout_s) as resp: + status = int(getattr(resp, 'status', 200)) + resp_headers = {k: v for (k, v) in (resp.headers.items() if resp.headers else [])} + if status == 202: + # Shouldn't happen for a request, but tolerate. + return None, resp_headers + + ctype = (resp.headers.get('Content-Type') or '').lower() if resp.headers else '' + if 'text/event-stream' in ctype: + result = self._read_sse_until_response(resp, req_id=req_id) + return result, resp_headers + + body = resp.read() or b'' + result = _parse_jsonrpc_response_body(body=body, req_id=req_id) + return result, resp_headers + except urllib.error.HTTPError as e: + raise McpHttpStatusError(status=int(e.code), body=_safe_read_http_error(e), url=self._endpoint) from e + + def _read_sse_until_response(self, resp, *, req_id: int) -> Any: # noqa: ANN401 + deadline = time.time() + self._timeout_s + + event_data_lines: List[str] = [] + # Note: Streamable HTTP doesn't require named events; parse "data:" generically. + while True: + if time.time() > deadline: + raise TimeoutError(f'SSE stream timed out waiting for response id={req_id}') + raw = resp.readline() + if not raw: + # Stream ended; if no response seen, error. + raise TimeoutError(f'SSE stream ended before response id={req_id}') + + try: + line = raw.decode('utf-8', errors='replace') + except Exception: + line = str(raw) + line = line.rstrip('\r\n') + + if not line: + # end of SSE event + if event_data_lines: + data = '\n'.join(event_data_lines) + for msg in _iter_jsonrpc_messages_from_sse_data(data): + maybe = _match_jsonrpc_id(msg, req_id=req_id) + if maybe is not None: + return maybe + event_data_lines = [] + continue + + if line.startswith(':'): + continue + if line.startswith('data:'): + event_data_lines.append(line.split(':', 1)[1].lstrip()) + continue + # ignore event:, id:, retry: + + def _build_headers(self) -> Dict[str, str]: + # Make a fresh headers dict per request. + return dict(self._headers) + + +def _safe_read_http_error(e: urllib.error.HTTPError) -> bytes: + try: + return e.read() # type: ignore[no-any-return] + except Exception: + return b'' + + +def _get_header(headers: Dict[str, str], name: str) -> str | None: + lname = name.lower() + for k, v in headers.items(): + if str(k).lower() == lname: + s = str(v).strip() + return s or None + return None + + +def _parse_jsonrpc_response_body(*, body: bytes, req_id: int) -> Any: # noqa: ANN401 + if not body: + raise McpProtocolError('Empty response body for JSON-RPC request') + try: + obj = json.loads(body.decode('utf-8')) + except Exception as e: + raise McpProtocolError(f'Invalid JSON response: {body[:200]!r}') from e + + # Body may be a single response or a batch. + for msg in _iter_jsonrpc_messages(obj): + maybe = _match_jsonrpc_id(msg, req_id=req_id) + if maybe is not None: + return maybe + raise McpProtocolError(f'No JSON-RPC response found for id={req_id}') + + +def _iter_jsonrpc_messages(obj: Any) -> Iterable[dict]: # noqa: ANN401 + if isinstance(obj, dict): + yield obj + elif isinstance(obj, list): + for it in obj: + if isinstance(it, dict): + yield it + + +def _iter_jsonrpc_messages_from_sse_data(data: str) -> Iterable[dict]: + if not data: + return + try: + obj = json.loads(data) + except Exception: + return + for msg in _iter_jsonrpc_messages(obj): + yield msg + + +def _match_jsonrpc_id(msg: dict, *, req_id: int) -> Any | None: # noqa: ANN401 + if not isinstance(msg, dict): + return None + if msg.get('id') != req_id: + return None + if 'error' in msg and isinstance(msg['error'], dict): + code = msg['error'].get('code') + message = msg['error'].get('message') + raise McpProtocolError(f'MCP error (id={req_id}) code={code} message={message}') + return msg.get('result') diff --git a/nodes/src/nodes/tool_butterbase/requirements.txt b/nodes/src/nodes/tool_butterbase/requirements.txt new file mode 100644 index 000000000..0ea2d693f --- /dev/null +++ b/nodes/src/nodes/tool_butterbase/requirements.txt @@ -0,0 +1,2 @@ +# No external dependencies — the Streamable HTTP MCP client uses only the +# Python standard library (urllib, json). Mirrors tool_mcp_client. diff --git a/nodes/src/nodes/tool_butterbase/services.json b/nodes/src/nodes/tool_butterbase/services.json new file mode 100644 index 000000000..1c433a3a8 --- /dev/null +++ b/nodes/src/nodes/tool_butterbase/services.json @@ -0,0 +1,67 @@ +{ + "title": "Butterbase", + "protocol": "tool_butterbase://", + "classType": ["tool"], + "capabilities": ["invoke", "experimental"], + "register": "filter", + "node": "python", + "path": "nodes.tool_butterbase", + "prefix": "butterbase", + "icon": "butterbase.svg", + "documentation": "https://docs.butterbase.ai", + "description": ["Connects to the Butterbase MCP server and exposes its backend tools for agent tool-calling.", "Butterbase is an AI-optimized Backend-as-a-Service (managed database, authentication, object storage, serverless functions, RAG). The agent can create apps, evolve schemas, configure auth, deploy functions, and more — as tools named butterbase.."], + "tile": ["Server: ${parameters.butterbase.serverName}"], + "lanes": {}, + "preconfig": { + "default": "default", + "profiles": { + "default": { + "title": "Butterbase", + "serverName": "butterbase", + "endpoint": "https://api.butterbase.ai/mcp", + "api_key": "" + } + } + }, + "fields": { + "butterbase.api_key": { + "type": "string", + "title": "API Key", + "description": "Butterbase API key (bb_sk_...). Create one in the Butterbase dashboard. Sent as an Authorization Bearer token.", + "default": "", + "secure": true, + "ui": { + "ui:widget": "ApiKeyWidget" + } + }, + "butterbase.endpoint": { + "type": "string", + "title": "Endpoint", + "description": "Butterbase MCP Streamable HTTP endpoint. Defaults to the production server.", + "default": "https://api.butterbase.ai/mcp" + }, + "butterbase.serverName": { + "type": "string", + "title": "Server name", + "description": "Namespace prefix for the discovered tools: . (example: butterbase.init_app).", + "default": "butterbase" + } + }, + "test": { + "profiles": ["default"], + "outputs": [], + "cases": [ + { + "name": "Config validation with placeholder key", + "text": "test" + } + ] + }, + "shape": [ + { + "section": "Pipe", + "title": "Butterbase", + "properties": ["type", "butterbase.api_key", "butterbase.endpoint", "butterbase.serverName"] + } + ] +} diff --git a/nodes/test/tool_butterbase/__init__.py b/nodes/test/tool_butterbase/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/nodes/test/tool_butterbase/test_tools.py b/nodes/test/tool_butterbase/test_tools.py new file mode 100644 index 000000000..c4fca227d --- /dev/null +++ b/nodes/test/tool_butterbase/test_tools.py @@ -0,0 +1,192 @@ +# ============================================================================= +# MIT License +# Copyright (c) 2026 Aparavi Software AG +# ============================================================================= + +"""Unit tests for the tool_butterbase node. + +Pure-Python: no server, no engine, no real HTTP. The node modules are imported +under composable stubs for ``rocketlib`` and ``ai.common.config`` so the +relative imports resolve without the engine runtime. The MCP transport is +never hit — we exercise the tool cache, namespacing, and dispatch directly with +a fake client. + +Covers: +* ``_split_tool_name`` — namespaced parsing and rejection of bad shapes. +* ``IGlobal._cache_tools`` + accessors — namespacing, lookup, scope guard. +* ``IGlobal.call_tool`` — routes to the client / rejects unknown server. +* ``IInstance._tool_invoke_dynamic`` — strips framework keys, dispatches. +""" + +from __future__ import annotations + +import sys +import types +from pathlib import Path + +import pytest + +_NODE_DIR = Path(__file__).resolve().parent.parent.parent / 'src' / 'nodes' / 'tool_butterbase' + + +# --------------------------------------------------------------------------- +# Composable import scaffolding (augments existing stubs, never clobbers) +# --------------------------------------------------------------------------- + + +def _ensure_rocketlib() -> None: + mod = sys.modules.get('rocketlib') or types.ModuleType('rocketlib') + if not hasattr(mod, 'IInstanceBase'): + mod.IInstanceBase = type('IInstanceBase', (), {}) + if not hasattr(mod, 'IGlobalBase'): + mod.IGlobalBase = type('IGlobalBase', (), {}) + if not hasattr(mod, 'OPEN_MODE'): + mod.OPEN_MODE = type('OPEN_MODE', (), {'CONFIG': 'config'}) + for name in ('debug', 'error', 'warning'): + if not hasattr(mod, name): + setattr(mod, name, lambda *a, **k: None) + sys.modules['rocketlib'] = mod + + +def _ensure_ai_common() -> None: + for name in ('ai', 'ai.common', 'ai.common.config'): + if name not in sys.modules: + sys.modules[name] = types.ModuleType(name) + if not hasattr(sys.modules['ai.common.config'], 'Config'): + + class _Config: + @staticmethod + def getNodeConfig(*_a, **_k): + return {} + + sys.modules['ai.common.config'].Config = _Config + + +def _ensure_pkg() -> None: + if 'tool_butterbase' not in sys.modules: + pkg = types.ModuleType('tool_butterbase') + pkg.__path__ = [str(_NODE_DIR)] + sys.modules['tool_butterbase'] = pkg + + +_ensure_rocketlib() +_ensure_ai_common() +_ensure_pkg() + +from tool_butterbase.IGlobal import IGlobal # noqa: E402 +from tool_butterbase.IInstance import IInstance, _split_tool_name # noqa: E402 +from tool_butterbase.mcp_streamable_http_client import McpToolDef # noqa: E402 + + +# --------------------------------------------------------------------------- +# Fakes +# --------------------------------------------------------------------------- + + +class _FakeClient: + def __init__(self): + self.calls = [] + self.stopped = False + + def call_tool(self, *, name, arguments): + self.calls.append({'name': name, 'arguments': arguments}) + return {'content': [{'type': 'text', 'text': f'ok:{name}'}]} + + def stop(self): + self.stopped = True + + +def _global_with_tools(*tool_names, server='butterbase'): + glb = IGlobal() + glb.serverName = server + glb._client = _FakeClient() + glb._cache_tools([McpToolDef(name=n, description=f'{n} desc', inputSchema={'type': 'object'}) for n in tool_names]) + return glb + + +# --------------------------------------------------------------------------- +# _split_tool_name +# --------------------------------------------------------------------------- + + +def test_split_tool_name_ok(): + assert _split_tool_name('butterbase.init_app') == ('butterbase', 'init_app') + # only the first dot splits — tool names may themselves contain dots + assert _split_tool_name('butterbase.schema.apply') == ('butterbase', 'schema.apply') + + +@pytest.mark.parametrize('bad', ['init_app', 'butterbase.', '.init_app', ' ', '']) +def test_split_tool_name_rejects_bad(bad): + with pytest.raises(ValueError): + _split_tool_name(bad) + + +# --------------------------------------------------------------------------- +# IGlobal cache + accessors +# --------------------------------------------------------------------------- + + +def test_cache_and_list_namespaced_tools(): + glb = _global_with_tools('init_app', 'apply_schema') + listed = {t['name']: t for t in glb.list_namespaced_tools()} + assert set(listed) == {'butterbase.init_app', 'butterbase.apply_schema'} + assert listed['butterbase.init_app']['description'] == 'init_app desc' + assert listed['butterbase.init_app']['input_schema'] == {'type': 'object'} + + +def test_get_tool_scope_guard(): + glb = _global_with_tools('init_app') + assert glb.get_tool(server_name='butterbase', tool_name='init_app').name == 'init_app' + assert glb.get_tool(server_name='butterbase', tool_name='nope') is None + # wrong server namespace → None + assert glb.get_tool(server_name='other', tool_name='init_app') is None + + +def test_call_tool_routes_to_client(): + glb = _global_with_tools('init_app') + out = glb.call_tool(server_name='butterbase', tool_name='init_app', arguments={'name': 'demo'}) + assert out['content'][0]['text'] == 'ok:init_app' + assert glb._client.calls == [{'name': 'init_app', 'arguments': {'name': 'demo'}}] + + +def test_call_tool_rejects_unknown_server(): + glb = _global_with_tools('init_app') + with pytest.raises(Exception): + glb.call_tool(server_name='other', tool_name='init_app', arguments={}) + + +# --------------------------------------------------------------------------- +# IInstance dynamic dispatch +# --------------------------------------------------------------------------- + + +def test_invoke_dynamic_strips_framework_keys_and_dispatches(): + glb = _global_with_tools('init_app') + inst = IInstance() + inst.IGlobal = glb + + # _tool_query_dynamic surfaces the namespaced tools + assert {t['name'] for t in inst._tool_query_dynamic()} == {'butterbase.init_app'} + + inst._tool_invoke_dynamic( + tool_name='butterbase.init_app', + input_obj={'name': 'demo', 'security_context': {'token': 'secret'}}, + ) + # framework key 'security_context' must be stripped before reaching the server + assert glb._client.calls[-1] == {'name': 'init_app', 'arguments': {'name': 'demo'}} + + +def test_invoke_dynamic_none_input_is_empty_args(): + glb = _global_with_tools('list_apps') + inst = IInstance() + inst.IGlobal = glb + inst._tool_invoke_dynamic(tool_name='butterbase.list_apps', input_obj=None) + assert glb._client.calls[-1] == {'name': 'list_apps', 'arguments': {}} + + +def test_invoke_dynamic_non_dict_input_raises(): + glb = _global_with_tools('init_app') + inst = IInstance() + inst.IGlobal = glb + with pytest.raises(ValueError): + inst._tool_invoke_dynamic(tool_name='butterbase.init_app', input_obj=['not', 'a', 'dict']) From 7aa753af30d3359d5d0399f055e848005429259d Mon Sep 17 00:00:00 2001 From: Ariel Vernaza Date: Tue, 2 Jun 2026 17:13:08 +0200 Subject: [PATCH 02/15] =?UTF-8?q?feat(example):=20butterbase=20agent=20pip?= =?UTF-8?q?e=20=E2=80=94=20Anthropic=20LLM=20+=20tool=5Fbutterbase?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit RocketRide Wave agent wired to tool_butterbase (and the required memory_internal), using Claude (claude-sonnet-4-6). Both keys use ROCKETRIDE_-prefixed env vars so pipeline substitution resolves them: ROCKETRIDE_ANTHROPIC_KEY and ROCKETRIDE_BUTTERBASE_API_KEY. --- examples/butterbase-agent.pipe | 235 ++++++++++++++++++++++++--------- 1 file changed, 169 insertions(+), 66 deletions(-) diff --git a/examples/butterbase-agent.pipe b/examples/butterbase-agent.pipe index e846d10d9..95fbabde7 100644 --- a/examples/butterbase-agent.pipe +++ b/examples/butterbase-agent.pipe @@ -1,67 +1,170 @@ { - "components": [ - { - "id": "chat_1", - "provider": "chat", - "config": { "hideForm": true, "mode": "Source", "parameters": {}, "type": "chat" }, - "ui": { "position": { "x": 20, "y": 200 }, "measured": { "width": 150, "height": 66 }, "nodeType": "default", "formDataValid": true } - }, - { - "id": "agent_rocketride_1", - "provider": "agent_rocketride", - "config": { - "instructions": ["You are a backend engineer agent. Use the butterbase tools to provision and manage the backend: create apps, define and apply schemas, configure auth and row-level rules, manage storage, and deploy functions.", "Inspect available butterbase tools before acting, prefer dry-run/preview where offered, and confirm destructive changes."], - "max_waves": 20, - "parameters": {} - }, - "input": [{ "lane": "questions", "from": "chat_1" }], - "ui": { "position": { "x": 240, "y": 200 }, "measured": { "width": 150, "height": 86 }, "nodeType": "default", "formDataValid": true } - }, - { - "id": "llm_anthropic_1", - "provider": "llm_anthropic", - "config": { - "profile": "claude", - "claude": { "apikey": "${ROCKETRIDE_ANTHROPIC_KEY}" }, - "parameters": {} - }, - "control": [ - { "classType": "llm", "from": "agent_rocketride_1" } - ], - "ui": { "position": { "x": 110, "y": 360 }, "measured": { "width": 150, "height": 66 }, "nodeType": "default", "formDataValid": true } - }, - { - "id": "memory_internal_1", - "provider": "memory_internal", - "config": { "type": "memory_internal" }, - "control": [ - { "classType": "memory", "from": "agent_rocketride_1" } - ], - "ui": { "position": { "x": 300, "y": 360 }, "measured": { "width": 150, "height": 66 }, "nodeType": "default", "formDataValid": true } - }, - { - "id": "tool_butterbase_1", - "provider": "tool_butterbase", - "config": { - "type": "tool_butterbase", - "api_key": "${BUTTERBASE_API_KEY}", - "endpoint": "https://api.butterbase.ai/mcp", - "serverName": "butterbase" - }, - "control": [ - { "classType": "tool", "from": "agent_rocketride_1" } - ], - "ui": { "position": { "x": 500, "y": 360 }, "measured": { "width": 150, "height": 66 }, "nodeType": "default", "formDataValid": true } - }, - { - "id": "response_answers_1", - "provider": "response_answers", - "config": { "laneName": "answers" }, - "input": [{ "lane": "answers", "from": "agent_rocketride_1" }], - "ui": { "position": { "x": 460, "y": 200 }, "measured": { "width": 150, "height": 66 }, "nodeType": "default", "formDataValid": true } - } - ], - "project_id": "b8d2f1e3-4c5a-4b67-8d9e-2f3a4b5c6d7e", - "viewport": { "x": 0, "y": 0, "zoom": 1 }, - "version": 1 -} + "components": [ + { + "id": "chat_1", + "provider": "chat", + "config": { + "hideForm": true, + "mode": "Source", + "parameters": {}, + "type": "chat" + }, + "ui": { + "position": { + "x": 20, + "y": 200 + }, + "measured": { + "width": 150, + "height": 66 + }, + "nodeType": "default", + "formDataValid": true + } + }, + { + "id": "agent_rocketride_1", + "provider": "agent_rocketride", + "config": { + "instructions": [ + "You are a backend engineer agent. Use the butterbase tools to provision and manage the backend: create apps, define and apply schemas, configure auth and row-level rules, manage storage, and deploy functions.", + "Inspect available butterbase tools before acting, prefer dry-run/preview where offered, and confirm destructive changes." + ], + "max_waves": 20, + "parameters": {} + }, + "input": [ + { + "lane": "questions", + "from": "chat_1" + } + ], + "ui": { + "position": { + "x": 240, + "y": 200 + }, + "measured": { + "width": 150, + "height": 86 + }, + "nodeType": "default", + "formDataValid": true + } + }, + { + "id": "llm_anthropic_1", + "provider": "llm_anthropic", + "config": { + "profile": "claude-sonnet-4-6", + "claude-sonnet-4-6": { + "apikey": "${ROCKETRIDE_ANTHROPIC_KEY}" + }, + "parameters": {} + }, + "control": [ + { + "classType": "llm", + "from": "agent_rocketride_1" + } + ], + "ui": { + "position": { + "x": 110, + "y": 360 + }, + "measured": { + "width": 150, + "height": 66 + }, + "nodeType": "default", + "formDataValid": true + } + }, + { + "id": "memory_internal_1", + "provider": "memory_internal", + "config": { + "type": "memory_internal" + }, + "control": [ + { + "classType": "memory", + "from": "agent_rocketride_1" + } + ], + "ui": { + "position": { + "x": 300, + "y": 360 + }, + "measured": { + "width": 150, + "height": 66 + }, + "nodeType": "default", + "formDataValid": true + } + }, + { + "id": "tool_butterbase_1", + "provider": "tool_butterbase", + "config": { + "type": "tool_butterbase", + "api_key": "${ROCKETRIDE_BUTTERBASE_API_KEY}", + "endpoint": "https://api.butterbase.ai/mcp", + "serverName": "butterbase" + }, + "control": [ + { + "classType": "tool", + "from": "agent_rocketride_1" + } + ], + "ui": { + "position": { + "x": 500, + "y": 360 + }, + "measured": { + "width": 150, + "height": 66 + }, + "nodeType": "default", + "formDataValid": true + } + }, + { + "id": "response_answers_1", + "provider": "response_answers", + "config": { + "laneName": "answers" + }, + "input": [ + { + "lane": "answers", + "from": "agent_rocketride_1" + } + ], + "ui": { + "position": { + "x": 460, + "y": 200 + }, + "measured": { + "width": 150, + "height": 66 + }, + "nodeType": "default", + "formDataValid": true + } + } + ], + "project_id": "6ba2f198-2277-40ef-ac46-f00778cb043d", + "viewport": { + "x": 0, + "y": 0, + "zoom": 1 + }, + "version": 1 +} \ No newline at end of file From ddbaec070beff4aaa9503915ab41c088721f5ddb Mon Sep 17 00:00:00 2001 From: Ariel Vernaza Date: Tue, 2 Jun 2026 17:24:23 +0200 Subject: [PATCH 03/15] =?UTF-8?q?feat(node):=20butterbase=20=E2=80=94=20us?= =?UTF-8?q?e=20Smithery=20icon=20URL=20for=20the=20node=20icon?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Butterbase publishes no vector logo, so point the icon field at the official Smithery server icon URL. Icon.tsx renders http(s) icon values via , so a remote PNG works without bundling a local SVG. --- nodes/src/nodes/tool_butterbase/services.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/nodes/src/nodes/tool_butterbase/services.json b/nodes/src/nodes/tool_butterbase/services.json index 1c433a3a8..165d09e0e 100644 --- a/nodes/src/nodes/tool_butterbase/services.json +++ b/nodes/src/nodes/tool_butterbase/services.json @@ -7,7 +7,7 @@ "node": "python", "path": "nodes.tool_butterbase", "prefix": "butterbase", - "icon": "butterbase.svg", + "icon": "https://api.smithery.ai/servers/butterbase/butterbase/icon", "documentation": "https://docs.butterbase.ai", "description": ["Connects to the Butterbase MCP server and exposes its backend tools for agent tool-calling.", "Butterbase is an AI-optimized Backend-as-a-Service (managed database, authentication, object storage, serverless functions, RAG). The agent can create apps, evolve schemas, configure auth, deploy functions, and more — as tools named butterbase.."], "tile": ["Server: ${parameters.butterbase.serverName}"], From a4138cec109e013d5dee4f9c8d16af51896f8d44 Mon Sep 17 00:00:00 2001 From: Ariel Vernaza Date: Tue, 2 Jun 2026 17:25:38 +0200 Subject: [PATCH 04/15] =?UTF-8?q?chore(node):=20butterbase=20=E2=80=94=20d?= =?UTF-8?q?rop=20unused=20placeholder=20icon=20(icon=20is=20the=20Smithery?= =?UTF-8?q?=20URL)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- nodes/src/nodes/tool_butterbase/butterbase.svg | 5 ----- 1 file changed, 5 deletions(-) delete mode 100644 nodes/src/nodes/tool_butterbase/butterbase.svg diff --git a/nodes/src/nodes/tool_butterbase/butterbase.svg b/nodes/src/nodes/tool_butterbase/butterbase.svg deleted file mode 100644 index 9c3e36c70..000000000 --- a/nodes/src/nodes/tool_butterbase/butterbase.svg +++ /dev/null @@ -1,5 +0,0 @@ - - - - - From 9593e74e6e9fdafe8c8c4b04c36830ca19b3574f Mon Sep 17 00:00:00 2001 From: Ariel Vernaza Date: Tue, 2 Jun 2026 17:28:36 +0200 Subject: [PATCH 05/15] =?UTF-8?q?feat(node):=20butterbase=20=E2=80=94=20re?= =?UTF-8?q?name=20to=20'Butterbase=20MCP=20Client',=20drop=20experimental?= =?UTF-8?q?=20flag?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Display title (node + default profile + shape) is now 'Butterbase MCP Client'; removed the 'experimental' capability so the card shows no EXPERIMENTAL badge. Internal ids (tool_butterbase, prefix, protocol) unchanged. --- nodes/src/nodes/tool_butterbase/services.json | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/nodes/src/nodes/tool_butterbase/services.json b/nodes/src/nodes/tool_butterbase/services.json index 165d09e0e..4627b3760 100644 --- a/nodes/src/nodes/tool_butterbase/services.json +++ b/nodes/src/nodes/tool_butterbase/services.json @@ -1,8 +1,8 @@ { - "title": "Butterbase", + "title": "Butterbase MCP Client", "protocol": "tool_butterbase://", "classType": ["tool"], - "capabilities": ["invoke", "experimental"], + "capabilities": ["invoke"], "register": "filter", "node": "python", "path": "nodes.tool_butterbase", @@ -16,7 +16,7 @@ "default": "default", "profiles": { "default": { - "title": "Butterbase", + "title": "Butterbase MCP Client", "serverName": "butterbase", "endpoint": "https://api.butterbase.ai/mcp", "api_key": "" @@ -60,7 +60,7 @@ "shape": [ { "section": "Pipe", - "title": "Butterbase", + "title": "Butterbase MCP Client", "properties": ["type", "butterbase.api_key", "butterbase.endpoint", "butterbase.serverName"] } ] From feb83d9bb49ae2bf2a421ac0f7373fe8f670a1bc Mon Sep 17 00:00:00 2001 From: Ariel Vernaza Date: Tue, 2 Jun 2026 17:30:54 +0200 Subject: [PATCH 06/15] =?UTF-8?q?docs(node):=20butterbase=20=E2=80=94=20ad?= =?UTF-8?q?d=20explanatory=20comments=20to=20services.json=20(match=20llm?= =?UTF-8?q?=5Fanthropic=20style)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- nodes/src/nodes/tool_butterbase/services.json | 68 +++++++++++++++++++ 1 file changed, 68 insertions(+) diff --git a/nodes/src/nodes/tool_butterbase/services.json b/nodes/src/nodes/tool_butterbase/services.json index 4627b3760..97dafe86a 100644 --- a/nodes/src/nodes/tool_butterbase/services.json +++ b/nodes/src/nodes/tool_butterbase/services.json @@ -1,17 +1,71 @@ { + // + // Required: + // The displayable name of this node + // "title": "Butterbase MCP Client", + // + // Required: + // The protocol is the endpoint protocol + // "protocol": "tool_butterbase://", + // + // Required: + // Class type of the node - what it does + // "classType": ["tool"], + // + // Required: + // Capabilities are flags that change the behavior of the underlying engine + // "capabilities": ["invoke"], + // + // Optional: + // Register is either filter, endpoint or ignored if not specified + // "register": "filter", + // + // Optional: + // The node is the actual physical node to instantiate + // "node": "python", + // + // Optional: + // The path is the executable/script code + // "path": "nodes.tool_butterbase", + // + // Required: + // The prefix map when converting URLs <=> paths (tools surface as .) + // "prefix": "butterbase", + // + // Optional: + // The icon to display in the UI for this node (a URL renders via ) + // "icon": "https://api.smithery.ai/servers/butterbase/butterbase/icon", "documentation": "https://docs.butterbase.ai", + // + // Optional: + // Description of this node shown in the pipeline builder + // "description": ["Connects to the Butterbase MCP server and exposes its backend tools for agent tool-calling.", "Butterbase is an AI-optimized Backend-as-a-Service (managed database, authentication, object storage, serverless functions, RAG). The agent can create apps, evolve schemas, configure auth, deploy functions, and more — as tools named butterbase.."], + // + // Optional: + // Rendering hints for the UI: which config fields to surface on the node tile + // "tile": ["Server: ${parameters.butterbase.serverName}"], + // + // Optional: + // As a pipe component, define what this component takes and produces. + // This is a control-plane tool node (no data lanes). + // "lanes": {}, + // + // Optional: + // Profile section - configuration options used by the driver itself. + // The default profile is applied when the node is first added to a pipeline. + // "preconfig": { "default": "default", "profiles": { @@ -23,6 +77,11 @@ } } }, + // + // Optional: + // Local field definitions. These define the config fields exposed in the UI. + // "secure: true" fields are encrypted at rest and masked in the UI. + // "fields": { "butterbase.api_key": { "type": "string", @@ -47,6 +106,11 @@ "default": "butterbase" } }, + // + // Optional: + // Test configuration for automated node testing. The API key is a + // placeholder — config validation is tested here, not live API calls. + // "test": { "profiles": ["default"], "outputs": [], @@ -57,6 +121,10 @@ } ] }, + // + // Required: + // Defines the fields (shape) shown in the pipeline builder side panel. + // "shape": [ { "section": "Pipe", From c4bb46ee102136ff7ae71bf3c0facfef7ab9367d Mon Sep 17 00:00:00 2001 From: Ariel Vernaza Date: Tue, 2 Jun 2026 17:48:17 +0200 Subject: [PATCH 07/15] =?UTF-8?q?docs(node):=20butterbase=20=E2=80=94=20do?= =?UTF-8?q?cument=20Developer=20Mode=20prerequisite?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Butterbase app must have Developer Mode enabled for the agent to create/modify resources (writes are rejected otherwise). Added to the README (Prerequisite section) and the node description shown in the builder. --- nodes/src/nodes/tool_butterbase/README.md | 7 +++++++ nodes/src/nodes/tool_butterbase/services.json | 2 +- 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/nodes/src/nodes/tool_butterbase/README.md b/nodes/src/nodes/tool_butterbase/README.md index 0a42e481c..c02eadb73 100644 --- a/nodes/src/nodes/tool_butterbase/README.md +++ b/nodes/src/nodes/tool_butterbase/README.md @@ -12,6 +12,13 @@ This is a purpose-built clone of the generic `tool_mcp_client`, hardcoded to But Streamable HTTP endpoint so setup is a single API key. For other MCP servers, use `tool_mcp_client`. +## Prerequisite: enable Developer Mode + +The Butterbase app must have **Developer Mode enabled** for the agent to create or modify +resources (apps, schema, auth, etc.). Without it, write operations are rejected and the agent +can only read. Turn it on in the Butterbase dashboard before running a pipeline that +provisions a backend. + ## Connection | | | diff --git a/nodes/src/nodes/tool_butterbase/services.json b/nodes/src/nodes/tool_butterbase/services.json index 97dafe86a..9257d14bb 100644 --- a/nodes/src/nodes/tool_butterbase/services.json +++ b/nodes/src/nodes/tool_butterbase/services.json @@ -49,7 +49,7 @@ // Optional: // Description of this node shown in the pipeline builder // - "description": ["Connects to the Butterbase MCP server and exposes its backend tools for agent tool-calling.", "Butterbase is an AI-optimized Backend-as-a-Service (managed database, authentication, object storage, serverless functions, RAG). The agent can create apps, evolve schemas, configure auth, deploy functions, and more — as tools named butterbase.."], + "description": ["Connects to the Butterbase MCP server and exposes its backend tools for agent tool-calling.", "Butterbase is an AI-optimized Backend-as-a-Service (managed database, authentication, object storage, serverless functions, RAG). The agent can create apps, evolve schemas, configure auth, deploy functions, and more — as tools named butterbase..", "Prerequisite: enable Developer Mode on your Butterbase app so the agent can create/modify resources (without it, write operations are rejected)."], // // Optional: // Rendering hints for the UI: which config fields to surface on the node tile From 1c796a5f33f005410b3dfe48ec9b20094009e01c Mon Sep 17 00:00:00 2001 From: Ariel Vernaza Date: Tue, 2 Jun 2026 18:16:05 +0200 Subject: [PATCH 08/15] Working example --- examples/butterbase-agent.pipe | 110 +++++++++++++-------------------- 1 file changed, 44 insertions(+), 66 deletions(-) diff --git a/examples/butterbase-agent.pipe b/examples/butterbase-agent.pipe index 95fbabde7..9c27cac22 100644 --- a/examples/butterbase-agent.pipe +++ b/examples/butterbase-agent.pipe @@ -14,10 +14,6 @@ "x": 20, "y": 200 }, - "measured": { - "width": 150, - "height": 66 - }, "nodeType": "default", "formDataValid": true } @@ -33,24 +29,20 @@ "max_waves": 20, "parameters": {} }, - "input": [ - { - "lane": "questions", - "from": "chat_1" - } - ], "ui": { "position": { "x": 240, "y": 200 }, - "measured": { - "width": 150, - "height": 86 - }, "nodeType": "default", "formDataValid": true - } + }, + "input": [ + { + "lane": "questions", + "from": "chat_1" + } + ] }, { "id": "llm_anthropic_1", @@ -62,24 +54,20 @@ }, "parameters": {} }, - "control": [ - { - "classType": "llm", - "from": "agent_rocketride_1" - } - ], "ui": { "position": { "x": 110, "y": 360 }, - "measured": { - "width": 150, - "height": 66 - }, "nodeType": "default", "formDataValid": true - } + }, + "control": [ + { + "classType": "llm", + "from": "agent_rocketride_1" + } + ] }, { "id": "memory_internal_1", @@ -87,24 +75,20 @@ "config": { "type": "memory_internal" }, - "control": [ - { - "classType": "memory", - "from": "agent_rocketride_1" - } - ], "ui": { "position": { "x": 300, "y": 360 }, - "measured": { - "width": 150, - "height": 66 - }, "nodeType": "default", "formDataValid": true - } + }, + "control": [ + { + "classType": "memory", + "from": "agent_rocketride_1" + } + ] }, { "id": "tool_butterbase_1", @@ -115,24 +99,20 @@ "endpoint": "https://api.butterbase.ai/mcp", "serverName": "butterbase" }, - "control": [ - { - "classType": "tool", - "from": "agent_rocketride_1" - } - ], "ui": { "position": { "x": 500, "y": 360 }, - "measured": { - "width": 150, - "height": 66 - }, "nodeType": "default", "formDataValid": true - } + }, + "control": [ + { + "classType": "tool", + "from": "agent_rocketride_1" + } + ] }, { "id": "response_answers_1", @@ -140,31 +120,29 @@ "config": { "laneName": "answers" }, - "input": [ - { - "lane": "answers", - "from": "agent_rocketride_1" - } - ], "ui": { "position": { "x": 460, "y": 200 }, - "measured": { - "width": 150, - "height": 66 - }, "nodeType": "default", "formDataValid": true - } + }, + "input": [ + { + "lane": "answers", + "from": "agent_rocketride_1" + } + ] } ], "project_id": "6ba2f198-2277-40ef-ac46-f00778cb043d", - "viewport": { - "x": 0, - "y": 0, - "zoom": 1 - }, - "version": 1 -} \ No newline at end of file + "version": 1, + "isLocked": false, + "snapToGrid": true, + "snapGridSize": [ + 10, + 10 + ], + "docRevision": 1 +} From 9574a573a2e76fd7fb8464c91075becca614825a Mon Sep 17 00:00:00 2001 From: Ariel Vernaza Date: Tue, 2 Jun 2026 18:44:21 +0200 Subject: [PATCH 09/15] =?UTF-8?q?refactor(node):=20butterbase=20=E2=80=94?= =?UTF-8?q?=20API=20key=20dashboard=20link=20+=20extract=20magic=20constan?= =?UTF-8?q?ts?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - API Key field help now links to the Butterbase dashboard (dashboard.butterbase.ai → API Keys), matching the xTrace pattern. - Extract transport defaults to named constants (_MCP_PROTOCOL_VERSION, _CLIENT_NAME, _CLIENT_VERSION, _DEFAULT_TIMEOUT_S) to avoid magic values in the client signature. --- .../tool_butterbase/mcp_streamable_http_client.py | 15 +++++++++++---- nodes/src/nodes/tool_butterbase/services.json | 2 +- 2 files changed, 12 insertions(+), 5 deletions(-) diff --git a/nodes/src/nodes/tool_butterbase/mcp_streamable_http_client.py b/nodes/src/nodes/tool_butterbase/mcp_streamable_http_client.py index 2ff2881bf..a4e8adabb 100644 --- a/nodes/src/nodes/tool_butterbase/mcp_streamable_http_client.py +++ b/nodes/src/nodes/tool_butterbase/mcp_streamable_http_client.py @@ -78,16 +78,23 @@ class McpToolDef: inputSchema: Dict[str, Any] +# Defaults (avoid magic constants in the client signature). +_MCP_PROTOCOL_VERSION = '2025-11-25' +_CLIENT_NAME = 'RocketRideButterbaseMcpClient' +_CLIENT_VERSION = '0.1.0' +_DEFAULT_TIMEOUT_S = 20.0 + + class McpStreamableHttpClient: def __init__( self, *, endpoint: str, headers: Optional[Dict[str, str]] = None, - protocol_version: str = '2025-11-25', - client_name: str = 'RocketRideButterbaseMcpClient', - client_version: str = '0.1.0', - timeout_s: float = 20.0, + protocol_version: str = _MCP_PROTOCOL_VERSION, + client_name: str = _CLIENT_NAME, + client_version: str = _CLIENT_VERSION, + timeout_s: float = _DEFAULT_TIMEOUT_S, ) -> None: """Create an MCP streamable HTTP client.""" self._endpoint = str(endpoint).strip() diff --git a/nodes/src/nodes/tool_butterbase/services.json b/nodes/src/nodes/tool_butterbase/services.json index 9257d14bb..37fa4be08 100644 --- a/nodes/src/nodes/tool_butterbase/services.json +++ b/nodes/src/nodes/tool_butterbase/services.json @@ -86,7 +86,7 @@ "butterbase.api_key": { "type": "string", "title": "API Key", - "description": "Butterbase API key (bb_sk_...). Create one in the Butterbase dashboard. Sent as an Authorization Bearer token.", + "description": "Butterbase API key (bb_sk_...). Create one in the Butterbase dashboard: dashboard.butterbase.ai → API Keys (or via the generate_service_key tool). Sent as an Authorization Bearer token.", "default": "", "secure": true, "ui": { From a3e72da06606f586b8f8a60c55b3610a37f4fdb0 Mon Sep 17 00:00:00 2001 From: Ariel Vernaza Date: Tue, 2 Jun 2026 18:53:03 +0200 Subject: [PATCH 10/15] =?UTF-8?q?test(node):=20butterbase=20=E2=80=94=20ad?= =?UTF-8?q?d=20tool=5Fbutterbase=20to=20skip=5Fnodes=20(needs=20live=20API?= =?UTF-8?q?=20key)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Its dynamic test would connect to the Butterbase MCP server and requires a live bb_sk_ key, so exclude it from the default CI run (opt-in via ROCKETRIDE_INCLUDE_SKIP). Mocked unit tests still cover the logic. --- nodes/test/conftest.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/nodes/test/conftest.py b/nodes/test/conftest.py index 5ad67b1fd..69d0fda3c 100644 --- a/nodes/test/conftest.py +++ b/nodes/test/conftest.py @@ -242,6 +242,8 @@ def pytest_generate_tests(metafunc): 'audio_tts', # Temporarily exclude nodes with failing tests until they can be fixed and re-enabled: 'index_search', + # Require live third-party API credentials (no live calls in default CI): + 'tool_butterbase', } include_skip = {n.strip() for n in os.environ.get('ROCKETRIDE_INCLUDE_SKIP', '').split(',') if n.strip()} From f56b8f61e23d3b1408c183c91b359119faf99265 Mon Sep 17 00:00:00 2001 From: Ariel Vernaza Date: Tue, 2 Jun 2026 19:25:13 +0200 Subject: [PATCH 11/15] =?UTF-8?q?docs(example):=20butterbase=20pipe=20?= =?UTF-8?q?=E2=80=94=20agent=20calls=20butterbase=5Fdocs(overview)=20first?= =?UTF-8?q?=20to=20verify=20connectivity?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- examples/butterbase-agent.pipe | 68 +++++++++++++++++++--------------- 1 file changed, 39 insertions(+), 29 deletions(-) diff --git a/examples/butterbase-agent.pipe b/examples/butterbase-agent.pipe index 9c27cac22..907b67708 100644 --- a/examples/butterbase-agent.pipe +++ b/examples/butterbase-agent.pipe @@ -3,6 +3,7 @@ { "id": "chat_1", "provider": "chat", + "name": "Chat", "config": { "hideForm": true, "mode": "Source", @@ -21,9 +22,11 @@ { "id": "agent_rocketride_1", "provider": "agent_rocketride", + "name": "RocketRide Wave", "config": { "instructions": [ - "You are a backend engineer agent. Use the butterbase tools to provision and manage the backend: create apps, define and apply schemas, configure auth and row-level rules, manage storage, and deploy functions.", + "You are a backend engineer agent. I'm using Butterbase as my backend. Call the butterbase_docs tool with topic \"overview\" to learn about the platform before doing anything else.", + "Use the butterbase tools to provision and manage the backend: create apps, define and apply schemas, configure auth and row-level rules, manage storage, and deploy functions.", "Inspect available butterbase tools before acting, prefer dry-run/preview where offered, and confirm destructive changes." ], "max_waves": 20, @@ -44,34 +47,10 @@ } ] }, - { - "id": "llm_anthropic_1", - "provider": "llm_anthropic", - "config": { - "profile": "claude-sonnet-4-6", - "claude-sonnet-4-6": { - "apikey": "${ROCKETRIDE_ANTHROPIC_KEY}" - }, - "parameters": {} - }, - "ui": { - "position": { - "x": 110, - "y": 360 - }, - "nodeType": "default", - "formDataValid": true - }, - "control": [ - { - "classType": "llm", - "from": "agent_rocketride_1" - } - ] - }, { "id": "memory_internal_1", "provider": "memory_internal", + "name": "Memory (Internal)", "config": { "type": "memory_internal" }, @@ -93,6 +72,7 @@ { "id": "tool_butterbase_1", "provider": "tool_butterbase", + "name": "Butterbase MCP Client", "config": { "type": "tool_butterbase", "api_key": "${ROCKETRIDE_BUTTERBASE_API_KEY}", @@ -117,6 +97,7 @@ { "id": "response_answers_1", "provider": "response_answers", + "name": "Return Answers", "config": { "laneName": "answers" }, @@ -134,9 +115,38 @@ "from": "agent_rocketride_1" } ] + }, + { + "id": "llm_openai_1", + "provider": "llm_openai", + "name": "OpenAI", + "config": { + "profile": "openai-5-2", + "openai-5-2": { + "apikey": "${ROCKETRIDE_OPENAI_KEY}" + }, + "name": "OpenAI", + "parameters": { + "google": {} + } + }, + "ui": { + "position": { + "x": 80, + "y": 360 + }, + "nodeType": "default", + "formDataValid": true + }, + "control": [ + { + "classType": "llm", + "from": "agent_rocketride_1" + } + ] } ], - "project_id": "6ba2f198-2277-40ef-ac46-f00778cb043d", + "project_id": "217c9e93-9a48-403a-8349-7bfe9812702b", "version": 1, "isLocked": false, "snapToGrid": true, @@ -144,5 +154,5 @@ 10, 10 ], - "docRevision": 1 -} + "docRevision": 13 +} \ No newline at end of file From 883750ae4855205de31b7183e7a6591a6219d2a6 Mon Sep 17 00:00:00 2001 From: Ariel Vernaza Date: Tue, 2 Jun 2026 20:39:51 +0200 Subject: [PATCH 12/15] refactor(node): butterbase as a branded service*.json variant of tool_mcp_client MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Per review: the cloned tool_butterbase node was byte-identical to tool_mcp_client (transport + IInstance), so drop it and add a branded manifest (tool_mcp_client/services.butterbase.json) that reuses the same implementation via path=nodes.tool_mcp_client — same pattern as response/*. Keeps the 'Butterbase MCP Client' title + logo + Butterbase Streamable-HTTP preset, with zero duplicated code. Removes the standalone node, its test, and the conftest skip entry; example pipe updated to the mcp_client config shape (transport+bearer). --- examples/butterbase-agent.pipe | 7 +- nodes/src/nodes/tool_butterbase/IGlobal.py | 133 ------- nodes/src/nodes/tool_butterbase/IInstance.py | 73 ---- nodes/src/nodes/tool_butterbase/README.md | 55 --- nodes/src/nodes/tool_butterbase/__init__.py | 29 -- .../mcp_streamable_http_client.py | 370 ------------------ .../nodes/tool_butterbase/requirements.txt | 2 - nodes/src/nodes/tool_butterbase/services.json | 135 ------- .../tool_mcp_client/services.butterbase.json | 57 +++ nodes/test/conftest.py | 2 - nodes/test/tool_butterbase/__init__.py | 0 nodes/test/tool_butterbase/test_tools.py | 192 --------- 12 files changed, 61 insertions(+), 994 deletions(-) delete mode 100644 nodes/src/nodes/tool_butterbase/IGlobal.py delete mode 100644 nodes/src/nodes/tool_butterbase/IInstance.py delete mode 100644 nodes/src/nodes/tool_butterbase/README.md delete mode 100644 nodes/src/nodes/tool_butterbase/__init__.py delete mode 100644 nodes/src/nodes/tool_butterbase/mcp_streamable_http_client.py delete mode 100644 nodes/src/nodes/tool_butterbase/requirements.txt delete mode 100644 nodes/src/nodes/tool_butterbase/services.json create mode 100644 nodes/src/nodes/tool_mcp_client/services.butterbase.json delete mode 100644 nodes/test/tool_butterbase/__init__.py delete mode 100644 nodes/test/tool_butterbase/test_tools.py diff --git a/examples/butterbase-agent.pipe b/examples/butterbase-agent.pipe index 907b67708..d878e7b8a 100644 --- a/examples/butterbase-agent.pipe +++ b/examples/butterbase-agent.pipe @@ -75,9 +75,10 @@ "name": "Butterbase MCP Client", "config": { "type": "tool_butterbase", - "api_key": "${ROCKETRIDE_BUTTERBASE_API_KEY}", + "transport": "streamable-http", "endpoint": "https://api.butterbase.ai/mcp", - "serverName": "butterbase" + "serverName": "butterbase", + "bearer": "${ROCKETRIDE_BUTTERBASE_API_KEY}" }, "ui": { "position": { @@ -146,7 +147,7 @@ ] } ], - "project_id": "217c9e93-9a48-403a-8349-7bfe9812702b", + "project_id": "711db6c9-2a46-45e0-80b5-f9dbe0d6ce4a", "version": 1, "isLocked": false, "snapToGrid": true, diff --git a/nodes/src/nodes/tool_butterbase/IGlobal.py b/nodes/src/nodes/tool_butterbase/IGlobal.py deleted file mode 100644 index 76dd7a576..000000000 --- a/nodes/src/nodes/tool_butterbase/IGlobal.py +++ /dev/null @@ -1,133 +0,0 @@ -# ============================================================================= -# RocketRide Engine -# ============================================================================= -# MIT License -# Copyright (c) 2026 Aparavi Software AG -# -# Permission is hereby granted, free of charge, to any person obtaining a copy -# of this software and associated documentation files (the "Software"), to deal -# in the Software without restriction, including without limitation the rights -# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -# copies of the Software, and to permit persons to whom the Software is -# furnished to do so, subject to the following conditions: -# -# The above copyright notice and this permission notice shall be included in -# all copies or substantial portions of the Software. -# -# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE -# SOFTWARE. -# ============================================================================= - -""" -Butterbase tool node - global (shared) state. - -A purpose-built clone of the generic ``tool_mcp_client`` node, hardcoded to -Butterbase's Streamable HTTP MCP server (https://api.butterbase.ai/mcp). On -open it connects, performs the MCP initialize handshake, and discovers -Butterbase's tools (init_app, schema, auth, storage, functions, …) via -tools/list, caching them so the agent can call them as ``butterbase.``. - -Auth is a single Bearer API key (``bb_sk_...``). The only required config is -that key; the endpoint defaults to production and is overridable. -""" - -from __future__ import annotations - -import os -from typing import Any, Dict, List, Optional - -from ai.common.config import Config -from rocketlib import IGlobalBase, OPEN_MODE, error, warning - -from .mcp_streamable_http_client import McpStreamableHttpClient, McpToolDef - -_DEFAULT_ENDPOINT = 'https://api.butterbase.ai/mcp' -_SERVER_NAME = 'butterbase' - - -class IGlobal(IGlobalBase): - """Global state for tool_butterbase.""" - - serverName: str = _SERVER_NAME - endpoint: str = _DEFAULT_ENDPOINT - - def beginGlobal(self) -> None: - # Skip heavy initialization in CONFIG mode (matches other nodes). - if self.IEndpoint.endpoint.openMode == OPEN_MODE.CONFIG: - return - - cfg = Config.getNodeConfig(self.glb.logicalType, self.glb.connConfig) - - api_key = str(cfg.get('api_key') or os.environ.get('BUTTERBASE_API_KEY', '')).strip() - if not api_key: - error('tool_butterbase: api_key is required — set it in node config or BUTTERBASE_API_KEY env var') - raise ValueError('tool_butterbase: api_key is required') - - self.serverName = str(cfg.get('serverName') or _SERVER_NAME).strip() or _SERVER_NAME - self.endpoint = str(cfg.get('endpoint') or _DEFAULT_ENDPOINT).strip() or _DEFAULT_ENDPOINT - - headers: Dict[str, str] = {'Authorization': f'Bearer {api_key}'} - - try: - self._client = McpStreamableHttpClient(endpoint=self.endpoint, headers=headers) - self._client.start() - tools = self._client.list_tools() - self._cache_tools(tools) - except Exception as e: - warning(str(e)) - raise - - def validateConfig(self) -> None: - """Validate config at save-time with quick local checks.""" - try: - cfg = Config.getNodeConfig(self.glb.logicalType, self.glb.connConfig) - api_key = str(cfg.get('api_key') or os.environ.get('BUTTERBASE_API_KEY', '')).strip() - if not api_key: - warning('api_key is required (Butterbase bb_sk_... key)') - except Exception as e: - warning(str(e)) - - def endGlobal(self) -> None: - try: - client = getattr(self, '_client', None) - if client is not None: - client.stop() - finally: - self._client = None - self._tools_by_original = {} - self._tools_by_namespaced = {} - - # ------------------------------------------------------------------ - # Tool cache + accessors for IInstance hooks - # ------------------------------------------------------------------ - def _cache_tools(self, tools: List[McpToolDef]) -> None: - self._tools_by_original: Dict[str, McpToolDef] = {} - self._tools_by_namespaced: Dict[str, McpToolDef] = {} - for t in tools: - self._tools_by_original[t.name] = t - self._tools_by_namespaced[f'{self.serverName}.{t.name}'] = t - - def list_namespaced_tools(self) -> List[Dict[str, Any]]: - out: List[Dict[str, Any]] = [] - for namespaced, tool in (self._tools_by_namespaced or {}).items(): - out.append({'name': namespaced, 'description': tool.description, 'input_schema': tool.inputSchema}) - return out - - def get_tool(self, *, server_name: str, tool_name: str) -> Optional[McpToolDef]: - if server_name != self.serverName: - return None - return (self._tools_by_original or {}).get(tool_name) - - def call_tool(self, *, server_name: str, tool_name: str, arguments: Dict[str, Any]) -> Dict[str, Any]: - if server_name != self.serverName: - raise Exception( - f'Unknown Butterbase serverName {server_name!r} (this node configured as {self.serverName!r})' - ) - if self._client is None: - raise Exception('Butterbase MCP client is not connected') - return self._client.call_tool(name=tool_name, arguments=arguments or {}) diff --git a/nodes/src/nodes/tool_butterbase/IInstance.py b/nodes/src/nodes/tool_butterbase/IInstance.py deleted file mode 100644 index 346ae8ca0..000000000 --- a/nodes/src/nodes/tool_butterbase/IInstance.py +++ /dev/null @@ -1,73 +0,0 @@ -# ============================================================================= -# RocketRide Engine -# ============================================================================= -# MIT License -# Copyright (c) 2026 Aparavi Software AG -# -# Permission is hereby granted, free of charge, to any person obtaining a copy -# of this software and associated documentation files (the "Software"), to deal -# in the Software without restriction, including without limitation the rights -# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -# copies of the Software, and to permit persons to whom the Software is -# furnished to do so, subject to the following conditions: -# -# The above copyright notice and this permission notice shall be included in -# all copies or substantial portions of the Software. -# -# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE -# SOFTWARE. -# ============================================================================= - -""" -Butterbase tool node instance. - -Uses the dynamic tools escape hatch — Butterbase's tools are discovered at -runtime from its MCP server, not declared with @tool_function decorators. -""" - -from __future__ import annotations - -from typing import Any, Dict - -from rocketlib import IInstanceBase - -from .IGlobal import IGlobal - -_FRAMEWORK_KEYS = frozenset({'security_context'}) - - -class IInstance(IInstanceBase): - IGlobal: IGlobal - - def _tool_query_dynamic(self) -> list: - """Return tools discovered from the Butterbase MCP server.""" - return self.IGlobal.list_namespaced_tools() - - def _tool_invoke_dynamic(self, *, tool_name: str, input_obj: Any) -> Any: - """Dispatch a tool call to the Butterbase MCP server.""" - server_name, bare_tool = _split_tool_name(tool_name) - if input_obj is None: - arguments: Dict[str, Any] = {} - elif isinstance(input_obj, dict): - arguments = {k: v for k, v in input_obj.items() if k not in _FRAMEWORK_KEYS} - else: - raise ValueError('Tool input must be a JSON object (dict)') - return self.IGlobal.call_tool(server_name=server_name, tool_name=bare_tool, arguments=arguments) - - -def _split_tool_name(tool_name: str) -> tuple[str, str]: - """Split ``'server.tool'`` into ``('server', 'tool')``.""" - s = (tool_name or '').strip() - if '.' not in s: - raise ValueError(f'Tool name must be namespaced as `server.tool`; got {tool_name!r}') - server, bare = s.split('.', 1) - server = server.strip() - bare = bare.strip() - if not server or not bare: - raise ValueError(f'Tool name must be namespaced as `server.tool`; got {tool_name!r}') - return server, bare diff --git a/nodes/src/nodes/tool_butterbase/README.md b/nodes/src/nodes/tool_butterbase/README.md deleted file mode 100644 index c02eadb73..000000000 --- a/nodes/src/nodes/tool_butterbase/README.md +++ /dev/null @@ -1,55 +0,0 @@ -# Butterbase - -Connects an agent to the [Butterbase](https://butterbase.ai) MCP server and exposes its -backend tools for tool-calling. - -Butterbase is an AI-optimized Backend-as-a-Service: a managed database, authentication, -object storage, serverless functions, a model gateway, and native RAG. Through its MCP server the -agent can create apps, evolve schemas, configure auth/RLS, manage storage, deploy functions, -and deploy frontends — as tools named `butterbase.` (e.g. `butterbase.init_app`). - -This is a purpose-built clone of the generic `tool_mcp_client`, hardcoded to Butterbase's -Streamable HTTP endpoint so setup is a single API key. For other MCP servers, use -`tool_mcp_client`. - -## Prerequisite: enable Developer Mode - -The Butterbase app must have **Developer Mode enabled** for the agent to create or modify -resources (apps, schema, auth, etc.). Without it, write operations are rejected and the agent -can only read. Turn it on in the Butterbase dashboard before running a pipeline that -provisions a backend. - -## Connection - -| | | -| --- | --- | -| Endpoint | `https://api.butterbase.ai/mcp` (Streamable HTTP) | -| Auth | `Authorization: Bearer ` (`bb_sk_...`) | -| Tools | Discovered at runtime via `tools/list` (init_app, schema, auth, storage, functions, …) | - -Reference: [Butterbase MCP Setup](https://docs.butterbase.ai/getting-started/mcp-setup). - -## Configuration - -| Field | Required | Notes | -| --- | --- | --- | -| `api_key` | yes | Butterbase API key (`bb_sk_...`). Also reads `BUTTERBASE_API_KEY`. Stored encrypted. | -| `endpoint` | no | Defaults to `https://api.butterbase.ai/mcp`. | -| `serverName` | no | Tool namespace prefix. Defaults to `butterbase`. | - -## Wiring - -This is a `tool` node — wire it to an agent via `control` (class `tool`): - -```jsonc -{ - "id": "tool_butterbase_1", - "provider": "tool_butterbase", - "config": { "type": "tool_butterbase", "api_key": "${BUTTERBASE_API_KEY}" }, - "control": [{ "classType": "tool", "from": "agent_rocketride_1" }] -} -``` - -The node connects on open, discovers Butterbase's tools, and the agent calls them by their -namespaced names. Never commit keys — use node config (encrypted) or the `BUTTERBASE_API_KEY` -env var. diff --git a/nodes/src/nodes/tool_butterbase/__init__.py b/nodes/src/nodes/tool_butterbase/__init__.py deleted file mode 100644 index 37de11f28..000000000 --- a/nodes/src/nodes/tool_butterbase/__init__.py +++ /dev/null @@ -1,29 +0,0 @@ -# ============================================================================= -# RocketRide Engine -# ============================================================================= -# MIT License -# Copyright (c) 2026 Aparavi Software AG -# -# Permission is hereby granted, free of charge, to any person obtaining a copy -# of this software and associated documentation files (the "Software"), to deal -# in the Software without restriction, including without limitation the rights -# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -# copies of the Software, and to permit persons to whom the Software is -# furnished to do so, subject to the following conditions: -# -# The above copyright notice and this permission notice shall be included in -# all copies or substantial portions of the Software. -# -# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE -# SOFTWARE. -# ============================================================================= - -from .IGlobal import IGlobal -from .IInstance import IInstance - -__all__ = ['IGlobal', 'IInstance'] diff --git a/nodes/src/nodes/tool_butterbase/mcp_streamable_http_client.py b/nodes/src/nodes/tool_butterbase/mcp_streamable_http_client.py deleted file mode 100644 index a4e8adabb..000000000 --- a/nodes/src/nodes/tool_butterbase/mcp_streamable_http_client.py +++ /dev/null @@ -1,370 +0,0 @@ -# ============================================================================= -# RocketRide Engine -# ============================================================================= -# MIT License -# Copyright (c) 2026 Aparavi Software AG -# -# Permission is hereby granted, free of charge, to any person obtaining a copy -# of this software and associated documentation files (the "Software"), to deal -# in the Software without restriction, including without limitation the rights -# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -# copies of the Software, and to permit persons to whom the Software is -# furnished to do so, subject to the following conditions: -# -# The above copyright notice and this permission notice shall be included in -# all copies or substantial portions of the Software. -# -# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE -# SOFTWARE. -# ============================================================================= - -""" -Minimal MCP Streamable HTTP client (no external SDK dependency). - -Cloned from the generic ``tool_mcp_client`` transport — Butterbase exposes a -Streamable HTTP MCP endpoint (GET/POST/DELETE on ``/mcp``), so this is the only -transport the Butterbase node needs. - -Spec reference: - MCP spec 2025-03-26: Streamable HTTP transport - - single MCP endpoint path supporting POST/GET - - client POSTs JSON-RPC messages to endpoint - - server responds with application/json OR text/event-stream - - server may return Mcp-Session-Id header at initialization - -This client supports: -- initialize + notifications/initialized -- tools/list -- tools/call - -It supports both response modes: -- Content-Type: application/json -- Content-Type: text/event-stream (SSE stream) -""" - -from __future__ import annotations - -import json -import time -import urllib.error -import urllib.parse -import urllib.request -from dataclasses import dataclass -from typing import Any, Dict, Iterable, List, Optional - - -class McpProtocolError(RuntimeError): - pass - - -class McpHttpStatusError(RuntimeError): - def __init__(self, *, status: int, body: bytes | None = None, url: str | None = None) -> None: - """Create an HTTP status error with response context.""" - super().__init__(f'HTTP status={status} url={url!r} body={(body or b"")[:200]!r}') - self.status = int(status) - self.body = body - self.url = url - - -@dataclass(frozen=True) -class McpToolDef: - name: str - description: str - inputSchema: Dict[str, Any] - - -# Defaults (avoid magic constants in the client signature). -_MCP_PROTOCOL_VERSION = '2025-11-25' -_CLIENT_NAME = 'RocketRideButterbaseMcpClient' -_CLIENT_VERSION = '0.1.0' -_DEFAULT_TIMEOUT_S = 20.0 - - -class McpStreamableHttpClient: - def __init__( - self, - *, - endpoint: str, - headers: Optional[Dict[str, str]] = None, - protocol_version: str = _MCP_PROTOCOL_VERSION, - client_name: str = _CLIENT_NAME, - client_version: str = _CLIENT_VERSION, - timeout_s: float = _DEFAULT_TIMEOUT_S, - ) -> None: - """Create an MCP streamable HTTP client.""" - self._endpoint = str(endpoint).strip() - if not self._endpoint: - raise ValueError('endpoint is required') - - self._timeout_s = float(timeout_s) - self._protocol_version = protocol_version - self._client_info = {'name': client_name, 'version': client_version} - - self._headers = {str(k): str(v) for k, v in (headers or {}).items()} - # Required by the Streamable HTTP spec: list both content types. - self._headers.setdefault('Accept', 'application/json, text/event-stream') - self._headers.setdefault('User-Agent', f'{client_name}/{client_version}') - - self._next_id = 1 - self._session_id: str | None = None - self._started = False - - # ------------------------------------------------------------------ - # Lifecycle - # ------------------------------------------------------------------ - def start(self) -> None: - if self._started: - raise RuntimeError('MCP streamable-http client already started') - - init_result, resp_headers = self._request_with_headers( - 'initialize', - { - 'protocolVersion': self._protocol_version, - 'capabilities': {'roots': {'listChanged': False}, 'sampling': {}}, - 'clientInfo': self._client_info, - }, - ) - if not isinstance(init_result, dict): - raise McpProtocolError(f'initialize result expected object, got {type(init_result)}') - - # Session management (optional). - sid = _get_header(resp_headers, 'Mcp-Session-Id') - if sid: - self._session_id = sid - - # notifications/initialized (a notification only; expect 202 if accepted). - self._notify('notifications/initialized', None) - self._started = True - - def stop(self) -> None: - # Optional: explicitly terminate session if supported. - sid = self._session_id - self._session_id = None - self._started = False - if not sid: - return - - headers = self._build_headers() - headers['Mcp-Session-Id'] = sid - req = urllib.request.Request(self._endpoint, headers=headers, method='DELETE') - try: - with urllib.request.urlopen(req, timeout=self._timeout_s) as resp: - _ = resp.read() - except urllib.error.HTTPError as e: - # 405 is allowed by spec (server may not allow clients to terminate). - if int(getattr(e, 'code', 0)) == 405: - return - except Exception: - # Best-effort only. - return - - # ------------------------------------------------------------------ - # MCP operations - # ------------------------------------------------------------------ - def list_tools(self) -> List[McpToolDef]: - result = self._request('tools/list', {}) - if not isinstance(result, dict): - raise McpProtocolError(f'tools/list result expected object, got {type(result)}') - tools = result.get('tools', []) - if not isinstance(tools, list): - raise McpProtocolError(f'tools/list result.tools expected list, got {type(tools)}') - - out: List[McpToolDef] = [] - for t in tools: - if not isinstance(t, dict): - continue - name = t.get('name') - if not isinstance(name, str) or not name: - continue - desc = t.get('description') if isinstance(t.get('description'), str) else '' - schema = t.get('inputSchema') if isinstance(t.get('inputSchema'), dict) else {'type': 'object'} - out.append(McpToolDef(name=name, description=desc, inputSchema=schema)) - return out - - def call_tool(self, *, name: str, arguments: Dict[str, Any]) -> Dict[str, Any]: - result = self._request('tools/call', {'name': name, 'arguments': arguments or {}}) - if not isinstance(result, dict): - raise McpProtocolError(f'tools/call result expected object, got {type(result)}') - return result - - # ------------------------------------------------------------------ - # JSON-RPC internals - # ------------------------------------------------------------------ - def _notify(self, method: str, params: Any) -> None: # noqa: ANN401 - msg: Dict[str, Any] = {'jsonrpc': '2.0', 'method': method} - if params is not None: - msg['params'] = params - self._post_notification(msg) - - def _request(self, method: str, params: Any) -> Any: # noqa: ANN401 - result, _headers = self._request_with_headers(method, params) - return result - - def _request_with_headers(self, method: str, params: Any) -> tuple[Any, Dict[str, str]]: # noqa: ANN401 - req_id = self._next_id - self._next_id += 1 - msg: Dict[str, Any] = {'jsonrpc': '2.0', 'id': req_id, 'method': method} - if params is not None: - msg['params'] = params - - return self._post_request_and_wait(req_id=req_id, payload=msg) - - def _post_notification(self, payload: Dict[str, Any]) -> None: - data = json.dumps(payload, ensure_ascii=False).encode('utf-8') - headers = self._build_headers() - headers['Content-Type'] = 'application/json' - if self._session_id: - headers['Mcp-Session-Id'] = self._session_id - - req = urllib.request.Request(self._endpoint, data=data, headers=headers, method='POST') - try: - with urllib.request.urlopen(req, timeout=self._timeout_s) as resp: - status = int(getattr(resp, 'status', 200)) - body = resp.read() or b'' - # For notifications/responses-only, spec requires 202 on accept (no body). - if status == 202: - return - if 200 <= status < 300 and not body: - return - # Some servers may return 200 with empty body; accept it. - if 200 <= status < 300 and body == b'': - return - except urllib.error.HTTPError as e: - raise McpHttpStatusError(status=int(e.code), body=_safe_read_http_error(e), url=self._endpoint) from e - - def _post_request_and_wait(self, *, req_id: int, payload: Dict[str, Any]) -> tuple[Any, Dict[str, str]]: # noqa: ANN401 - data = json.dumps(payload, ensure_ascii=False).encode('utf-8') - headers = self._build_headers() - headers['Content-Type'] = 'application/json' - if self._session_id: - headers['Mcp-Session-Id'] = self._session_id - - req = urllib.request.Request(self._endpoint, data=data, headers=headers, method='POST') - try: - with urllib.request.urlopen(req, timeout=self._timeout_s) as resp: - status = int(getattr(resp, 'status', 200)) - resp_headers = {k: v for (k, v) in (resp.headers.items() if resp.headers else [])} - if status == 202: - # Shouldn't happen for a request, but tolerate. - return None, resp_headers - - ctype = (resp.headers.get('Content-Type') or '').lower() if resp.headers else '' - if 'text/event-stream' in ctype: - result = self._read_sse_until_response(resp, req_id=req_id) - return result, resp_headers - - body = resp.read() or b'' - result = _parse_jsonrpc_response_body(body=body, req_id=req_id) - return result, resp_headers - except urllib.error.HTTPError as e: - raise McpHttpStatusError(status=int(e.code), body=_safe_read_http_error(e), url=self._endpoint) from e - - def _read_sse_until_response(self, resp, *, req_id: int) -> Any: # noqa: ANN401 - deadline = time.time() + self._timeout_s - - event_data_lines: List[str] = [] - # Note: Streamable HTTP doesn't require named events; parse "data:" generically. - while True: - if time.time() > deadline: - raise TimeoutError(f'SSE stream timed out waiting for response id={req_id}') - raw = resp.readline() - if not raw: - # Stream ended; if no response seen, error. - raise TimeoutError(f'SSE stream ended before response id={req_id}') - - try: - line = raw.decode('utf-8', errors='replace') - except Exception: - line = str(raw) - line = line.rstrip('\r\n') - - if not line: - # end of SSE event - if event_data_lines: - data = '\n'.join(event_data_lines) - for msg in _iter_jsonrpc_messages_from_sse_data(data): - maybe = _match_jsonrpc_id(msg, req_id=req_id) - if maybe is not None: - return maybe - event_data_lines = [] - continue - - if line.startswith(':'): - continue - if line.startswith('data:'): - event_data_lines.append(line.split(':', 1)[1].lstrip()) - continue - # ignore event:, id:, retry: - - def _build_headers(self) -> Dict[str, str]: - # Make a fresh headers dict per request. - return dict(self._headers) - - -def _safe_read_http_error(e: urllib.error.HTTPError) -> bytes: - try: - return e.read() # type: ignore[no-any-return] - except Exception: - return b'' - - -def _get_header(headers: Dict[str, str], name: str) -> str | None: - lname = name.lower() - for k, v in headers.items(): - if str(k).lower() == lname: - s = str(v).strip() - return s or None - return None - - -def _parse_jsonrpc_response_body(*, body: bytes, req_id: int) -> Any: # noqa: ANN401 - if not body: - raise McpProtocolError('Empty response body for JSON-RPC request') - try: - obj = json.loads(body.decode('utf-8')) - except Exception as e: - raise McpProtocolError(f'Invalid JSON response: {body[:200]!r}') from e - - # Body may be a single response or a batch. - for msg in _iter_jsonrpc_messages(obj): - maybe = _match_jsonrpc_id(msg, req_id=req_id) - if maybe is not None: - return maybe - raise McpProtocolError(f'No JSON-RPC response found for id={req_id}') - - -def _iter_jsonrpc_messages(obj: Any) -> Iterable[dict]: # noqa: ANN401 - if isinstance(obj, dict): - yield obj - elif isinstance(obj, list): - for it in obj: - if isinstance(it, dict): - yield it - - -def _iter_jsonrpc_messages_from_sse_data(data: str) -> Iterable[dict]: - if not data: - return - try: - obj = json.loads(data) - except Exception: - return - for msg in _iter_jsonrpc_messages(obj): - yield msg - - -def _match_jsonrpc_id(msg: dict, *, req_id: int) -> Any | None: # noqa: ANN401 - if not isinstance(msg, dict): - return None - if msg.get('id') != req_id: - return None - if 'error' in msg and isinstance(msg['error'], dict): - code = msg['error'].get('code') - message = msg['error'].get('message') - raise McpProtocolError(f'MCP error (id={req_id}) code={code} message={message}') - return msg.get('result') diff --git a/nodes/src/nodes/tool_butterbase/requirements.txt b/nodes/src/nodes/tool_butterbase/requirements.txt deleted file mode 100644 index 0ea2d693f..000000000 --- a/nodes/src/nodes/tool_butterbase/requirements.txt +++ /dev/null @@ -1,2 +0,0 @@ -# No external dependencies — the Streamable HTTP MCP client uses only the -# Python standard library (urllib, json). Mirrors tool_mcp_client. diff --git a/nodes/src/nodes/tool_butterbase/services.json b/nodes/src/nodes/tool_butterbase/services.json deleted file mode 100644 index 37fa4be08..000000000 --- a/nodes/src/nodes/tool_butterbase/services.json +++ /dev/null @@ -1,135 +0,0 @@ -{ - // - // Required: - // The displayable name of this node - // - "title": "Butterbase MCP Client", - // - // Required: - // The protocol is the endpoint protocol - // - "protocol": "tool_butterbase://", - // - // Required: - // Class type of the node - what it does - // - "classType": ["tool"], - // - // Required: - // Capabilities are flags that change the behavior of the underlying engine - // - "capabilities": ["invoke"], - // - // Optional: - // Register is either filter, endpoint or ignored if not specified - // - "register": "filter", - // - // Optional: - // The node is the actual physical node to instantiate - // - "node": "python", - // - // Optional: - // The path is the executable/script code - // - "path": "nodes.tool_butterbase", - // - // Required: - // The prefix map when converting URLs <=> paths (tools surface as .) - // - "prefix": "butterbase", - // - // Optional: - // The icon to display in the UI for this node (a URL renders via ) - // - "icon": "https://api.smithery.ai/servers/butterbase/butterbase/icon", - "documentation": "https://docs.butterbase.ai", - // - // Optional: - // Description of this node shown in the pipeline builder - // - "description": ["Connects to the Butterbase MCP server and exposes its backend tools for agent tool-calling.", "Butterbase is an AI-optimized Backend-as-a-Service (managed database, authentication, object storage, serverless functions, RAG). The agent can create apps, evolve schemas, configure auth, deploy functions, and more — as tools named butterbase..", "Prerequisite: enable Developer Mode on your Butterbase app so the agent can create/modify resources (without it, write operations are rejected)."], - // - // Optional: - // Rendering hints for the UI: which config fields to surface on the node tile - // - "tile": ["Server: ${parameters.butterbase.serverName}"], - // - // Optional: - // As a pipe component, define what this component takes and produces. - // This is a control-plane tool node (no data lanes). - // - "lanes": {}, - // - // Optional: - // Profile section - configuration options used by the driver itself. - // The default profile is applied when the node is first added to a pipeline. - // - "preconfig": { - "default": "default", - "profiles": { - "default": { - "title": "Butterbase MCP Client", - "serverName": "butterbase", - "endpoint": "https://api.butterbase.ai/mcp", - "api_key": "" - } - } - }, - // - // Optional: - // Local field definitions. These define the config fields exposed in the UI. - // "secure: true" fields are encrypted at rest and masked in the UI. - // - "fields": { - "butterbase.api_key": { - "type": "string", - "title": "API Key", - "description": "Butterbase API key (bb_sk_...). Create one in the Butterbase dashboard: dashboard.butterbase.ai → API Keys (or via the generate_service_key tool). Sent as an Authorization Bearer token.", - "default": "", - "secure": true, - "ui": { - "ui:widget": "ApiKeyWidget" - } - }, - "butterbase.endpoint": { - "type": "string", - "title": "Endpoint", - "description": "Butterbase MCP Streamable HTTP endpoint. Defaults to the production server.", - "default": "https://api.butterbase.ai/mcp" - }, - "butterbase.serverName": { - "type": "string", - "title": "Server name", - "description": "Namespace prefix for the discovered tools: . (example: butterbase.init_app).", - "default": "butterbase" - } - }, - // - // Optional: - // Test configuration for automated node testing. The API key is a - // placeholder — config validation is tested here, not live API calls. - // - "test": { - "profiles": ["default"], - "outputs": [], - "cases": [ - { - "name": "Config validation with placeholder key", - "text": "test" - } - ] - }, - // - // Required: - // Defines the fields (shape) shown in the pipeline builder side panel. - // - "shape": [ - { - "section": "Pipe", - "title": "Butterbase MCP Client", - "properties": ["type", "butterbase.api_key", "butterbase.endpoint", "butterbase.serverName"] - } - ] -} diff --git a/nodes/src/nodes/tool_mcp_client/services.butterbase.json b/nodes/src/nodes/tool_mcp_client/services.butterbase.json new file mode 100644 index 000000000..ec0d131b0 --- /dev/null +++ b/nodes/src/nodes/tool_mcp_client/services.butterbase.json @@ -0,0 +1,57 @@ +{ + "title": "Butterbase MCP Client", + "protocol": "tool_butterbase://", + "classType": ["tool"], + "capabilities": ["invoke"], + "register": "filter", + "node": "python", + "path": "nodes.tool_mcp_client", + "prefix": "butterbase", + "icon": "https://api.smithery.ai/servers/butterbase/butterbase/icon", + "documentation": "https://docs.butterbase.ai", + "description": ["Connects to the Butterbase MCP server and exposes its backend tools for agent tool-calling.", "Butterbase is an AI-optimized Backend-as-a-Service (managed database, authentication, object storage, serverless functions, RAG). The agent can create apps, evolve schemas, configure auth, deploy functions, and more — as tools named butterbase..", "This is a branded preset of the generic MCP Client (Streamable HTTP), so it shares the same implementation. Prerequisite: enable Developer Mode on your Butterbase app so the agent can create/modify resources."], + "tile": ["Server: ${parameters.mcp_client.serverName}"], + "lanes": {}, + "preconfig": { + "default": "butterbase", + "profiles": { + "butterbase": { + "title": "Butterbase MCP server", + "serverName": "butterbase", + "transport": "streamable-http", + "endpoint": "https://api.butterbase.ai/mcp" + } + } + }, + "fields": { + "mcp_client.bearer": { + "type": "string", + "title": "API Key", + "description": "Butterbase API key (bb_sk_...). Create one in the Butterbase dashboard: dashboard.butterbase.ai → API Keys. Sent as an Authorization Bearer token.", + "default": "", + "secure": true, + "ui": { + "ui:widget": "ApiKeyWidget" + } + }, + "mcp_client.endpoint": { + "type": "string", + "title": "Endpoint", + "description": "Butterbase MCP Streamable HTTP endpoint. Defaults to the production server.", + "default": "https://api.butterbase.ai/mcp" + }, + "mcp_client.serverName": { + "type": "string", + "title": "Server name", + "description": "Namespace prefix for the discovered tools: . (example: butterbase.init_app).", + "default": "butterbase" + } + }, + "shape": [ + { + "section": "Pipe", + "title": "Butterbase MCP Client", + "properties": ["type", "mcp_client.bearer", "mcp_client.endpoint", "mcp_client.serverName"] + } + ] +} diff --git a/nodes/test/conftest.py b/nodes/test/conftest.py index 69d0fda3c..5ad67b1fd 100644 --- a/nodes/test/conftest.py +++ b/nodes/test/conftest.py @@ -242,8 +242,6 @@ def pytest_generate_tests(metafunc): 'audio_tts', # Temporarily exclude nodes with failing tests until they can be fixed and re-enabled: 'index_search', - # Require live third-party API credentials (no live calls in default CI): - 'tool_butterbase', } include_skip = {n.strip() for n in os.environ.get('ROCKETRIDE_INCLUDE_SKIP', '').split(',') if n.strip()} diff --git a/nodes/test/tool_butterbase/__init__.py b/nodes/test/tool_butterbase/__init__.py deleted file mode 100644 index e69de29bb..000000000 diff --git a/nodes/test/tool_butterbase/test_tools.py b/nodes/test/tool_butterbase/test_tools.py deleted file mode 100644 index c4fca227d..000000000 --- a/nodes/test/tool_butterbase/test_tools.py +++ /dev/null @@ -1,192 +0,0 @@ -# ============================================================================= -# MIT License -# Copyright (c) 2026 Aparavi Software AG -# ============================================================================= - -"""Unit tests for the tool_butterbase node. - -Pure-Python: no server, no engine, no real HTTP. The node modules are imported -under composable stubs for ``rocketlib`` and ``ai.common.config`` so the -relative imports resolve without the engine runtime. The MCP transport is -never hit — we exercise the tool cache, namespacing, and dispatch directly with -a fake client. - -Covers: -* ``_split_tool_name`` — namespaced parsing and rejection of bad shapes. -* ``IGlobal._cache_tools`` + accessors — namespacing, lookup, scope guard. -* ``IGlobal.call_tool`` — routes to the client / rejects unknown server. -* ``IInstance._tool_invoke_dynamic`` — strips framework keys, dispatches. -""" - -from __future__ import annotations - -import sys -import types -from pathlib import Path - -import pytest - -_NODE_DIR = Path(__file__).resolve().parent.parent.parent / 'src' / 'nodes' / 'tool_butterbase' - - -# --------------------------------------------------------------------------- -# Composable import scaffolding (augments existing stubs, never clobbers) -# --------------------------------------------------------------------------- - - -def _ensure_rocketlib() -> None: - mod = sys.modules.get('rocketlib') or types.ModuleType('rocketlib') - if not hasattr(mod, 'IInstanceBase'): - mod.IInstanceBase = type('IInstanceBase', (), {}) - if not hasattr(mod, 'IGlobalBase'): - mod.IGlobalBase = type('IGlobalBase', (), {}) - if not hasattr(mod, 'OPEN_MODE'): - mod.OPEN_MODE = type('OPEN_MODE', (), {'CONFIG': 'config'}) - for name in ('debug', 'error', 'warning'): - if not hasattr(mod, name): - setattr(mod, name, lambda *a, **k: None) - sys.modules['rocketlib'] = mod - - -def _ensure_ai_common() -> None: - for name in ('ai', 'ai.common', 'ai.common.config'): - if name not in sys.modules: - sys.modules[name] = types.ModuleType(name) - if not hasattr(sys.modules['ai.common.config'], 'Config'): - - class _Config: - @staticmethod - def getNodeConfig(*_a, **_k): - return {} - - sys.modules['ai.common.config'].Config = _Config - - -def _ensure_pkg() -> None: - if 'tool_butterbase' not in sys.modules: - pkg = types.ModuleType('tool_butterbase') - pkg.__path__ = [str(_NODE_DIR)] - sys.modules['tool_butterbase'] = pkg - - -_ensure_rocketlib() -_ensure_ai_common() -_ensure_pkg() - -from tool_butterbase.IGlobal import IGlobal # noqa: E402 -from tool_butterbase.IInstance import IInstance, _split_tool_name # noqa: E402 -from tool_butterbase.mcp_streamable_http_client import McpToolDef # noqa: E402 - - -# --------------------------------------------------------------------------- -# Fakes -# --------------------------------------------------------------------------- - - -class _FakeClient: - def __init__(self): - self.calls = [] - self.stopped = False - - def call_tool(self, *, name, arguments): - self.calls.append({'name': name, 'arguments': arguments}) - return {'content': [{'type': 'text', 'text': f'ok:{name}'}]} - - def stop(self): - self.stopped = True - - -def _global_with_tools(*tool_names, server='butterbase'): - glb = IGlobal() - glb.serverName = server - glb._client = _FakeClient() - glb._cache_tools([McpToolDef(name=n, description=f'{n} desc', inputSchema={'type': 'object'}) for n in tool_names]) - return glb - - -# --------------------------------------------------------------------------- -# _split_tool_name -# --------------------------------------------------------------------------- - - -def test_split_tool_name_ok(): - assert _split_tool_name('butterbase.init_app') == ('butterbase', 'init_app') - # only the first dot splits — tool names may themselves contain dots - assert _split_tool_name('butterbase.schema.apply') == ('butterbase', 'schema.apply') - - -@pytest.mark.parametrize('bad', ['init_app', 'butterbase.', '.init_app', ' ', '']) -def test_split_tool_name_rejects_bad(bad): - with pytest.raises(ValueError): - _split_tool_name(bad) - - -# --------------------------------------------------------------------------- -# IGlobal cache + accessors -# --------------------------------------------------------------------------- - - -def test_cache_and_list_namespaced_tools(): - glb = _global_with_tools('init_app', 'apply_schema') - listed = {t['name']: t for t in glb.list_namespaced_tools()} - assert set(listed) == {'butterbase.init_app', 'butterbase.apply_schema'} - assert listed['butterbase.init_app']['description'] == 'init_app desc' - assert listed['butterbase.init_app']['input_schema'] == {'type': 'object'} - - -def test_get_tool_scope_guard(): - glb = _global_with_tools('init_app') - assert glb.get_tool(server_name='butterbase', tool_name='init_app').name == 'init_app' - assert glb.get_tool(server_name='butterbase', tool_name='nope') is None - # wrong server namespace → None - assert glb.get_tool(server_name='other', tool_name='init_app') is None - - -def test_call_tool_routes_to_client(): - glb = _global_with_tools('init_app') - out = glb.call_tool(server_name='butterbase', tool_name='init_app', arguments={'name': 'demo'}) - assert out['content'][0]['text'] == 'ok:init_app' - assert glb._client.calls == [{'name': 'init_app', 'arguments': {'name': 'demo'}}] - - -def test_call_tool_rejects_unknown_server(): - glb = _global_with_tools('init_app') - with pytest.raises(Exception): - glb.call_tool(server_name='other', tool_name='init_app', arguments={}) - - -# --------------------------------------------------------------------------- -# IInstance dynamic dispatch -# --------------------------------------------------------------------------- - - -def test_invoke_dynamic_strips_framework_keys_and_dispatches(): - glb = _global_with_tools('init_app') - inst = IInstance() - inst.IGlobal = glb - - # _tool_query_dynamic surfaces the namespaced tools - assert {t['name'] for t in inst._tool_query_dynamic()} == {'butterbase.init_app'} - - inst._tool_invoke_dynamic( - tool_name='butterbase.init_app', - input_obj={'name': 'demo', 'security_context': {'token': 'secret'}}, - ) - # framework key 'security_context' must be stripped before reaching the server - assert glb._client.calls[-1] == {'name': 'init_app', 'arguments': {'name': 'demo'}} - - -def test_invoke_dynamic_none_input_is_empty_args(): - glb = _global_with_tools('list_apps') - inst = IInstance() - inst.IGlobal = glb - inst._tool_invoke_dynamic(tool_name='butterbase.list_apps', input_obj=None) - assert glb._client.calls[-1] == {'name': 'list_apps', 'arguments': {}} - - -def test_invoke_dynamic_non_dict_input_raises(): - glb = _global_with_tools('init_app') - inst = IInstance() - inst.IGlobal = glb - with pytest.raises(ValueError): - inst._tool_invoke_dynamic(tool_name='butterbase.init_app', input_obj=['not', 'a', 'dict']) From 17f49b39b45c2d187683e384a8e67b6f80ad2ac3 Mon Sep 17 00:00:00 2001 From: Ariel Vernaza Date: Tue, 2 Jun 2026 20:42:12 +0200 Subject: [PATCH 13/15] =?UTF-8?q?docs(example):=20butterbase=20pipe=20?= =?UTF-8?q?=E2=80=94=20encode=20the=20gotchas=20we=20learned=20fixing=20th?= =?UTF-8?q?e=20forum?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Enrich the agent instructions with the practical lessons from repairing a real Butterbase app: enable Developer Mode for writes; keep schema/frontend field names consistent (body vs content); use the absolute data API base and the separate /auth/ base; set is_published for anon reads; auto-login after signup (no email verification needed); ensure the frontend deployment reaches READY. --- examples/butterbase-agent.pipe | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/examples/butterbase-agent.pipe b/examples/butterbase-agent.pipe index d878e7b8a..24efd5947 100644 --- a/examples/butterbase-agent.pipe +++ b/examples/butterbase-agent.pipe @@ -25,8 +25,10 @@ "name": "RocketRide Wave", "config": { "instructions": [ - "You are a backend engineer agent. I'm using Butterbase as my backend. Call the butterbase_docs tool with topic \"overview\" to learn about the platform before doing anything else.", - "Use the butterbase tools to provision and manage the backend: create apps, define and apply schemas, configure auth and row-level rules, manage storage, and deploy functions.", + "You are a backend engineer agent. I'm using Butterbase as my backend. Call the butterbase_docs tool with topic \"overview\" first to learn the platform.", + "Provision and manage the backend with the butterbase tools: create the app, define and apply the schema, configure auth and row-level rules, seed data, and deploy functions/frontend. Developer Mode must be enabled on the app for create/modify operations to succeed.", + "Keep field names identical between the database schema and any frontend or code you generate — if the column is \"body\", read and write \"body\", not \"content\".", + "When you build a frontend for the app: use the ABSOLUTE data API base https://api.butterbase.ai/v1/, and the auth base https://api.butterbase.ai/auth/ for signup/login/me (auth is NOT under /v1). Set is_published=true so anonymous visitors can read public rows, auto-login right after signup (email verification is not required to log in), and confirm the frontend deployment reaches status READY so the domain serves the real app.", "Inspect available butterbase tools before acting, prefer dry-run/preview where offered, and confirm destructive changes." ], "max_waves": 20, @@ -147,7 +149,7 @@ ] } ], - "project_id": "711db6c9-2a46-45e0-80b5-f9dbe0d6ce4a", + "project_id": "5bd9113f-6fba-485d-b01f-be37e8493598", "version": 1, "isLocked": false, "snapToGrid": true, From c549c772b30b784a52c2db4c9616048a8f2d65d0 Mon Sep 17 00:00:00 2001 From: Ariel Vernaza Date: Tue, 2 Jun 2026 20:49:24 +0200 Subject: [PATCH 14/15] =?UTF-8?q?fix(example,node):=20address=20PR=20#1069?= =?UTF-8?q?=20review=20=E2=80=94=20Anthropic=20LLM,=20namespaced=20docs=20?= =?UTF-8?q?tool,=20commented=20manifest?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Example pipe: switch LLM to llm_anthropic (claude-sonnet-4-6, ROCKETRIDE_ANTHROPIC_KEY) to match the Wave+Anthropic+Butterbase intent. - Example pipe: the agent calls the runtime-namespaced tool 'butterbase.butterbase_docs' (serverName.tool), not the flat name. - services.butterbase.json: add the // Required/Optional comment blocks (matching llm_anthropic style) and ensure a trailing newline. --- examples/butterbase-agent.pipe | 21 +++--- .../tool_mcp_client/services.butterbase.json | 64 +++++++++++++++++++ 2 files changed, 73 insertions(+), 12 deletions(-) diff --git a/examples/butterbase-agent.pipe b/examples/butterbase-agent.pipe index 24efd5947..da851a103 100644 --- a/examples/butterbase-agent.pipe +++ b/examples/butterbase-agent.pipe @@ -25,7 +25,7 @@ "name": "RocketRide Wave", "config": { "instructions": [ - "You are a backend engineer agent. I'm using Butterbase as my backend. Call the butterbase_docs tool with topic \"overview\" first to learn the platform.", + "You are a backend engineer agent. I'm using Butterbase as my backend. Call the butterbase.butterbase_docs tool with topic \"overview\" first to learn the platform.", "Provision and manage the backend with the butterbase tools: create the app, define and apply the schema, configure auth and row-level rules, seed data, and deploy functions/frontend. Developer Mode must be enabled on the app for create/modify operations to succeed.", "Keep field names identical between the database schema and any frontend or code you generate — if the column is \"body\", read and write \"body\", not \"content\".", "When you build a frontend for the app: use the ABSOLUTE data API base https://api.butterbase.ai/v1/, and the auth base https://api.butterbase.ai/auth/ for signup/login/me (auth is NOT under /v1). Set is_published=true so anonymous visitors can read public rows, auto-login right after signup (email verification is not required to log in), and confirm the frontend deployment reaches status READY so the domain serves the real app.", @@ -120,18 +120,15 @@ ] }, { - "id": "llm_openai_1", - "provider": "llm_openai", - "name": "OpenAI", + "id": "llm_anthropic_1", + "provider": "llm_anthropic", + "name": "Anthropic", "config": { - "profile": "openai-5-2", - "openai-5-2": { - "apikey": "${ROCKETRIDE_OPENAI_KEY}" + "profile": "claude-sonnet-4-6", + "claude-sonnet-4-6": { + "apikey": "${ROCKETRIDE_ANTHROPIC_KEY}" }, - "name": "OpenAI", - "parameters": { - "google": {} - } + "parameters": {} }, "ui": { "position": { @@ -149,7 +146,7 @@ ] } ], - "project_id": "5bd9113f-6fba-485d-b01f-be37e8493598", + "project_id": "b5c8c9fa-a05d-4c9c-b7e0-fd250ee5b679", "version": 1, "isLocked": false, "snapToGrid": true, diff --git a/nodes/src/nodes/tool_mcp_client/services.butterbase.json b/nodes/src/nodes/tool_mcp_client/services.butterbase.json index ec0d131b0..3a90f9b3b 100644 --- a/nodes/src/nodes/tool_mcp_client/services.butterbase.json +++ b/nodes/src/nodes/tool_mcp_client/services.butterbase.json @@ -1,17 +1,72 @@ { + // + // Required: + // The displayable name of this node + // "title": "Butterbase MCP Client", + // + // Required: + // The protocol is the endpoint protocol + // "protocol": "tool_butterbase://", + // + // Required: + // Class type of the node - what it does + // "classType": ["tool"], + // + // Required: + // Capabilities are flags that change the behavior of the underlying engine + // "capabilities": ["invoke"], + // + // Optional: + // Register is either filter, endpoint or ignored if not specified + // "register": "filter", + // + // Optional: + // The node is the actual physical node to instantiate + // "node": "python", + // + // Optional: + // Reuses the generic MCP client implementation — this is a branded preset, + // not a separate node (no duplicated code). + // "path": "nodes.tool_mcp_client", + // + // Required: + // The prefix map when converting URLs <=> paths (tools surface as .) + // "prefix": "butterbase", + // + // Optional: + // The icon to display in the UI for this node (a URL renders via ) + // "icon": "https://api.smithery.ai/servers/butterbase/butterbase/icon", "documentation": "https://docs.butterbase.ai", + // + // Optional: + // Description of this node shown in the pipeline builder + // "description": ["Connects to the Butterbase MCP server and exposes its backend tools for agent tool-calling.", "Butterbase is an AI-optimized Backend-as-a-Service (managed database, authentication, object storage, serverless functions, RAG). The agent can create apps, evolve schemas, configure auth, deploy functions, and more — as tools named butterbase..", "This is a branded preset of the generic MCP Client (Streamable HTTP), so it shares the same implementation. Prerequisite: enable Developer Mode on your Butterbase app so the agent can create/modify resources."], + // + // Optional: + // Rendering hints for the UI: which config fields to surface on the node tile + // "tile": ["Server: ${parameters.mcp_client.serverName}"], + // + // Optional: + // As a pipe component, define what this component takes and produces. + // This is a control-plane tool node (no data lanes). + // "lanes": {}, + // + // Optional: + // Profile section - configuration options used by the driver itself. + // The default profile pins the Butterbase Streamable HTTP endpoint. + // "preconfig": { "default": "butterbase", "profiles": { @@ -23,6 +78,11 @@ } } }, + // + // Optional: + // Local field definitions. These define the config fields exposed in the UI. + // "secure: true" fields are encrypted at rest and masked in the UI. + // "fields": { "mcp_client.bearer": { "type": "string", @@ -47,6 +107,10 @@ "default": "butterbase" } }, + // + // Required: + // Defines the fields (shape) shown in the pipeline builder side panel. + // "shape": [ { "section": "Pipe", From bf17c310ed97e58fe08d88c03794ec006e87bf40 Mon Sep 17 00:00:00 2001 From: Ariel Vernaza Date: Tue, 2 Jun 2026 21:03:03 +0200 Subject: [PATCH 15/15] =?UTF-8?q?feat(node):=20butterbase=20=E2=80=94=20ve?= =?UTF-8?q?ndor=20the=20icon=20locally=20instead=20of=20the=20Smithery=20U?= =?UTF-8?q?RL?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Download the Butterbase mark, downscale to 96px, embed as a data-URI in a local butterbase.svg (next to mcp.svg) and point the manifest icon at it. Removes the runtime dependency on the external Smithery URL (which could disappear). --- nodes/src/nodes/tool_mcp_client/butterbase.svg | 1 + nodes/src/nodes/tool_mcp_client/services.butterbase.json | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) create mode 100644 nodes/src/nodes/tool_mcp_client/butterbase.svg diff --git a/nodes/src/nodes/tool_mcp_client/butterbase.svg b/nodes/src/nodes/tool_mcp_client/butterbase.svg new file mode 100644 index 000000000..b031f5385 --- /dev/null +++ b/nodes/src/nodes/tool_mcp_client/butterbase.svg @@ -0,0 +1 @@ +Butterbase diff --git a/nodes/src/nodes/tool_mcp_client/services.butterbase.json b/nodes/src/nodes/tool_mcp_client/services.butterbase.json index 3a90f9b3b..83b3771cf 100644 --- a/nodes/src/nodes/tool_mcp_client/services.butterbase.json +++ b/nodes/src/nodes/tool_mcp_client/services.butterbase.json @@ -44,7 +44,7 @@ // Optional: // The icon to display in the UI for this node (a URL renders via ) // - "icon": "https://api.smithery.ai/servers/butterbase/butterbase/icon", + "icon": "butterbase.svg", "documentation": "https://docs.butterbase.ai", // // Optional: