Files
wordvibe-hermes/tools.py
T
admin ab1cfc88da 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).
2026-09-22 01:43:11 +03:00

215 lines
7.3 KiB
Python

"""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)