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).
This commit is contained in:
2026-09-22 01:43:11 +03:00
commit ab1cfc88da
9 changed files with 985 additions and 0 deletions
+9
View File
@@ -0,0 +1,9 @@
__pycache__/
*.py[cod]
*.egg-info/
.venv/
venv/
.env
.DS_Store
.idea/
.vscode/
+21
View File
@@ -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.
+114
View File
@@ -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 <wv_mcp_server_key>` (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/<you>/wordvibe-hermes
# or drop the folder in place
git clone https://github.com/<you>/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
+81
View File
@@ -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 <tool> <json>]` — 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 <tool> '{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 <tool> '{json}'`",
)
ctx.register_hook("post_tool_call", _on_post_tool_call)
logger.info("wordvibe-hermes registered %d tools", len(registry))
+301
View File
@@ -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 <wv_mcp_server_key>
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"<Site {self.name} {self.active_endpoint}>"
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)
+24
View File
@@ -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
+164
View File
@@ -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"],
},
}
+57
View File
@@ -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/<slug>"` |
| 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.
+214
View File
@@ -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)