commit ab1cfc88da04fdcffbc52035774139d4c18bfc92 Author: WordVibe Date: Tue Sep 22 01:43:11 2026 +0300 feat: initial wordvibe-hermes plugin Hermes Agent plugin that drives WordPress sites running the WordVibe plugin over the site MCP server (POST /wp-json/wp-ide/v1/mcp, Bearer key from Settings -> MCP). 8 lean tools (sites, site_info, tools, call, read_file, write_file, edit_file, search) + /wordvibe slash command + bundled skill. The site exposes ~100 tools; the rest are reachable via wordvibe_call to keep the per-turn schema small. Verified against a mock of the plugin JSON-RPC server: 15/15 scenarios (initialize, tools/list, tools/call, blocked tools, unknown tool, 403 when MCP off, connection refused, plain-permalink ?rest_route= fallback). diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..3c77005 --- /dev/null +++ b/.gitignore @@ -0,0 +1,9 @@ +__pycache__/ +*.py[cod] +*.egg-info/ +.venv/ +venv/ +.env +.DS_Store +.idea/ +.vscode/ diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..aaea194 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 WordVibe + +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. diff --git a/README.md b/README.md new file mode 100644 index 0000000..155312b --- /dev/null +++ b/README.md @@ -0,0 +1,114 @@ +# wordvibe-hermes + +A Hermes Agent plugin that lets your agent drive **WordPress sites running the WordVibe plugin** — +read and write files, make targeted code edits, search the codebase, query the database, manage +posts/plugins/themes, run WP-CLI and more — from any Hermes surface (CLI, Telegram, cron, subagents). + +It talks to the MCP server that ships **inside the WordVibe WordPress plugin**, so there is nothing +extra to install on the site: + +| | | +|---|---| +| **Endpoint** | `POST {site}/wp-json/wp-ide/v1/mcp` (Streamable HTTP, JSON-RPC 2.0) | +| **Auth** | `Authorization: Bearer ` (or a WP Application Password) | +| **Gate** | WordVibe → **Settings → MCP** → enable **MCP Server Mode**, copy the key | +| **Tools** | the plugin's own agent tool registry (`wv_ai_tools()`), minus `php_eval` and `db_mutate`, which the site blocks remotely | +| **Protocol** | MCP `2024-11-05`; methods `initialize`, `initialized`, `tools/list`, `tools/call`, `ping` | + +## Install + +```bash +# from a git URL +hermes plugins install https://github.com//wordvibe-hermes + +# or drop the folder in place +git clone https://github.com//wordvibe-hermes ~/.hermes/plugins/wordvibe-hermes +``` + +Then add the credentials to your Hermes `.env` (same dir as `config.yaml` — `hermes config env-path`): + +```env +WORDVIBE_SITE_URL=https://mysite.com +WORDVIBE_MCP_KEY=the-key-from-settings-mcp +``` + +Serve several sites instead — `WORDVIBE_MCP_SITES` takes precedence: + +```env +WORDVIBE_MCP_SITES=[{"name":"client-a","url":"https://a.com","key":"..."},{"name":"client-b","url":"https://b.com","key":"..."}] +``` + +Enable the plugin and restart Hermes (plugins are opt-in; tool changes need a fresh session): + +```bash +hermes plugins enable wordvibe-hermes +hermes plugins list # expect: wordvibe-hermes 1.0.0 8 tools +hermes # then: /wordvibe sites +``` + +Debug a plugin that won't load with `HERMES_PLUGINS_DEBUG=1 hermes plugins list`. + +## Tools + +| Tool | What it does | +|---|---| +| `wordvibe_sites` | List configured sites (optionally probe reachability of each MCP endpoint) | +| `wordvibe_site_info` | MCP server identity (plugin version) + WordPress name/version/theme/plugin count | +| `wordvibe_tools` | The catalogue of tools the site exposes (filterable) | +| `wordvibe_call` | **Escape hatch** — call any site tool by name with its own arguments | +| `wordvibe_read_file` | Read any file in the WP install (optional `lines="10-60"`) | +| `wordvibe_write_file` | Overwrite a file (the site snapshots the previous version first) | +| `wordvibe_edit_file` | Targeted `search` → `replace` edit (safer than a full write) | +| `wordvibe_search` | Codebase search across themes/plugins/core, plain text or regex | + +Slash command for when you don't want to spend a model turn: + +``` +/wordvibe sites +/wordvibe info flexi +/wordvibe tools +/wordvibe call post_list '{"post_type":"post","limit":5}' +``` + +### Why only 8 tools + +The site exposes ~100 tools over MCP. Registering all of them would put ~100 schemas on every API +call (Hermes sends the whole tool schema set each turn), so this plugin registers a lean wrapper set +and routes everything else through `wordvibe_call` — the agent asks `wordvibe_tools` what exists, +then calls it. If you'd rather have a specific tool as first-class, add it to `schemas.py` + `tools.py`. + +## Using it well + +- Always `wordvibe_read_file` before `wordvibe_write_file`. Prefer `wordvibe_edit_file` for small changes. +- `db_query` is read-only by design; `db_mutate` and `php_eval` are blocked from MCP — use the site's + own UI or a snapshot-backed file edit instead. +- Writes are real. The site keeps a snapshot of the previous version (`wp-content/wv-safety/snapshots/`) + so `rollback_list` / `rollback_restore` can undo a mistake — mention it to the user when you change code. +- Handy pass-throughs: `post_list`, `plugin_list`, `theme_list`, `list_directory`, `file_search`, + `security_scan_installed`, `cli_exec`, `create_post`, `update_option`, `fetch_url`. + +## Layout + +``` +wordvibe-hermes/ +├── plugin.yaml # manifest (tools + required env) +├── __init__.py # register(ctx): tools, /wordvibe command, hook +├── schemas.py # what the LLM reads +├── tools.py # handlers (JSON in, JSON out, never raise) +├── mcp_client.py # stdlib JSON-RPC client for the site MCP endpoint +└── skills/ + └── wordvibe-sites/SKILL.md +``` + +No third-party dependencies — `urllib` only. + +## Security + +- The bearer key grants the full remote tool surface of the site (minus the two blocked tools). Treat + it like an admin password: keep it in `.env`, never in the repo, rotate it from Settings → MCP. +- Per-site: disabling **MCP Server Mode** immediately makes the endpoint return 403. +- Hermes's secret redaction applies to tool output, but the key itself should still only live in `.env`. + +## License + +MIT diff --git a/__init__.py b/__init__.py new file mode 100644 index 0000000..5c9a8fd --- /dev/null +++ b/__init__.py @@ -0,0 +1,81 @@ +"""wordvibe-hermes — registration. + +Connects Hermes to WordPress sites running the WordVibe plugin (which ships an +MCP server at /wp-json/wp-ide/v1/mcp) and exposes a lean set of tools for +driving those sites from any Hermes surface — CLI, Telegram, cron, subagents. +""" + +import logging + +from . import schemas, tools + +logger = logging.getLogger(__name__) + +TOOLSET = "wordvibe" + + +def _split_commands(raw: str) -> list: + return [c for c in (raw or "").split() if c] + + +def _handle_command(ctx, raw_args: str) -> str: + """`/wordvibe [sites|info|tools|call ]` — quick access without a model turn.""" + parts = _split_commands(raw_args) + sub = (parts[0].lower() if parts else "sites") + + if sub in ("sites", "list"): + return tools.sites({"probe": True}, ctx=ctx) + if sub in ("info", "status"): + return tools.site_info({"site": parts[1] if len(parts) > 1 else ""}, ctx=ctx) + if sub in ("tools", "listtools"): + return tools.tools({"site": parts[1] if len(parts) > 1 else ""}, ctx=ctx) + if sub in ("call", "run") and len(parts) >= 2: + import json as _json + + tool_name = parts[1] + raw_json = raw_args.split(tool_name, 1)[1].strip() if tool_name in raw_args else "" + try: + arguments = _json.loads(raw_json) if raw_json else {} + except Exception as exc: # noqa: BLE001 + return tools._err(f"arguments must be JSON: {exc}") + return tools.call({"tool": tool_name, "arguments": arguments}, ctx=ctx) + + return tools._ok( + { + "usage": "/wordvibe sites | info [site] | tools [site] | call '{json}'", + "example": "/wordvibe call post_list '{\"post_type\":\"post\",\"limit\":5}'", + } + ) + + +def _on_post_tool_call(tool_name, args, result, task_id, **kwargs): + """Log wordvibe_* traffic at debug level (visibility without noise).""" + if str(tool_name).startswith("wordvibe_"): + logger.debug("wordvibe tool %s (session %s)", tool_name, task_id) + + +def register(ctx): + """Wire the wordvibe tools into the Hermes tool registry.""" + registry = [ + ("wordvibe_sites", schemas.SITES, tools.sites), + ("wordvibe_site_info", schemas.SITE_INFO, tools.site_info), + ("wordvibe_tools", schemas.TOOLS, tools.tools), + ("wordvibe_call", schemas.CALL, tools.call), + ("wordvibe_read_file", schemas.READ_FILE, tools.read_file), + ("wordvibe_write_file", schemas.WRITE_FILE, tools.write_file), + ("wordvibe_edit_file", schemas.EDIT_FILE, tools.edit_file), + ("wordvibe_search", schemas.SEARCH, tools.search), + ] + + for name, schema, handler in registry: + ctx.register_tool(name=name, toolset=TOOLSET, schema=schema, handler=handler) + + ctx.register_command( + "wordvibe", + lambda raw: _handle_command(ctx, raw), + description="WordVibe sites: `sites`, `info [site]`, `tools [site]`, `call '{json}'`", + ) + + ctx.register_hook("post_tool_call", _on_post_tool_call) + + logger.info("wordvibe-hermes registered %d tools", len(registry)) diff --git a/mcp_client.py b/mcp_client.py new file mode 100644 index 0000000..a84f5ff --- /dev/null +++ b/mcp_client.py @@ -0,0 +1,301 @@ +"""Minimal JSON-RPC 2.0 client for the WordVibe MCP server (stdlib only). + +The WordVibe WordPress plugin ships an MCP server at: + + POST {site}/wp-json/wp-ide/v1/mcp (pretty permalinks) + POST {site}/index.php?rest_route=/wp-ide/v1/mcp (plain permalinks) + Auth: Authorization: Bearer + Gate: option wv_mcp_server_enabled must be true (WordVibe → Settings → MCP) + +Both URL shapes are accepted, and a 404 on the /wp-json/ form automatically +retries via ?rest_route= (the form the settings screen shows on plain-permalink +sites such as flexi.com.et). + +Supported methods: initialize, initialized, tools/list, tools/call, ping. +Tools exposed = the plugin's own agent tool registry (`wv_ai_tools()`), +minus `php_eval` and `db_mutate`, which the server blocks remotely. +""" + +import json +import os +import ssl +import urllib.error +import urllib.request + +MCP_PATH = "/wp-json/wp-ide/v1/mcp" +MCP_REST_ROUTE = "index.php?rest_route=/wp-ide/v1/mcp" +PROTOCOL_VERSION = "2024-11-05" +DEFAULT_TIMEOUT = 120 + + +def _endpoint(site_url: str) -> str: + """Normalise a site root (or a full MCP URL) into the MCP endpoint. + + Accepts every form the WordVibe settings screen hands out: + https://site.com + https://site.com/wp-json/wp-ide/v1/mcp + https://site.com/index.php?rest_route=/wp-ide/v1/mcp (plain permalinks) + """ + url = (site_url or "").strip().rstrip("/") + if not url: + return "" + if "rest_route=" in url or url.endswith("/mcp") or "/wp-json/" in url: + return url + return url + MCP_PATH + + +def _alt_endpoint(endpoint: str) -> str: + """Plain-permalink fallback for a /wp-json/ endpoint (404 → ?rest_route=).""" + if "/wp-json/" in endpoint: + root = endpoint.split("/wp-json/", 1)[0] + return f"{root.rstrip('/')}/{MCP_REST_ROUTE}" + return "" + + +class Site: + """One configured WordPress site.""" + + def __init__(self, name: str, url: str, key: str, timeout: int = DEFAULT_TIMEOUT): + self.name = name + self.url = url + self.key = key + self.timeout = timeout + # Set once the ?rest_route= fallback has proven to be the working form. + self._use_alt = False + + @property + def endpoint(self) -> str: + return _endpoint(self.url) + + @property + def active_endpoint(self) -> str: + if self._use_alt: + alt = _alt_endpoint(self.endpoint) + if alt: + return alt + return self.endpoint + + def __repr__(self) -> str: # pragma: no cover - debug helper + return f"" + + +def load_sites() -> list: + """Read site config from the environment. + + Preferred (multi-site): + WORDVIBE_MCP_SITES='[{"name":"client-a","url":"https://a.com","key":"..."}, + {"name":"client-b","url":"https://b.com","key":"..."}]' + + Simple (single site): + WORDVIBE_SITE_URL=https://a.com + WORDVIBE_MCP_KEY=... + """ + sites = [] + + raw = (os.getenv("WORDVIBE_MCP_SITES") or "").strip() + if raw: + try: + data = json.loads(raw) + if isinstance(data, dict): + data = [data] + for i, item in enumerate(data or []): + if not isinstance(item, dict): + continue + url = str(item.get("url") or "").strip() + if not url: + continue + sites.append( + Site( + name=str(item.get("name") or f"site{i + 1}").strip(), + url=url, + key=str(item.get("key") or item.get("token") or "").strip(), + timeout=int(item.get("timeout") or DEFAULT_TIMEOUT), + ) + ) + except Exception: # noqa: BLE001 - malformed JSON must not break the plugin + pass + + if not sites: + url = (os.getenv("WORDVIBE_SITE_URL") or "").strip() + key = (os.getenv("WORDVIBE_MCP_KEY") or "").strip() + if url: + sites.append(Site(name=os.getenv("WORDVIBE_SITE_NAME") or "default", url=url, key=key)) + + return sites + + +def resolve_site(site: str = "") -> Site: + """Resolve a site reference (name or URL) to a configured Site. + + Falls back to the first configured site when `site` is empty. + Raises LookupError when nothing matches — callers turn that into JSON. + """ + sites = load_sites() + if not sites: + raise LookupError( + "No WordVibe site configured. Set WORDVIBE_SITE_URL + WORDVIBE_MCP_KEY " + "(or WORDVIBE_MCP_SITES for multiple sites) and restart Hermes." + ) + + want = (site or "").strip().lower() + if not want: + return sites[0] + + for s in sites: + if s.name.lower() == want or s.url.lower().rstrip("/") == want.rstrip("/"): + return s + for s in sites: + if want in s.url.lower() or want in s.name.lower(): + return s + + known = ", ".join(f"{s.name} ({s.url})" for s in sites) + raise LookupError(f"Unknown site '{site}'. Configured sites: {known}") + + +def _decode_body(raw: bytes) -> str: + """Decode a JSON or SSE-framed response body.""" + text = raw.decode("utf-8", "replace").strip() + if text.startswith("event:") or text.startswith("data:"): + for line in text.splitlines(): + if line.startswith("data:"): + return line[5:].strip() + return text + + +class WordVibeMCP: + """JSON-RPC client bound to one site.""" + + def __init__(self, site: Site): + self.site = site + self._next_id = 1 + + # -- transport --------------------------------------------------------- + def _post(self, url: str, payload: dict) -> dict: + """POST one JSON-RPC request to `url` and return the parsed response.""" + headers = { + "Content-Type": "application/json", + "Accept": "application/json, text/event-stream", + "User-Agent": "wordvibe-hermes/1.0", + } + if self.site.key: + headers["Authorization"] = f"Bearer {self.site.key}" + + req = urllib.request.Request( + url, + data=json.dumps(payload).encode("utf-8"), + headers=headers, + method="POST", + ) + + try: + with urllib.request.urlopen( + req, timeout=self.site.timeout, context=ssl.create_default_context() + ) as resp: + status = resp.status + body = _decode_body(resp.read()) + except urllib.error.HTTPError as exc: + detail = "" + try: + detail = _decode_body(exc.read())[:400] + except Exception: # noqa: BLE001 + pass + hint = "" + if exc.code == 403: + hint = " (MCP Server Mode is probably off: enable it in WordVibe → Settings → MCP)" + elif exc.code == 401: + hint = " (check the WORDVIBE_MCP_KEY bearer token)" + return { + "error": { + "code": exc.code, + "message": f"HTTP {exc.code}{hint} {detail}".strip(), + "_url": url, + } + } + except Exception as exc: # noqa: BLE001 - network/SSL/timeout + return {"error": {"code": -1, "message": f"{type(exc).__name__}: {exc}", "_url": url}} + + if not body: + return {"error": {"code": status, "message": "Empty response body", "_url": url}} + + try: + return json.loads(body) + except Exception: # noqa: BLE001 + return { + "error": {"code": status, "message": f"Non-JSON response: {body[:300]}", "_url": url} + } + + def rpc(self, method: str, params: dict = None) -> dict: + """POST one JSON-RPC request, retrying via ?rest_route= on a 404.""" + if not self.site.endpoint: + return {"error": {"code": -1, "message": "Site URL is empty"}} + + payload = { + "jsonrpc": "2.0", + "id": self._next_id, + "method": method, + "params": params or {}, + } + self._next_id += 1 + + result = self._post(self.site.active_endpoint, payload) + + # Plain-permalink sites 404 on /wp-json/ — retry the rest_route form once, + # then stick with whichever form worked for the rest of the session. + if (result.get("error") or {}).get("code") == 404 and not self.site._use_alt: + alt = _alt_endpoint(self.site.endpoint) + if alt: + retry = self._post(alt, payload) + if (retry.get("error") or {}).get("code") != 404: + self.site._use_alt = True + return retry + return result + + # -- MCP surface ------------------------------------------------------- + def initialize(self) -> dict: + return self.rpc( + "initialize", + { + "protocolVersion": PROTOCOL_VERSION, + "capabilities": {}, + "clientInfo": {"name": "wordvibe-hermes", "version": "1.0.0"}, + }, + ) + + def list_tools(self) -> dict: + return self.rpc("tools/list", {}) + + def call_tool(self, name: str, arguments: dict = None) -> dict: + return self.rpc("tools/call", {"name": name, "arguments": arguments or {}}) + + +def result_text(response: dict) -> str: + """Flatten an MCP tools/call response into plain text.""" + if not isinstance(response, dict): + return str(response) + + if "error" in response and response.get("error"): + err = response["error"] + if isinstance(err, dict): + return f"ERROR: {err.get('message') or json.dumps(err)}" + return f"ERROR: {err}" + + result = response.get("result") or {} + if isinstance(result, dict) and result.get("isError"): + parts = result.get("content") or [] + return "ERROR: " + " ".join( + str(c.get("text", "")) for c in parts if isinstance(c, dict) + ) + + content = result.get("content") if isinstance(result, dict) else None + if isinstance(content, list): + chunks = [] + for c in content: + if isinstance(c, dict): + if c.get("type") == "text": + chunks.append(str(c.get("text", ""))) + else: + chunks.append(json.dumps(c)) + if chunks: + return "\n".join(chunks) + + return json.dumps(result, indent=2, default=str) diff --git a/plugin.yaml b/plugin.yaml new file mode 100644 index 0000000..13a0e19 --- /dev/null +++ b/plugin.yaml @@ -0,0 +1,24 @@ +name: wordvibe-hermes +version: 1.0.0 +description: >- + Drive WordPress sites that run the WordVibe plugin — read/write files, edit code, + search the codebase, query the database, manage posts/plugins, run WP-CLI — through + the site's own built-in MCP server (WordVibe → Settings → MCP). +author: WordVibe +author_url: https://wordvibe.io +homepage: https://wordvibe.io +provides_tools: + - wordvibe_sites + - wordvibe_site_info + - wordvibe_tools + - wordvibe_call + - wordvibe_read_file + - wordvibe_write_file + - wordvibe_edit_file + - wordvibe_search +requires_env: + - name: WORDVIBE_SITE_URL + description: "WordPress site URL (e.g. https://mysite.com). MCP Server Mode must be enabled in WordVibe → Settings → MCP." + - name: WORDVIBE_MCP_KEY + description: "MCP server key from WordVibe → Settings → MCP (Bearer token)." + secret: true diff --git a/schemas.py b/schemas.py new file mode 100644 index 0000000..c967f69 --- /dev/null +++ b/schemas.py @@ -0,0 +1,164 @@ +"""Tool schemas — what the LLM reads to decide when to call these tools. + +Deliberately small (8 tools). The WordVibe site exposes ~100 tools over MCP; +registering all of them would put ~100 schemas on every API call. Everything +beyond these wrappers is reachable through `wordvibe_call`. +""" + +_SITE = { + "type": "string", + "description": ( + "Which configured site to use: its name or URL " + "(e.g. 'mysite' or 'https://mysite.com'). Omit to use the first configured site." + ), +} + +SITES = { + "name": "wordvibe_sites", + "description": ( + "List the WordPress sites this plugin can drive (configured via WORDVIBE_SITE_URL / " + "WORDVIBE_MCP_SITES). Optionally probes each one to report whether its WordVibe MCP " + "server is reachable and enabled." + ), + "parameters": { + "type": "object", + "properties": { + "probe": { + "type": "boolean", + "description": "Also connect to each site and report reachability (default false).", + }, + }, + }, +} + +SITE_INFO = { + "name": "wordvibe_site_info", + "description": ( + "Report what a WordVibe site is running: MCP server name/version (the plugin version), " + "WordPress name/version/URL, active theme, and plugin count. Use this first when you need " + "to know what you're working with." + ), + "parameters": {"type": "object", "properties": {"site": _SITE}}, +} + +TOOLS = { + "name": "wordvibe_tools", + "description": ( + "List the tools the WordVibe site exposes over MCP — file read/write/edit, codebase " + "search, database queries, posts/media, plugin & theme management, WP-CLI, cron, security " + "scans, and more. Call this when you need a capability that isn't covered by the " + "wordvibe_read_file / wordvibe_write_file / wordvibe_edit_file / wordvibe_search wrappers, " + "then invoke it with wordvibe_call." + ), + "parameters": { + "type": "object", + "properties": { + "site": _SITE, + "filter": { + "type": "string", + "description": "Optional case-insensitive substring to filter tool names/descriptions.", + }, + }, + }, +} + +CALL = { + "name": "wordvibe_call", + "description": ( + "Call any tool on the WordVibe site by name (see wordvibe_tools for the catalogue). " + "This is the escape hatch for everything without a dedicated wrapper — e.g. post_list, " + "plugin_list, db_query (SELECT only), cli_exec, security_scan_installed, list_directory, " + "file_search, create_post. Arguments are passed through to the site tool as-is." + ), + "parameters": { + "type": "object", + "properties": { + "tool": {"type": "string", "description": "Site tool name, e.g. 'post_list'."}, + "arguments": { + "type": "object", + "description": "Arguments for the site tool (its own parameter names).", + }, + "site": _SITE, + }, + "required": ["tool"], + }, +} + +READ_FILE = { + "name": "wordvibe_read_file", + "description": ( + "Read a file from a WordVibe site (any path inside the WordPress install, e.g. " + "'wp-content/themes/my-theme/functions.php' or 'wp-config.php'). Always read a file " + "before writing or editing it." + ), + "parameters": { + "type": "object", + "properties": { + "path": {"type": "string", "description": "Path relative to the WordPress root."}, + "lines": { + "type": "string", + "description": "Optional line range, e.g. '1-80' or '120-160'. Omit for the whole file.", + }, + "site": _SITE, + }, + "required": ["path"], + }, +} + +WRITE_FILE = { + "name": "wordvibe_write_file", + "description": ( + "Write (overwrite) a file on a WordVibe site. The site takes a safety snapshot of the " + "previous version before saving, so a rollback is always possible. Read the file first." + ), + "parameters": { + "type": "object", + "properties": { + "path": {"type": "string", "description": "Path relative to the WordPress root."}, + "content": {"type": "string", "description": "Full new file content."}, + "site": _SITE, + }, + "required": ["path", "content"], + }, +} + +EDIT_FILE = { + "name": "wordvibe_edit_file", + "description": ( + "Make a targeted edit on a WordVibe site: replace the first exact occurrence of `search` " + "with `replace`. Preferred over wordvibe_write_file for small changes — it can't clobber " + "the rest of the file." + ), + "parameters": { + "type": "object", + "properties": { + "path": {"type": "string", "description": "Path relative to the WordPress root."}, + "search": {"type": "string", "description": "Exact text to find."}, + "replace": {"type": "string", "description": "Replacement text."}, + "site": _SITE, + }, + "required": ["path", "search", "replace"], + }, +} + +SEARCH = { + "name": "wordvibe_search", + "description": ( + "Search a WordVibe site's codebase (themes, plugins, mu-plugins, core) and return matching " + "lines with context. Supports plain text or regex, scoped to a path and/or file pattern." + ), + "parameters": { + "type": "object", + "properties": { + "query": {"type": "string", "description": "Text or regex to search for."}, + "path": { + "type": "string", + "description": "Optional subdirectory, e.g. 'wp-content/plugins/my-plugin'.", + }, + "include": {"type": "string", "description": "Optional file glob, e.g. '*.php'."}, + "regex": {"type": "boolean", "description": "Treat `query` as a regular expression."}, + "site": _SITE, + }, + "required": ["query"], + }, +} diff --git a/skills/wordvibe-sites/SKILL.md b/skills/wordvibe-sites/SKILL.md new file mode 100644 index 0000000..412e0ec --- /dev/null +++ b/skills/wordvibe-sites/SKILL.md @@ -0,0 +1,57 @@ +--- +name: wordvibe-sites +description: "Use when driving a WordPress site through the WordVibe plugin (wordvibe_* tools). Covers site selection, read-before-write, snapshots/rollback, and which site tools to reach for." +version: 1.0.0 +author: WordVibe +license: MIT +metadata: + hermes: + tags: [wordpress, wordvibe, mcp, files, agents] +--- + +# Driving WordPress with the wordvibe_* tools + +The `wordvibe_*` tools talk to the MCP server inside the **WordVibe** WordPress plugin. One +configured site is the default; when several are configured, pass `site` (name or URL). + +## Order of operations + +1. `wordvibe_sites(probe=true)` — confirm the site(s) and that MCP is reachable. +2. `wordvibe_site_info` — WordPress version, active theme, plugin count, WordVibe plugin version. +3. `wordvibe_tools` — the catalogue. Filter it (`filter="post"`, `filter="security"`) when hunting. +4. Do the work with the wrappers, or `wordvibe_call` for anything without a wrapper. + +## Rules that keep you out of trouble + +- **Read before write.** `wordvibe_read_file` first, then `wordvibe_edit_file` (targeted + `search` → `replace`) rather than `wordvibe_write_file` (full overwrite). Read again after a batch + of edits. +- **Snapshots exist.** Every save on the site captures the previous version. If you break something, + `wordvibe_call` with `rollback_list`, then `rollback_restore`. Say so to the user instead of + improvising a fix. +- **`db_query` is SELECT-only**; `db_mutate` and `php_eval` are blocked over MCP by design. If a task + truly needs SQL writes or code execution, tell the user it has to be done in the site's own UI. +- **`cli_exec` runs WP-CLI** on the site (`wp plugin list`, `wp post list …`). Prefer the dedicated + tools (`plugin_list`, `post_list`) — they return structured output. +- **Never invent paths.** `list_directory` and `file_search` (or `wordvibe_search`) resolve the real + layout; `get_file_info` confirms a file exists. +- **Writes are live.** They hit a production site. Before a destructive or wide change, state what + you're about to change and why, and keep the change minimal. + +## Common tasks → tools + +| Task | Call | +|---|---| +| Find code | `wordvibe_search(query="add_filter", include="*.php")` | +| Inspect a plugin | `wordvibe_call(tool="plugin_list")`, then `list_directory path="wp-content/plugins/"` | +| Read/update a post | `wordvibe_call(tool="post_list")` → `wordvibe_call(tool="get_post")` → `update_post` | +| Database lookups | `wordvibe_call(tool="db_query", arguments={"sql":"SELECT COUNT(*) FROM wp_posts"})` | +| Security sweep | `wordvibe_call(tool="security_scan_installed")` | +| Roll back an edit | `wordvibe_call(tool="rollback_list")` → `rollback_restore` | +| Site basics | `wordvibe_call(tool="site_info")`, `theme_list`, `plugin_list` | + +## Multi-site + +With `WORDVIBE_MCP_SITES` configured, name the target explicitly on every call — do not let a default +silently decide which client's site you just wrote to. When the user says "my site" and more than one +is configured, ask which one before writing anything. diff --git a/tools.py b/tools.py new file mode 100644 index 0000000..34caf2a --- /dev/null +++ b/tools.py @@ -0,0 +1,214 @@ +"""Tool handlers — the code that runs when the LLM calls a wordvibe_* tool. + +Rules (per the Hermes plugin contract): + 1. signature: def handler(args: dict, **kwargs) -> str + 2. always return a JSON string — success and errors alike + 3. never raise +""" + +import json + +from .mcp_client import WordVibeMCP, load_sites, resolve_site, result_text + +# Keep tool results context-friendly. +MAX_CHARS = 40000 + + +def _ok(payload: dict) -> str: + return json.dumps(payload, default=str) + + +def _err(message: str, **extra) -> str: + payload = {"error": message} + payload.update(extra) + return json.dumps(payload, default=str) + + +def _clip(text: str, limit: int = MAX_CHARS) -> str: + if len(text) <= limit: + return text + return text[:limit] + f"\n…[truncated at {limit} chars — narrow the query or use line ranges]" + + +def _site_for(args: dict): + """Resolve the requested site, or raise LookupError with a readable message.""" + return resolve_site(args.get("site") or "") + + +def _call_tool(site_ref: str, tool: str, arguments: dict) -> str: + """Call one site tool and return clipped text (or an error string).""" + try: + site = resolve_site(site_ref) + except LookupError as exc: + return "ERROR: " + str(exc) + + client = WordVibeMCP(site) + try: + response = client.call_tool(tool, arguments or {}) + except Exception as exc: # noqa: BLE001 - belt and braces; rpc() already guards + return f"ERROR: {type(exc).__name__}: {exc}" + + return _clip(result_text(response)) + + +# -------------------------------------------------------------------------- +# Site management / discovery +# -------------------------------------------------------------------------- +def sites(args: dict, **kwargs) -> str: + """List configured sites, optionally probing each MCP endpoint.""" + try: + configured = load_sites() + except Exception as exc: # noqa: BLE001 + return _err(f"Could not read site config: {exc}") + + if not configured: + return _err( + "No site configured. Set WORDVIBE_SITE_URL + WORDVIBE_MCP_KEY " + "(or WORDVIBE_MCP_SITES for several) in the Hermes .env, then restart Hermes." + ) + + probe = bool(args.get("probe")) + out = [] + for s in configured: + row = {"name": s.name, "url": s.url, "endpoint": s.endpoint, "key_set": bool(s.key)} + if probe: + client = WordVibeMCP(s) + init = client.initialize() + if isinstance(init, dict) and init.get("error"): + row["reachable"] = False + row["error"] = (init.get("error") or {}).get("message", "unknown error") + else: + info = ((init or {}).get("result") or {}).get("serverInfo") or {} + row["reachable"] = True + row["server"] = info.get("name", "wordvibe-ide") + row["plugin_version"] = info.get("version", "?") + out.append(row) + + return _ok({"sites": out, "count": len(out)}) + + +def site_info(args: dict, **kwargs) -> str: + """MCP server identity + the site's own site_info tool.""" + try: + site = _site_for(args) + except LookupError as exc: + return _err(str(exc)) + + client = WordVibeMCP(site) + init = client.initialize() + if isinstance(init, dict) and init.get("error"): + return _err( + "Could not reach the MCP server on %s: %s" % (site.url, (init.get("error") or {}).get("message")), + hint="Enable MCP Server Mode in WordVibe → Settings → MCP, and check WORDVIBE_MCP_KEY.", + ) + + result = (init or {}).get("result") or {} + payload = { + "site": site.name, + "url": site.url, + "endpoint": site.endpoint, + "mcp_protocol": result.get("protocolVersion"), + "server": result.get("serverInfo"), + } + + # The plugin also exposes a `site_info` tool with WordPress-level details. + wp = client.call_tool("site_info", {}) + wp_text = result_text(wp) + if not wp_text.startswith("ERROR:"): + payload["wordpress"] = _clip(wp_text, 6000) + + return _ok(payload) + + +def tools(args: dict, **kwargs) -> str: + """List the tools the site exposes over MCP (the plugin's agent tool registry).""" + try: + site = _site_for(args) + except LookupError as exc: + return _err(str(exc)) + + client = WordVibeMCP(site) + response = client.list_tools() + if isinstance(response, dict) and response.get("error"): + return _err( + "tools/list failed on %s: %s" % (site.url, (response.get("error") or {}).get("message")) + ) + + items = ((response or {}).get("result") or {}).get("tools") or [] + needle = (args.get("filter") or "").strip().lower() + + listed = [] + for t in items: + if not isinstance(t, dict): + continue + name = str(t.get("name") or "") + desc = str(t.get("description") or "").strip() + if needle and needle not in name.lower() and needle not in desc.lower(): + continue + listed.append({"name": name, "description": desc[:220]}) + + return _ok({"site": site.name, "count": len(listed), "tools": listed}) + + +def call(args: dict, **kwargs) -> str: + """Generic escape hatch: invoke any site tool by name.""" + tool = str(args.get("tool") or "").strip() + if not tool: + return _err("Missing 'tool'. Use wordvibe_tools to list what the site exposes.") + + arguments = args.get("arguments") + if arguments is None: + arguments = {} + if not isinstance(arguments, dict): + return _err("'arguments' must be an object of the site tool's parameters.") + + return _call_tool(args.get("site") or "", tool, arguments) + + +# -------------------------------------------------------------------------- +# Convenience wrappers over the most useful site tools +# -------------------------------------------------------------------------- +def read_file(args: dict, **kwargs) -> str: + path = str(args.get("path") or "").strip() + if not path: + return _err("Missing 'path'.") + payload = {"path": path} + lines = str(args.get("lines") or "").strip() + if lines: + payload["lines"] = lines + return _call_tool(args.get("site") or "", "file_read", payload) + + +def write_file(args: dict, **kwargs) -> str: + path = str(args.get("path") or "").strip() + content = args.get("content") + if not path: + return _err("Missing 'path'.") + if not isinstance(content, str): + return _err("Missing 'content' (must be the full file body as a string).") + return _call_tool(args.get("site") or "", "file_write", {"path": path, "content": content}) + + +def edit_file(args: dict, **kwargs) -> str: + path = str(args.get("path") or "").strip() + search = args.get("search") + replace = args.get("replace") + if not path or not isinstance(search, str) or replace is None: + return _err("Need 'path', 'search' and 'replace'.") + return _call_tool( + args.get("site") or "", "file_edit", {"path": path, "search": search, "replace": replace} + ) + + +def search(args: dict, **kwargs) -> str: + query = str(args.get("query") or "").strip() + if not query: + return _err("Missing 'query'.") + payload = {"query": query} + for key in ("path", "include"): + val = str(args.get(key) or "").strip() + if val: + payload[key] = val + if args.get("regex"): + payload["regex"] = True + return _call_tool(args.get("site") or "", "codebase_search", payload)