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:
@@ -0,0 +1,9 @@
|
||||
__pycache__/
|
||||
*.py[cod]
|
||||
*.egg-info/
|
||||
.venv/
|
||||
venv/
|
||||
.env
|
||||
.DS_Store
|
||||
.idea/
|
||||
.vscode/
|
||||
@@ -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.
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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"],
|
||||
},
|
||||
}
|
||||
@@ -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.
|
||||
@@ -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)
|
||||
Reference in New Issue
Block a user