feat(v3.13.0): settings live DB reads, remove Directus/Appwrite, admin stats
Settings fixes: - MAX_SITES_PER_USER, USER_RATE_LIMIT_PER_MIN/HR now read from DB settings table (DB > ENV > default), so dashboard/settings changes apply without restart. Sync cache refreshed on every save or delete. - /api/me reports the live DB value for max_sites_per_user. Admin improvements: - Admin users bypass per-user rate limiting entirely (role=admin or ADMIN_EMAILS). - Admin Overview now shows platform stats: registered users, new users (7d), total user sites, available tools. Plugin cleanup: - Appwrite and Directus plugins removed from the active registry (8 plugins now: WordPress, WooCommerce, WordPress Specialist, Gitea, n8n, Supabase, OpenPanel, Coolify). Plugin code is retained for future re-enabling. - Settings page plugin visibility list updated to match. Mobile onboarding: - Stepper steps on narrow viewports stack vertically with correct full border and rounded corners on each step. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
17
plugins/wordpress_specialist/__init__.py
Normal file
17
plugins/wordpress_specialist/__init__.py
Normal file
@@ -0,0 +1,17 @@
|
||||
"""WordPress Specialist Plugin (F.19).
|
||||
|
||||
Companion-backed advanced WordPress management for professionals —
|
||||
plugins, themes, users, options, cron, maintenance, page editing, site
|
||||
config + layout, db inspection, bulk fan-out. Replaced the deprecated
|
||||
``wordpress_advanced`` plugin (sunset 2026-05-04 in F.19.3.2-.3); this
|
||||
plugin requires only Airano MCP Bridge v2.18.0+ and an Application
|
||||
Password for a user with ``manage_options``. No Docker socket needed.
|
||||
"""
|
||||
|
||||
from plugins.wordpress_specialist import handlers
|
||||
from plugins.wordpress_specialist.plugin import WordPressSpecialistPlugin
|
||||
|
||||
__all__ = [
|
||||
"WordPressSpecialistPlugin",
|
||||
"handlers",
|
||||
]
|
||||
53
plugins/wordpress_specialist/handlers/__init__.py
Normal file
53
plugins/wordpress_specialist/handlers/__init__.py
Normal file
@@ -0,0 +1,53 @@
|
||||
"""Handlers for the WordPress Specialist plugin."""
|
||||
|
||||
from plugins.wordpress_specialist.handlers.bulk import BulkHandler
|
||||
from plugins.wordpress_specialist.handlers.bulk import (
|
||||
get_tool_specifications as get_bulk_specs,
|
||||
)
|
||||
from plugins.wordpress_specialist.handlers.database import DatabaseHandler
|
||||
from plugins.wordpress_specialist.handlers.database import (
|
||||
get_tool_specifications as get_database_specs,
|
||||
)
|
||||
from plugins.wordpress_specialist.handlers.management import ManagementHandler
|
||||
from plugins.wordpress_specialist.handlers.management import (
|
||||
get_tool_specifications as get_management_specs,
|
||||
)
|
||||
from plugins.wordpress_specialist.handlers.pages import PagesHandler
|
||||
from plugins.wordpress_specialist.handlers.pages import (
|
||||
get_tool_specifications as get_pages_specs,
|
||||
)
|
||||
from plugins.wordpress_specialist.handlers.plugins import PluginsHandler
|
||||
from plugins.wordpress_specialist.handlers.plugins import (
|
||||
get_tool_specifications as get_plugins_specs,
|
||||
)
|
||||
from plugins.wordpress_specialist.handlers.site_config import SiteConfigHandler
|
||||
from plugins.wordpress_specialist.handlers.site_config import (
|
||||
get_tool_specifications as get_site_config_specs,
|
||||
)
|
||||
from plugins.wordpress_specialist.handlers.site_layout import SiteLayoutHandler
|
||||
from plugins.wordpress_specialist.handlers.site_layout import (
|
||||
get_tool_specifications as get_site_layout_specs,
|
||||
)
|
||||
from plugins.wordpress_specialist.handlers.themes import ThemesHandler
|
||||
from plugins.wordpress_specialist.handlers.themes import (
|
||||
get_tool_specifications as get_themes_specs,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
"BulkHandler",
|
||||
"get_bulk_specs",
|
||||
"DatabaseHandler",
|
||||
"get_database_specs",
|
||||
"ManagementHandler",
|
||||
"get_management_specs",
|
||||
"PagesHandler",
|
||||
"get_pages_specs",
|
||||
"PluginsHandler",
|
||||
"get_plugins_specs",
|
||||
"SiteConfigHandler",
|
||||
"get_site_config_specs",
|
||||
"SiteLayoutHandler",
|
||||
"get_site_layout_specs",
|
||||
"ThemesHandler",
|
||||
"get_themes_specs",
|
||||
]
|
||||
293
plugins/wordpress_specialist/handlers/bulk.py
Normal file
293
plugins/wordpress_specialist/handlers/bulk.py
Normal file
@@ -0,0 +1,293 @@
|
||||
"""F.19.3.2-.3 — Bulk fan-out surface (post + term updates).
|
||||
|
||||
Stock-REST-backed: each item dispatches to ``wp/v2/posts/{id}`` or
|
||||
``wp/v2/{taxonomy}/{id}`` — no companion routes are added for these
|
||||
because the stock REST endpoints already exist and handle their own
|
||||
permission checks. Per-item permission gating happens at the WP layer
|
||||
(``edit_post`` / ``manage_terms`` per id), so partial successes are
|
||||
the expected shape.
|
||||
|
||||
Surface map:
|
||||
|
||||
* **wp_bulk_post_update** — ``POST wp/v2/posts/{id}`` per item, fanned
|
||||
out with concurrency=10 (mirror of the legacy ``wordpress_advanced``
|
||||
bulk pattern). 50-item cap.
|
||||
* **wp_bulk_term_update** — ``POST wp/v2/{taxonomy}/{id}`` per item,
|
||||
same shape. 50-item cap. ``taxonomy`` is the REST base
|
||||
(``categories`` / ``tags`` / custom rest_base).
|
||||
|
||||
Both tools sit on the ``editor`` tier — same risk class as bulk page
|
||||
edits. Caller needs ``edit_posts`` / ``manage_terms`` (or the
|
||||
taxonomy-specific cap) on every item; per-item failures are surfaced
|
||||
in the response, the rest succeed.
|
||||
|
||||
Security rules (extending S-1…S-25):
|
||||
|
||||
* **S-26** — Bulk operations cap at 50 items per call (mirror of the
|
||||
S-14 / S-18 family). Bigger payloads return ``bulk_too_large`` 400
|
||||
client-side without any HTTP traffic. Per-item permission checks
|
||||
happen one-by-one inside WP REST (``current_user_can('edit_post', $id)``);
|
||||
the client doesn't aggregate caps.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import re
|
||||
from typing import Any
|
||||
|
||||
from plugins.wordpress.client import WordPressClient
|
||||
|
||||
# S-26 cap. Server doesn't add its own check — the cap is purely
|
||||
# client-side because each request is a separate stock-REST call.
|
||||
_BULK_MAX_ITEMS = 50
|
||||
|
||||
# Stock REST uses POST for create/update on the post / taxonomy
|
||||
# endpoints. PUT also works in modern WP, but POST is the documented
|
||||
# pattern and matches what WP-CLI emits.
|
||||
_UPDATE_METHOD = "POST"
|
||||
|
||||
# Acceptable taxonomy slug shape — must look like ``sanitize_key``
|
||||
# would have produced it server-side. Lowercased, digits, ``_``, ``-``.
|
||||
_TAXONOMY_RE = re.compile(r"^[a-z0-9][a-z0-9_\-]{0,63}$")
|
||||
|
||||
# Concurrency bound for the fan-out. Matches the legacy
|
||||
# ``wordpress_advanced`` pattern; keeps the WP server from getting
|
||||
# swamped on shared hosting.
|
||||
_FANOUT_CONCURRENCY = 10
|
||||
|
||||
|
||||
def get_tool_specifications() -> list[dict[str, Any]]:
|
||||
"""Return tool specs for the F.19.3.2-.3 bulk surface."""
|
||||
return [
|
||||
{
|
||||
"name": "wp_bulk_post_update",
|
||||
"method_name": "wp_bulk_post_update",
|
||||
"description": (
|
||||
"Fan-out update across multiple posts via stock REST "
|
||||
"``wp/v2/posts/{id}``. Pass ``updates=[{id, ...fields}]`` "
|
||||
"where each item carries the id plus whichever fields "
|
||||
"to write (``status``, ``title``, ``content``, ``excerpt``, "
|
||||
"``categories``, ``tags``, ``author``, ``featured_media``, "
|
||||
"``meta``, etc — anything stock REST accepts on the "
|
||||
"``post`` endpoint). Returns "
|
||||
"``[{id, status:'ok'|'error', error?}]`` — partial "
|
||||
"successes are the expected shape since per-item "
|
||||
"``edit_post`` cap checks happen at the WP layer. "
|
||||
"S-26: 50-item cap per call; bigger payloads return "
|
||||
"``bulk_too_large`` 400 without any HTTP. Concurrency "
|
||||
"is bounded to 10 in flight to avoid swamping shared "
|
||||
"hosts."
|
||||
),
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"updates": {
|
||||
"type": "array",
|
||||
"minItems": 1,
|
||||
"maxItems": _BULK_MAX_ITEMS,
|
||||
"items": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"id": {
|
||||
"type": "integer",
|
||||
"minimum": 1,
|
||||
"description": "Post id to update.",
|
||||
},
|
||||
},
|
||||
"required": ["id"],
|
||||
},
|
||||
"description": (
|
||||
f"Updates array (1–{_BULK_MAX_ITEMS} items). "
|
||||
"Each item must carry ``id`` plus any "
|
||||
"post fields stock REST accepts."
|
||||
),
|
||||
},
|
||||
},
|
||||
"required": ["updates"],
|
||||
},
|
||||
"scope": "editor",
|
||||
},
|
||||
{
|
||||
"name": "wp_bulk_term_update",
|
||||
"method_name": "wp_bulk_term_update",
|
||||
"description": (
|
||||
"Fan-out update across multiple terms in a single "
|
||||
"taxonomy via stock REST ``wp/v2/{taxonomy}/{id}``. "
|
||||
"``taxonomy`` is the REST base — ``categories``, "
|
||||
"``tags``, or a custom taxonomy's ``rest_base``. "
|
||||
"Pass ``updates=[{id, ...fields}]`` where each item "
|
||||
"carries the id plus whichever term fields to write "
|
||||
"(``name``, ``slug``, ``description``, ``parent``, "
|
||||
"``meta``). Returns "
|
||||
"``[{id, status:'ok'|'error', error?}]``. Per-item "
|
||||
"``manage_terms`` (or the taxonomy-specific edit cap) "
|
||||
"is enforced server-side. S-26: 50-item cap per call. "
|
||||
"Concurrency is bounded to 10 in flight."
|
||||
),
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"taxonomy": {
|
||||
"type": "string",
|
||||
"minLength": 1,
|
||||
"maxLength": 64,
|
||||
"description": (
|
||||
"REST base of the taxonomy "
|
||||
"(``categories``, ``tags``, or a custom "
|
||||
"taxonomy's rest_base)."
|
||||
),
|
||||
},
|
||||
"updates": {
|
||||
"type": "array",
|
||||
"minItems": 1,
|
||||
"maxItems": _BULK_MAX_ITEMS,
|
||||
"items": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"id": {
|
||||
"type": "integer",
|
||||
"minimum": 1,
|
||||
"description": "Term id to update.",
|
||||
},
|
||||
},
|
||||
"required": ["id"],
|
||||
},
|
||||
"description": (f"Updates array (1–{_BULK_MAX_ITEMS} items)."),
|
||||
},
|
||||
},
|
||||
"required": ["taxonomy", "updates"],
|
||||
},
|
||||
"scope": "editor",
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────
|
||||
# Client-side validation helpers
|
||||
# ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def _validate_updates(value: Any) -> list[dict[str, Any]]:
|
||||
"""Validate the bulk updates array (S-26 client side).
|
||||
|
||||
Each item must be a dict with a positive integer ``id``. Other
|
||||
fields are forwarded untouched — stock REST does its own field
|
||||
validation. Empty arrays are rejected (no-op), > 50 returns
|
||||
``bulk_too_large``.
|
||||
"""
|
||||
if not isinstance(value, list):
|
||||
raise ValueError(f"updates must be a list (got {type(value).__name__})")
|
||||
if not value:
|
||||
raise ValueError("updates must contain at least one item")
|
||||
if len(value) > _BULK_MAX_ITEMS:
|
||||
raise ValueError(
|
||||
f"bulk_too_large: updates contains {len(value)} items "
|
||||
f"(max {_BULK_MAX_ITEMS} per call)"
|
||||
)
|
||||
for idx, item in enumerate(value):
|
||||
if not isinstance(item, dict):
|
||||
raise ValueError(f"updates[{idx}] must be an object")
|
||||
item_id = item.get("id")
|
||||
if not isinstance(item_id, int) or isinstance(item_id, bool) or item_id < 1:
|
||||
raise ValueError(f"updates[{idx}].id must be a positive integer (got {item_id!r})")
|
||||
return value
|
||||
|
||||
|
||||
def _validate_taxonomy(value: Any) -> str:
|
||||
"""Validate the taxonomy slug shape (cheap pre-check).
|
||||
|
||||
The binding gate is server-side — WP returns 404 for unregistered
|
||||
taxonomies. This catches obviously bad input (slashes, spaces,
|
||||
etc.) without a round-trip.
|
||||
"""
|
||||
if not isinstance(value, str):
|
||||
raise ValueError(f"taxonomy must be a string (got {type(value).__name__})")
|
||||
stripped = value.strip()
|
||||
if not stripped:
|
||||
raise ValueError("taxonomy must be a non-empty string")
|
||||
if not _TAXONOMY_RE.match(stripped):
|
||||
raise ValueError(f"taxonomy must match [a-z0-9][a-z0-9_-]{{0,63}} (got {stripped!r})")
|
||||
return stripped
|
||||
|
||||
|
||||
class BulkHandler:
|
||||
"""Bulk fan-out surface (F.19.3.2-.3) — post + term updates.
|
||||
|
||||
Each method runs N stock-REST requests in parallel (bounded at
|
||||
concurrency=10) and returns the per-item status array. Per-item
|
||||
failures don't fail the whole call — the caller gets
|
||||
``{id, status:'error', error}`` for each one and ``status:'ok'``
|
||||
for the successes.
|
||||
"""
|
||||
|
||||
def __init__(self, client: WordPressClient) -> None:
|
||||
self.client = client
|
||||
|
||||
async def _fanout(
|
||||
self,
|
||||
endpoint_template: str,
|
||||
updates: list[dict[str, Any]],
|
||||
) -> list[dict[str, Any]]:
|
||||
"""Run the per-item fan-out with bounded concurrency.
|
||||
|
||||
``endpoint_template`` is a format string that takes the item id —
|
||||
e.g. ``"posts/{id}"`` or ``"categories/{id}"``. Each item's
|
||||
non-``id`` fields are forwarded as the JSON body.
|
||||
"""
|
||||
sem = asyncio.Semaphore(_FANOUT_CONCURRENCY)
|
||||
results: list[dict[str, Any]] = [None] * len(updates) # type: ignore[list-item]
|
||||
|
||||
async def one(idx: int, item: dict[str, Any]) -> None:
|
||||
item_id = int(item["id"])
|
||||
body = {k: v for k, v in item.items() if k != "id"}
|
||||
async with sem:
|
||||
try:
|
||||
await self.client.request(
|
||||
_UPDATE_METHOD,
|
||||
endpoint_template.format(id=item_id),
|
||||
json_data=body if body else None,
|
||||
)
|
||||
results[idx] = {"id": item_id, "status": "ok"}
|
||||
except Exception as exc: # noqa: BLE001 — relay any per-item failure
|
||||
results[idx] = {
|
||||
"id": item_id,
|
||||
"status": "error",
|
||||
"error": str(exc),
|
||||
}
|
||||
|
||||
await asyncio.gather(*(one(i, u) for i, u in enumerate(updates)))
|
||||
return results
|
||||
|
||||
async def wp_bulk_post_update(
|
||||
self,
|
||||
updates: list[dict[str, Any]],
|
||||
**_: Any,
|
||||
) -> dict[str, Any]:
|
||||
clean = _validate_updates(updates)
|
||||
results = await self._fanout("posts/{id}", clean)
|
||||
ok = sum(1 for r in results if r["status"] == "ok")
|
||||
return {
|
||||
"total": len(results),
|
||||
"ok": ok,
|
||||
"errors": len(results) - ok,
|
||||
"results": results,
|
||||
}
|
||||
|
||||
async def wp_bulk_term_update(
|
||||
self,
|
||||
taxonomy: str,
|
||||
updates: list[dict[str, Any]],
|
||||
**_: Any,
|
||||
) -> dict[str, Any]:
|
||||
tax = _validate_taxonomy(taxonomy)
|
||||
clean = _validate_updates(updates)
|
||||
results = await self._fanout(tax + "/{id}", clean)
|
||||
ok = sum(1 for r in results if r["status"] == "ok")
|
||||
return {
|
||||
"taxonomy": tax,
|
||||
"total": len(results),
|
||||
"ok": ok,
|
||||
"errors": len(results) - ok,
|
||||
"results": results,
|
||||
}
|
||||
249
plugins/wordpress_specialist/handlers/database.py
Normal file
249
plugins/wordpress_specialist/handlers/database.py
Normal file
@@ -0,0 +1,249 @@
|
||||
"""F.19.3.2-.3 — Database inspection surface (db/size + db/tables + db/search).
|
||||
|
||||
Closes the database-introspection gap on ``wordpress_specialist`` so the
|
||||
deprecated ``wordpress_advanced`` (Docker-socket / WP-CLI) plugin can
|
||||
sunset cleanly. All three tools are read-only (scope=``read``) and
|
||||
companion-backed via Airano MCP Bridge v2.18.0+.
|
||||
|
||||
Surface map:
|
||||
|
||||
* **wp_db_size** (``GET /admin/db/size``) — single
|
||||
``information_schema.TABLES`` aggregation. Returns
|
||||
``{database_bytes, table_count, row_count_estimate, database_name,
|
||||
table_prefix}``. No SQL exposure: caller doesn't pick the query.
|
||||
* **wp_db_tables** (``GET /admin/db/tables``) — per-table breakdown
|
||||
from the same source. One row per WP table with name / engine /
|
||||
rows / data_bytes / index_bytes / total_bytes / collation.
|
||||
* **wp_db_search** (``POST /admin/db/search``) — search wrapper
|
||||
around ``WP_Query`` with ``s=$query``. NEVER raw SQL.
|
||||
|
||||
Security rules (extending S-1…S-24):
|
||||
|
||||
* **S-25** — ``wp_db_search`` uses ``WP_Query`` with ``s=``, not raw
|
||||
SQL. ``query`` is sanitised via ``sanitize_text_field`` server-side
|
||||
and length-capped at 200 chars client-side. Refusal to return non-
|
||||
readable posts (private/draft from other authors) is enforced by
|
||||
``WP_Query``'s own ``posts_clauses`` filter — the same gate the WP
|
||||
search page uses.
|
||||
|
||||
All routes gated on ``manage_options`` at the companion level. Pulling
|
||||
``information_schema.TABLES`` requires the DB user to have access to it
|
||||
— the standard WordPress install has this for the same MySQL user that
|
||||
runs WP itself, so this is safe in practice.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from plugins.wordpress.client import WordPressClient
|
||||
|
||||
# Companion admin namespace — same prefix used by every F.19.* surface.
|
||||
_ADMIN_NS = "airano-mcp/v1/admin"
|
||||
|
||||
# S-25 client-side cap. Server enforces the same limit; this is purely
|
||||
# a fast-fail before the round-trip.
|
||||
_QUERY_MAX_LEN = 200
|
||||
|
||||
# Limit cap on db/search. Mirrors the server-side ceiling.
|
||||
_SEARCH_LIMIT_MAX = 100
|
||||
|
||||
|
||||
def get_tool_specifications() -> list[dict[str, Any]]:
|
||||
"""Return tool specs for the F.19.3.2-.3 database surface."""
|
||||
return [
|
||||
{
|
||||
"name": "wp_db_size",
|
||||
"method_name": "wp_db_size",
|
||||
"description": (
|
||||
"Read aggregate database size for the WordPress install. "
|
||||
"Returns ``{database_bytes, table_count, "
|
||||
"row_count_estimate, database_name, table_prefix}``. "
|
||||
"Source is a single ``information_schema.TABLES`` "
|
||||
"aggregation scoped to the WP table prefix — no SQL is "
|
||||
"exposed to the caller (S-25). InnoDB row counts are "
|
||||
"estimates, not exact, mirroring MySQL's own caveat. "
|
||||
"Use to answer 'how big is this site?' before deciding "
|
||||
"whether to migrate / archive. Requires Airano MCP "
|
||||
"Bridge v2.18.0+ and manage_options."
|
||||
),
|
||||
"schema": {"type": "object", "properties": {}},
|
||||
"scope": "read",
|
||||
},
|
||||
{
|
||||
"name": "wp_db_tables",
|
||||
"method_name": "wp_db_tables",
|
||||
"description": (
|
||||
"Read per-table size + row breakdown. Returns "
|
||||
"``{database_name, table_prefix, tables: [{name, engine, "
|
||||
"rows, data_bytes, index_bytes, total_bytes, collation}]}`` "
|
||||
"ordered by total_bytes descending. Same source as "
|
||||
"``wp_db_size`` — one row per WP table. Useful for "
|
||||
"'which table is the bloat?' debugging (commonly "
|
||||
"options, postmeta, comments, or a plugin's log table). "
|
||||
"Requires Airano MCP Bridge v2.18.0+ and manage_options."
|
||||
),
|
||||
"schema": {"type": "object", "properties": {}},
|
||||
"scope": "read",
|
||||
},
|
||||
{
|
||||
"name": "wp_db_search",
|
||||
"method_name": "wp_db_search",
|
||||
"description": (
|
||||
"Full-text search across post / product content using "
|
||||
"``WP_Query`` with ``s=$query``. NEVER raw SQL (S-25). "
|
||||
"Returns ``{query, limit, count, hits: [{id, post_type, "
|
||||
"status, title, snippet, url, modified}]}``. Sanitises "
|
||||
"``query`` via ``sanitize_text_field`` server-side; "
|
||||
"client caps it at 200 chars. ``limit`` defaults to 20, "
|
||||
"max 100. Optional ``post_type`` (string or array) and "
|
||||
"``status`` (string or array) filters. Non-readable "
|
||||
"posts (private / draft from other authors) are filtered "
|
||||
"out by ``WP_Query``'s own ``posts_clauses`` — same gate "
|
||||
"the WP search page uses. Requires Airano MCP Bridge "
|
||||
"v2.18.0+ and manage_options."
|
||||
),
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"query": {
|
||||
"type": "string",
|
||||
"minLength": 1,
|
||||
"maxLength": _QUERY_MAX_LEN,
|
||||
"description": (
|
||||
"Search term. Sanitised server-side via "
|
||||
"``sanitize_text_field``; client caps at "
|
||||
f"{_QUERY_MAX_LEN} chars."
|
||||
),
|
||||
},
|
||||
"post_type": {
|
||||
"description": (
|
||||
"Optional post type filter. String "
|
||||
"(``post``, ``page``, ``product`` ...) or "
|
||||
"array of strings. Defaults to ``any``."
|
||||
),
|
||||
},
|
||||
"status": {
|
||||
"description": (
|
||||
"Optional post status filter. String "
|
||||
"(``publish``, ``draft`` ...) or array of "
|
||||
"strings. Defaults to ``any``."
|
||||
),
|
||||
},
|
||||
"limit": {
|
||||
"type": "integer",
|
||||
"minimum": 1,
|
||||
"maximum": _SEARCH_LIMIT_MAX,
|
||||
"default": 20,
|
||||
"description": (
|
||||
f"Max hits to return (1–{_SEARCH_LIMIT_MAX}). " "Defaults to 20."
|
||||
),
|
||||
},
|
||||
},
|
||||
"required": ["query"],
|
||||
},
|
||||
"scope": "read",
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────
|
||||
# Client-side validation helpers
|
||||
# ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def _normalise_query(value: Any) -> str:
|
||||
"""Validate + length-cap the search query (S-25 client side).
|
||||
|
||||
The server re-runs ``sanitize_text_field`` and applies the same
|
||||
length cap; this is a fast-fail before the round-trip. Empty
|
||||
queries are rejected — ``WP_Query`` with ``s=''`` matches every
|
||||
post and would return a meaningless dump.
|
||||
"""
|
||||
if not isinstance(value, str):
|
||||
raise ValueError(f"query must be a string (got {type(value).__name__})")
|
||||
stripped = value.strip()
|
||||
if not stripped:
|
||||
raise ValueError("query must be a non-empty string")
|
||||
if len(stripped) > _QUERY_MAX_LEN:
|
||||
stripped = stripped[:_QUERY_MAX_LEN]
|
||||
return stripped
|
||||
|
||||
|
||||
def _normalise_limit(value: Any) -> int:
|
||||
"""Cap the search limit at 100 (mirror of the server-side ceiling)."""
|
||||
if value is None:
|
||||
return 20
|
||||
if not isinstance(value, int) or isinstance(value, bool):
|
||||
raise ValueError(f"limit must be an integer (got {type(value).__name__})")
|
||||
if value < 1:
|
||||
raise ValueError(f"limit must be >= 1 (got {value})")
|
||||
if value > _SEARCH_LIMIT_MAX:
|
||||
return _SEARCH_LIMIT_MAX
|
||||
return value
|
||||
|
||||
|
||||
def _normalise_filter(value: Any, field: str) -> str | list[str] | None:
|
||||
"""Accept a string or list of strings; reject anything else.
|
||||
|
||||
The server sanitises with ``sanitize_key`` per item; we only check
|
||||
structural shape here.
|
||||
"""
|
||||
if value is None:
|
||||
return None
|
||||
if isinstance(value, str):
|
||||
return value if value else None
|
||||
if isinstance(value, list):
|
||||
if not all(isinstance(v, str) for v in value):
|
||||
raise ValueError(f"{field} array must contain only strings")
|
||||
return value
|
||||
raise ValueError(f"{field} must be a string or array of strings")
|
||||
|
||||
|
||||
class DatabaseHandler:
|
||||
"""Database inspection surface (F.19.3.2-.3) — db/size + db/tables + db/search.
|
||||
|
||||
Each method returns the parsed JSON envelope from the companion.
|
||||
The plugin.py wrapper serialises for MCP transport. Server errors
|
||||
(500 db_size_query_failed, 400 invalid_query, etc.) are relayed
|
||||
untouched — the companion is the binding gate.
|
||||
"""
|
||||
|
||||
def __init__(self, client: WordPressClient) -> None:
|
||||
self.client = client
|
||||
|
||||
async def wp_db_size(self, **_: Any) -> dict[str, Any]:
|
||||
return await self.client.get(
|
||||
f"{_ADMIN_NS}/db/size",
|
||||
use_custom_namespace=True,
|
||||
)
|
||||
|
||||
async def wp_db_tables(self, **_: Any) -> dict[str, Any]:
|
||||
return await self.client.get(
|
||||
f"{_ADMIN_NS}/db/tables",
|
||||
use_custom_namespace=True,
|
||||
)
|
||||
|
||||
async def wp_db_search(
|
||||
self,
|
||||
query: str,
|
||||
post_type: str | list[str] | None = None,
|
||||
status: str | list[str] | None = None,
|
||||
limit: int | None = None,
|
||||
**_: Any,
|
||||
) -> dict[str, Any]:
|
||||
body: dict[str, Any] = {
|
||||
"query": _normalise_query(query),
|
||||
"limit": _normalise_limit(limit),
|
||||
}
|
||||
pt = _normalise_filter(post_type, "post_type")
|
||||
if pt is not None:
|
||||
body["post_type"] = pt
|
||||
st = _normalise_filter(status, "status")
|
||||
if st is not None:
|
||||
body["status"] = st
|
||||
return await self.client.post(
|
||||
f"{_ADMIN_NS}/db/search",
|
||||
json_data=body,
|
||||
use_custom_namespace=True,
|
||||
)
|
||||
236
plugins/wordpress_specialist/handlers/management.py
Normal file
236
plugins/wordpress_specialist/handlers/management.py
Normal file
@@ -0,0 +1,236 @@
|
||||
"""F.19.1 — WordPress Specialist read-only management handler.
|
||||
|
||||
Surfaces the ``airano-mcp/v1/admin/*`` companion routes (plugins, themes,
|
||||
users, options, cron, maintenance) as MCP tools. Read-only in this
|
||||
iteration; write operations land in F.19.2 once user-supplied security
|
||||
rules are folded in.
|
||||
|
||||
All tools require companion plugin v2.11.0+ and the saved Application
|
||||
Password to belong to a WordPress user with ``manage_options``. Behaviour
|
||||
when the companion is missing or auth is insufficient is delegated to
|
||||
the companion's own error responses, which arrive as ``rest_no_route``
|
||||
or ``rest_forbidden`` from WordPress core.
|
||||
"""
|
||||
|
||||
from typing import Any
|
||||
|
||||
from plugins.wordpress.client import WordPressClient
|
||||
|
||||
# Single-source admin namespace prefix. The companion plugin registers
|
||||
# routes under this prefix in airano-mcp-bridge.php register_rest_routes().
|
||||
_ADMIN_NS = "airano-mcp/v1/admin"
|
||||
|
||||
|
||||
def get_tool_specifications() -> list[dict[str, Any]]:
|
||||
"""Return tool specs for the F.19.1 read-only admin surface."""
|
||||
return [
|
||||
{
|
||||
"name": "wp_plugin_list",
|
||||
"method_name": "wp_plugin_list",
|
||||
"description": (
|
||||
"List every plugin known to WordPress with active/network-active "
|
||||
"status, version, author, and update availability. Read-only "
|
||||
"(no install/activate). Requires Airano MCP Bridge v2.11.0+ "
|
||||
"and a WordPress user with manage_options."
|
||||
),
|
||||
"schema": {"type": "object", "properties": {}},
|
||||
"scope": "read",
|
||||
},
|
||||
{
|
||||
"name": "wp_theme_list",
|
||||
"method_name": "wp_theme_list",
|
||||
"description": (
|
||||
"List every installed theme: stylesheet/template names, "
|
||||
"version, parent, block-theme flag, active flag, update "
|
||||
"availability. Requires Airano MCP Bridge v2.11.0+ and "
|
||||
"manage_options."
|
||||
),
|
||||
"schema": {"type": "object", "properties": {}},
|
||||
"scope": "read",
|
||||
},
|
||||
{
|
||||
"name": "wp_user_list",
|
||||
"method_name": "wp_user_list",
|
||||
"description": (
|
||||
"List WordPress users with id, username, email, display name, "
|
||||
"roles, and registration timestamp. Supports optional role "
|
||||
"and search filters; paginated up to 200 per call. Requires "
|
||||
"Airano MCP Bridge v2.11.0+ and manage_options."
|
||||
),
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"role": {
|
||||
"type": "string",
|
||||
"description": "Filter by role slug (e.g. 'administrator', 'editor').",
|
||||
},
|
||||
"search": {
|
||||
"type": "string",
|
||||
"description": "Search across login, email, and display name.",
|
||||
},
|
||||
"page": {"type": "integer", "minimum": 1, "default": 1},
|
||||
"per_page": {
|
||||
"type": "integer",
|
||||
"minimum": 1,
|
||||
"maximum": 200,
|
||||
"default": 50,
|
||||
},
|
||||
},
|
||||
},
|
||||
"scope": "read",
|
||||
},
|
||||
{
|
||||
"name": "wp_option_get",
|
||||
"method_name": "wp_option_get",
|
||||
"description": (
|
||||
"Read a single WordPress option by name. Refuses keys that "
|
||||
"look like credentials (suffix matches secret/password/"
|
||||
"api_key/token/auth_key/auth_salt etc.) — operators can "
|
||||
"still inspect those via wp-admin if needed. Requires "
|
||||
"Airano MCP Bridge v2.11.0+ and manage_options."
|
||||
),
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"name": {
|
||||
"type": "string",
|
||||
"description": "Option key (alphanumerics, dashes, underscores).",
|
||||
}
|
||||
},
|
||||
"required": ["name"],
|
||||
},
|
||||
"scope": "read",
|
||||
},
|
||||
{
|
||||
"name": "wp_cron_list",
|
||||
"method_name": "wp_cron_list",
|
||||
"description": (
|
||||
"Dump the WordPress cron table: hook name, next run time "
|
||||
"(epoch + ISO 8601 UTC), schedule slug, interval, and "
|
||||
"stored args. Requires Airano MCP Bridge v2.11.0+ and "
|
||||
"manage_options."
|
||||
),
|
||||
"schema": {"type": "object", "properties": {}},
|
||||
"scope": "read",
|
||||
},
|
||||
{
|
||||
"name": "wp_maintenance_status",
|
||||
"method_name": "wp_maintenance_status",
|
||||
"description": (
|
||||
"Report whether WordPress is currently in maintenance mode "
|
||||
"by inspecting the .maintenance sentinel file. Returns "
|
||||
"``enabled``, ``started_at`` (epoch), and ``stale`` (true "
|
||||
"when older than 10 minutes — WP's own threshold). "
|
||||
"Requires Airano MCP Bridge v2.11.0+ and manage_options."
|
||||
),
|
||||
"schema": {"type": "object", "properties": {}},
|
||||
"scope": "read",
|
||||
},
|
||||
# F.19.3.1 — system info ports (companion v2.12.0+). Originally
|
||||
# ported from the legacy wordpress_advanced WP-CLI surface
|
||||
# (sunset 2026-05-04); kept here as the companion-backed
|
||||
# equivalents.
|
||||
{
|
||||
"name": "wp_system_info",
|
||||
"method_name": "wp_system_info",
|
||||
"description": (
|
||||
"PHP / MySQL / WordPress versions, server software, "
|
||||
"memory limits, multisite flag, debug state, and "
|
||||
"canonical paths (ABSPATH, plugins, uploads). Companion-"
|
||||
"backed; no Docker socket required. Requires Airano MCP "
|
||||
"Bridge v2.12.0+ and manage_options."
|
||||
),
|
||||
"schema": {"type": "object", "properties": {}},
|
||||
"scope": "read",
|
||||
},
|
||||
{
|
||||
"name": "wp_php_info",
|
||||
"method_name": "wp_php_info",
|
||||
"description": (
|
||||
"Curated PHP configuration snapshot — sorted extension "
|
||||
"list, common ini settings (memory/upload/session/error), "
|
||||
"disabled functions, opcache state. Returns structured "
|
||||
"JSON, not the full ``phpinfo()`` HTML dump (which would "
|
||||
"leak server internals). Requires Airano MCP Bridge "
|
||||
"v2.12.0+ and manage_options."
|
||||
),
|
||||
"schema": {"type": "object", "properties": {}},
|
||||
"scope": "read",
|
||||
},
|
||||
{
|
||||
"name": "wp_disk_usage",
|
||||
"method_name": "wp_disk_usage",
|
||||
"description": (
|
||||
"Bytes used by uploads, plugins, and themes plus "
|
||||
"filesystem-wide ``disk_total/free/used`` for ABSPATH. "
|
||||
"Each tree walk caps at 200k files / 5s wall clock; "
|
||||
"truncated walks set ``truncated: true`` so the caller "
|
||||
"can treat the value as a lower bound. Requires Airano "
|
||||
"MCP Bridge v2.12.0+ and manage_options."
|
||||
),
|
||||
"schema": {"type": "object", "properties": {}},
|
||||
"scope": "read",
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
class ManagementHandler:
|
||||
"""Thin wrapper around the companion's ``admin`` namespace.
|
||||
|
||||
Every method awaits a companion REST GET and returns the parsed JSON
|
||||
body unchanged so the SPA / MCP client sees the same shape WordPress
|
||||
produced. Errors propagate as exceptions raised by ``WordPressClient``;
|
||||
the plugin.py wrapper layer is responsible for serialisation.
|
||||
"""
|
||||
|
||||
def __init__(self, client: WordPressClient) -> None:
|
||||
self.client = client
|
||||
|
||||
async def wp_plugin_list(self, **_: Any) -> dict[str, Any]:
|
||||
return await self.client.get(f"{_ADMIN_NS}/plugins", use_custom_namespace=True)
|
||||
|
||||
async def wp_theme_list(self, **_: Any) -> dict[str, Any]:
|
||||
return await self.client.get(f"{_ADMIN_NS}/themes", use_custom_namespace=True)
|
||||
|
||||
async def wp_user_list(
|
||||
self,
|
||||
role: str | None = None,
|
||||
search: str | None = None,
|
||||
page: int = 1,
|
||||
per_page: int = 50,
|
||||
**_: Any,
|
||||
) -> dict[str, Any]:
|
||||
params: dict[str, Any] = {"page": int(page), "per_page": int(per_page)}
|
||||
if role:
|
||||
params["role"] = role
|
||||
if search:
|
||||
params["search"] = search
|
||||
return await self.client.get(f"{_ADMIN_NS}/users", params=params, use_custom_namespace=True)
|
||||
|
||||
async def wp_option_get(self, name: str, **_: Any) -> dict[str, Any]:
|
||||
if not name or not isinstance(name, str):
|
||||
raise ValueError("wp_option_get requires a non-empty 'name' string")
|
||||
# WordPress option keys are typically [a-zA-Z0-9_-]; the companion
|
||||
# route also enforces this via sanitize_key, but reject obvious
|
||||
# injection attempts (path traversal, slashes) on the client side
|
||||
# so they never reach the wire.
|
||||
if "/" in name or ".." in name or "\x00" in name:
|
||||
raise ValueError(f"wp_option_get rejected suspicious option name: {name!r}")
|
||||
return await self.client.get(f"{_ADMIN_NS}/options/{name}", use_custom_namespace=True)
|
||||
|
||||
async def wp_cron_list(self, **_: Any) -> dict[str, Any]:
|
||||
return await self.client.get(f"{_ADMIN_NS}/cron", use_custom_namespace=True)
|
||||
|
||||
async def wp_maintenance_status(self, **_: Any) -> dict[str, Any]:
|
||||
return await self.client.get(f"{_ADMIN_NS}/maintenance", use_custom_namespace=True)
|
||||
|
||||
# F.19.3.1 — system info ports (companion v2.12.0+)
|
||||
|
||||
async def wp_system_info(self, **_: Any) -> dict[str, Any]:
|
||||
return await self.client.get(f"{_ADMIN_NS}/system-info", use_custom_namespace=True)
|
||||
|
||||
async def wp_php_info(self, **_: Any) -> dict[str, Any]:
|
||||
return await self.client.get(f"{_ADMIN_NS}/phpinfo", use_custom_namespace=True)
|
||||
|
||||
async def wp_disk_usage(self, **_: Any) -> dict[str, Any]:
|
||||
return await self.client.get(f"{_ADMIN_NS}/disk-usage", use_custom_namespace=True)
|
||||
686
plugins/wordpress_specialist/handlers/pages.py
Normal file
686
plugins/wordpress_specialist/handlers/pages.py
Normal file
@@ -0,0 +1,686 @@
|
||||
"""F.19.5 — Page editing surface (Gutenberg + Elementor + Classic).
|
||||
|
||||
Eleven tools split across three surfaces. The Gutenberg + Elementor +
|
||||
Classic surfaces share one handler because every tool reaches the same
|
||||
WordPress site through the same companion plugin; splitting along the
|
||||
"`pages.py` for content writes / `management.py` for inventory" axis
|
||||
keeps each handler small and focused.
|
||||
|
||||
Surface map:
|
||||
|
||||
* **Gutenberg** (4 tools, companion v2.13.0 routes ``/admin/blocks/*``):
|
||||
``wp_blocks_get`` reads via stock REST + ``parse_blocks()`` server-side
|
||||
in MCPHub; the writes (``wp_blocks_replace`` / ``wp_blocks_insert_at``
|
||||
/ ``wp_blocks_remove_at``) hit the companion so ``serialize_blocks()``
|
||||
stays server-side and avoids client-side corruption of HTML comment
|
||||
delimiters.
|
||||
* **Elementor** (6 tools, ``/admin/elementor/*``):
|
||||
``wp_elementor_detect`` + ``wp_elementor_get`` + ``wp_elementor_template_list``
|
||||
read; ``wp_elementor_set`` + ``wp_elementor_render_css`` +
|
||||
``wp_elementor_template_apply`` write. The companion handles the
|
||||
slash-strip / JSON-validate dance and fires
|
||||
``elementor/document/after_save`` after writes so caches and CSS
|
||||
regenerate cleanly.
|
||||
* **Classic** (1 tool): ``wp_classic_html_replace`` is a thin
|
||||
``post_content`` swap — the only F.19.5 tool that exists for sites
|
||||
that haven't migrated to the block editor.
|
||||
|
||||
Security rules layered on top of F.19.2 S-1…S-11 (companion enforces
|
||||
these regardless of MCPHub-side guards):
|
||||
|
||||
* **S-12** — every block / Elementor / Classic write requires
|
||||
``edit_post`` on the target post id (per-item, not just the global
|
||||
manage_options gate). Companion checks via ``current_user_can``.
|
||||
* **S-13** — block + classic content sanitised via ``wp_kses_post`` by
|
||||
default; ``raw_html=True`` only goes through when the calling WP user
|
||||
has ``unfiltered_html``.
|
||||
* **S-14** — Elementor JSON node count capped at 5,000 per call; the
|
||||
companion returns ``elementor_too_large`` when oversized — callers
|
||||
should switch to ``wp_elementor_template_apply``.
|
||||
|
||||
All tools require Airano MCP Bridge v2.13.0+.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from plugins.wordpress.client import WordPressClient
|
||||
|
||||
# Companion admin namespace — same prefix used by the management surface.
|
||||
_ADMIN_NS = "airano-mcp/v1/admin"
|
||||
|
||||
# Stock REST namespace — used by the two tools that don't need companion
|
||||
# routes (``wp_blocks_get`` and ``wp_classic_html_replace`` read paths).
|
||||
_WP_NS = "wp/v2"
|
||||
|
||||
# Mirrored from the companion's BLOCKS_MAX_PER_CALL / ELEMENTOR_MAX_NODES
|
||||
# constants so MCPHub can reject obviously-oversized payloads before
|
||||
# they reach the wire. The companion enforces the real limit.
|
||||
_BLOCKS_MAX_PER_CALL = 200
|
||||
_ELEMENTOR_MAX_NODES = 5000
|
||||
|
||||
|
||||
def get_tool_specifications() -> list[dict[str, Any]]:
|
||||
"""Return tool specs for the F.19.5 page editing surface."""
|
||||
return [
|
||||
# ───── Gutenberg blocks ──────────────────────────────────────
|
||||
{
|
||||
"name": "wp_blocks_get",
|
||||
"method_name": "wp_blocks_get",
|
||||
"description": (
|
||||
"Read a post or page as a block tree. Fetches post_content "
|
||||
"via stock REST then parses it server-side with WP's block "
|
||||
"grammar so the caller gets a structured array of "
|
||||
"{blockName, attrs, innerBlocks, innerHTML} entries. Works "
|
||||
"on any WordPress 5.0+ install — no companion route "
|
||||
"needed for reads."
|
||||
),
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"post_id": {
|
||||
"type": "integer",
|
||||
"description": "Target post or page id.",
|
||||
"minimum": 1,
|
||||
},
|
||||
"post_type": {
|
||||
"type": "string",
|
||||
"description": (
|
||||
"Stock REST collection — ``posts`` (default) or "
|
||||
"``pages``. Companion isn't consulted for reads."
|
||||
),
|
||||
"default": "posts",
|
||||
},
|
||||
},
|
||||
"required": ["post_id"],
|
||||
},
|
||||
"scope": "read",
|
||||
},
|
||||
{
|
||||
"name": "wp_blocks_replace",
|
||||
"method_name": "wp_blocks_replace",
|
||||
"description": (
|
||||
"Replace a post's full block tree. The companion serializes "
|
||||
"the array via WP's serialize_blocks() so HTML comment "
|
||||
"delimiters round-trip cleanly. Block content is sanitised "
|
||||
"with wp_kses_post unless raw_html=true (S-13: requires "
|
||||
"the WP user to also hold unfiltered_html). Capped at 200 "
|
||||
"blocks per call. Requires Airano MCP Bridge v2.13.0+ and "
|
||||
"edit_post on the target."
|
||||
),
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"post_id": {"type": "integer", "minimum": 1},
|
||||
"blocks": {
|
||||
"type": "array",
|
||||
"description": (
|
||||
"Array of block dicts (same shape parse_blocks "
|
||||
"returns). innerBlocks may be nested."
|
||||
),
|
||||
"maxItems": _BLOCKS_MAX_PER_CALL,
|
||||
},
|
||||
"raw_html": {
|
||||
"type": "boolean",
|
||||
"default": False,
|
||||
"description": (
|
||||
"Skip wp_kses_post sanitisation. Companion "
|
||||
"still enforces unfiltered_html — false stays "
|
||||
"the default in every case."
|
||||
),
|
||||
},
|
||||
},
|
||||
"required": ["post_id", "blocks"],
|
||||
},
|
||||
"scope": "editor",
|
||||
},
|
||||
{
|
||||
"name": "wp_blocks_insert_at",
|
||||
"method_name": "wp_blocks_insert_at",
|
||||
"description": (
|
||||
"Insert a single block at a given index, pushing the rest "
|
||||
"down. Same sanitisation + cap rules as wp_blocks_replace. "
|
||||
"Requires Airano MCP Bridge v2.13.0+."
|
||||
),
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"post_id": {"type": "integer", "minimum": 1},
|
||||
"index": {
|
||||
"type": "integer",
|
||||
"minimum": 0,
|
||||
"description": (
|
||||
"0-based insertion point. Pass the current "
|
||||
"block count to append. Defaults to append."
|
||||
),
|
||||
},
|
||||
"block": {
|
||||
"type": "object",
|
||||
"description": "Single block dict to insert.",
|
||||
},
|
||||
"raw_html": {"type": "boolean", "default": False},
|
||||
},
|
||||
"required": ["post_id", "block"],
|
||||
},
|
||||
"scope": "editor",
|
||||
},
|
||||
{
|
||||
"name": "wp_blocks_remove_at",
|
||||
"method_name": "wp_blocks_remove_at",
|
||||
"description": (
|
||||
"Remove the block at the given index. The response "
|
||||
"includes the removed block so the caller can rollback by "
|
||||
"feeding it back to wp_blocks_insert_at. Requires Airano "
|
||||
"MCP Bridge v2.13.0+."
|
||||
),
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"post_id": {"type": "integer", "minimum": 1},
|
||||
"index": {"type": "integer", "minimum": 0},
|
||||
},
|
||||
"required": ["post_id", "index"],
|
||||
},
|
||||
"scope": "editor",
|
||||
},
|
||||
# ───── Elementor ─────────────────────────────────────────────
|
||||
{
|
||||
"name": "wp_elementor_detect",
|
||||
"method_name": "wp_elementor_detect",
|
||||
"description": (
|
||||
"Report Elementor presence on the site: installed flag, "
|
||||
"version, Pro flag, and the post types Elementor edits. "
|
||||
"Returns ``installed: false`` cleanly when Elementor is "
|
||||
"absent — non-Elementor sites do not 404. Requires Airano "
|
||||
"MCP Bridge v2.13.0+."
|
||||
),
|
||||
"schema": {"type": "object", "properties": {}},
|
||||
"scope": "read",
|
||||
},
|
||||
{
|
||||
"name": "wp_elementor_get",
|
||||
"method_name": "wp_elementor_get",
|
||||
"description": (
|
||||
"Fetch the parsed _elementor_data tree for a post. The "
|
||||
"companion strips WP's slashes and JSON-decodes server-"
|
||||
"side; the caller always sees a plain array. Returns "
|
||||
"``edited_with_elementor: false`` if the post hasn't been "
|
||||
"opened in Elementor. Requires Airano MCP Bridge v2.13.0+."
|
||||
),
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {"post_id": {"type": "integer", "minimum": 1}},
|
||||
"required": ["post_id"],
|
||||
},
|
||||
"scope": "read",
|
||||
},
|
||||
{
|
||||
"name": "wp_elementor_set",
|
||||
"method_name": "wp_elementor_set",
|
||||
"description": (
|
||||
"Replace a post's _elementor_data tree. Companion validates "
|
||||
"every node has id/elType/settings, enforces the 5,000-node "
|
||||
"cap (S-14), writes via update_post_meta, and fires "
|
||||
"elementor/document/after_save so caches and CSS clear. "
|
||||
"Oversized payloads return ``elementor_too_large``; switch "
|
||||
"to wp_elementor_template_apply. Requires Airano MCP "
|
||||
"Bridge v2.13.0+ and edit_post on the target."
|
||||
),
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"post_id": {"type": "integer", "minimum": 1},
|
||||
"data": {
|
||||
"type": "array",
|
||||
"description": (
|
||||
"Top-level Elementor sections array. Every "
|
||||
"node (recursively, via ``elements``) must "
|
||||
"carry id, elType, settings."
|
||||
),
|
||||
},
|
||||
},
|
||||
"required": ["post_id", "data"],
|
||||
},
|
||||
"scope": "editor",
|
||||
},
|
||||
{
|
||||
"name": "wp_elementor_render_css",
|
||||
"method_name": "wp_elementor_render_css",
|
||||
"description": (
|
||||
"Trigger Elementor's per-post CSS regeneration so the "
|
||||
"front-end picks up changes from a recent wp_elementor_set "
|
||||
"or theme switch. Equivalent to clicking 'Regenerate CSS' "
|
||||
"scoped to a single post. Requires Airano MCP Bridge "
|
||||
"v2.13.0+ and Elementor active."
|
||||
),
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {"post_id": {"type": "integer", "minimum": 1}},
|
||||
"required": ["post_id"],
|
||||
},
|
||||
"scope": "editor",
|
||||
},
|
||||
{
|
||||
"name": "wp_elementor_template_list",
|
||||
"method_name": "wp_elementor_template_list",
|
||||
"description": (
|
||||
"List saved Elementor templates (the elementor_library "
|
||||
"CPT). Returns id, title, type (page/section/header/…), "
|
||||
"and modified_gmt. Returns ``installed: false`` cleanly "
|
||||
"if Elementor is not active. Requires Airano MCP Bridge "
|
||||
"v2.13.0+."
|
||||
),
|
||||
"schema": {"type": "object", "properties": {}},
|
||||
"scope": "read",
|
||||
},
|
||||
{
|
||||
"name": "wp_elementor_template_apply",
|
||||
"method_name": "wp_elementor_template_apply",
|
||||
"description": (
|
||||
"Copy a saved Elementor template's data into a target "
|
||||
"post. Subject to the same S-14 5,000-node cap as "
|
||||
"wp_elementor_set. Useful when a payload exceeds the "
|
||||
"cap — clone a known-good template instead of streaming "
|
||||
"raw JSON. Requires Airano MCP Bridge v2.13.0+ and "
|
||||
"edit_post on the target."
|
||||
),
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"template_id": {
|
||||
"type": "integer",
|
||||
"minimum": 1,
|
||||
"description": "Source elementor_library post id.",
|
||||
},
|
||||
"post_id": {
|
||||
"type": "integer",
|
||||
"minimum": 1,
|
||||
"description": "Target post id (where the layout lands).",
|
||||
},
|
||||
},
|
||||
"required": ["template_id", "post_id"],
|
||||
},
|
||||
"scope": "editor",
|
||||
},
|
||||
# ───── Classic editor ────────────────────────────────────────
|
||||
{
|
||||
"name": "wp_classic_html_replace",
|
||||
"method_name": "wp_classic_html_replace",
|
||||
"description": (
|
||||
"Pure post_content swap for sites still on the Classic "
|
||||
"editor. Companion sanitises with wp_kses_post unless "
|
||||
"raw_html=true (S-13). Requires Airano MCP Bridge "
|
||||
"v2.13.0+ and edit_post on the target."
|
||||
),
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"post_id": {"type": "integer", "minimum": 1},
|
||||
"html": {
|
||||
"type": "string",
|
||||
"description": "Replacement post_content body.",
|
||||
},
|
||||
"raw_html": {"type": "boolean", "default": False},
|
||||
},
|
||||
"required": ["post_id", "html"],
|
||||
},
|
||||
"scope": "editor",
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
def _validate_post_id(post_id: Any) -> int:
|
||||
"""Reject obviously-bad ids before the wire."""
|
||||
if not isinstance(post_id, int) or isinstance(post_id, bool) or post_id <= 0:
|
||||
raise ValueError(f"post_id must be a positive integer, got {post_id!r}")
|
||||
return post_id
|
||||
|
||||
|
||||
def _count_elementor_nodes(tree: list[Any]) -> int:
|
||||
"""Recursively count Elementor nodes (mirrors the companion's walker)."""
|
||||
total = 0
|
||||
for node in tree:
|
||||
if isinstance(node, dict):
|
||||
total += 1
|
||||
children = node.get("elements")
|
||||
if isinstance(children, list):
|
||||
total += _count_elementor_nodes(children)
|
||||
return total
|
||||
|
||||
|
||||
class PagesHandler:
|
||||
"""Block + Elementor + Classic page-editing surface (F.19.5).
|
||||
|
||||
Each method returns the parsed JSON envelope from the companion (or
|
||||
from stock REST in the two read-only cases). The plugin.py wrapper
|
||||
layer is responsible for serialising the dict for MCP transport.
|
||||
"""
|
||||
|
||||
def __init__(self, client: WordPressClient) -> None:
|
||||
self.client = client
|
||||
|
||||
# ── Gutenberg ────────────────────────────────────────────────────
|
||||
|
||||
async def wp_blocks_get(
|
||||
self,
|
||||
post_id: int,
|
||||
post_type: str = "posts",
|
||||
**_: Any,
|
||||
) -> dict[str, Any]:
|
||||
"""Read post_content via stock REST, parse blocks server-side."""
|
||||
post_id = _validate_post_id(post_id)
|
||||
if post_type not in {"posts", "pages"}:
|
||||
raise ValueError(f"post_type must be 'posts' or 'pages', got {post_type!r}")
|
||||
# Stock REST returns post_content under "content.raw" when
|
||||
# context=edit and the user has edit_posts. The wordpress
|
||||
# client always authenticates with an Application Password so
|
||||
# we ask for the raw form.
|
||||
post = await self.client.get(
|
||||
f"{post_type}/{post_id}",
|
||||
params={"context": "edit"},
|
||||
)
|
||||
raw = ""
|
||||
if isinstance(post, dict):
|
||||
content = post.get("content")
|
||||
if isinstance(content, dict):
|
||||
raw = content.get("raw") or content.get("rendered") or ""
|
||||
# Lazy import — `parse_blocks` lives in MCPHub-side helpers so
|
||||
# we don't reach into a separate WordPress installation.
|
||||
blocks = _parse_blocks_python(raw)
|
||||
return {
|
||||
"post_id": post_id,
|
||||
"post_type": post_type,
|
||||
"count": len(blocks),
|
||||
"blocks": blocks,
|
||||
}
|
||||
|
||||
async def wp_blocks_replace(
|
||||
self,
|
||||
post_id: int,
|
||||
blocks: list[dict[str, Any]],
|
||||
raw_html: bool = False,
|
||||
**_: Any,
|
||||
) -> dict[str, Any]:
|
||||
post_id = _validate_post_id(post_id)
|
||||
if not isinstance(blocks, list):
|
||||
raise ValueError("blocks must be a list of block dicts")
|
||||
if len(blocks) > _BLOCKS_MAX_PER_CALL:
|
||||
raise ValueError(f"blocks exceeds {_BLOCKS_MAX_PER_CALL} per call (got {len(blocks)})")
|
||||
return await self.client.post(
|
||||
f"{_ADMIN_NS}/blocks/replace",
|
||||
json_data={"post_id": post_id, "blocks": blocks, "raw_html": bool(raw_html)},
|
||||
use_custom_namespace=True,
|
||||
)
|
||||
|
||||
async def wp_blocks_insert_at(
|
||||
self,
|
||||
post_id: int,
|
||||
block: dict[str, Any],
|
||||
index: int | None = None,
|
||||
raw_html: bool = False,
|
||||
**_: Any,
|
||||
) -> dict[str, Any]:
|
||||
post_id = _validate_post_id(post_id)
|
||||
if not isinstance(block, dict):
|
||||
raise ValueError("block must be a dict")
|
||||
body: dict[str, Any] = {
|
||||
"post_id": post_id,
|
||||
"block": block,
|
||||
"raw_html": bool(raw_html),
|
||||
}
|
||||
if index is not None:
|
||||
if not isinstance(index, int) or isinstance(index, bool) or index < 0:
|
||||
raise ValueError("index must be a non-negative integer")
|
||||
body["index"] = index
|
||||
return await self.client.post(
|
||||
f"{_ADMIN_NS}/blocks/insert",
|
||||
json_data=body,
|
||||
use_custom_namespace=True,
|
||||
)
|
||||
|
||||
async def wp_blocks_remove_at(
|
||||
self,
|
||||
post_id: int,
|
||||
index: int,
|
||||
**_: Any,
|
||||
) -> dict[str, Any]:
|
||||
post_id = _validate_post_id(post_id)
|
||||
if not isinstance(index, int) or isinstance(index, bool) or index < 0:
|
||||
raise ValueError("index must be a non-negative integer")
|
||||
return await self.client.post(
|
||||
f"{_ADMIN_NS}/blocks/remove",
|
||||
json_data={"post_id": post_id, "index": index},
|
||||
use_custom_namespace=True,
|
||||
)
|
||||
|
||||
# ── Elementor ────────────────────────────────────────────────────
|
||||
|
||||
async def wp_elementor_detect(self, **_: Any) -> dict[str, Any]:
|
||||
return await self.client.get(
|
||||
f"{_ADMIN_NS}/elementor/status",
|
||||
use_custom_namespace=True,
|
||||
)
|
||||
|
||||
async def wp_elementor_get(self, post_id: int, **_: Any) -> dict[str, Any]:
|
||||
post_id = _validate_post_id(post_id)
|
||||
return await self.client.get(
|
||||
f"{_ADMIN_NS}/elementor/{post_id}",
|
||||
use_custom_namespace=True,
|
||||
)
|
||||
|
||||
async def wp_elementor_set(
|
||||
self,
|
||||
post_id: int,
|
||||
data: list[Any],
|
||||
**_: Any,
|
||||
) -> dict[str, Any]:
|
||||
post_id = _validate_post_id(post_id)
|
||||
if not isinstance(data, list):
|
||||
raise ValueError("data must be a top-level Elementor sections array")
|
||||
node_count = _count_elementor_nodes(data)
|
||||
if node_count > _ELEMENTOR_MAX_NODES:
|
||||
raise ValueError(
|
||||
f"Elementor payload has {node_count} nodes — exceeds "
|
||||
f"{_ELEMENTOR_MAX_NODES} per call. Use "
|
||||
f"wp_elementor_template_apply with a saved template instead."
|
||||
)
|
||||
return await self.client.post(
|
||||
f"{_ADMIN_NS}/elementor/{post_id}",
|
||||
json_data={"data": data},
|
||||
use_custom_namespace=True,
|
||||
)
|
||||
|
||||
async def wp_elementor_render_css(self, post_id: int, **_: Any) -> dict[str, Any]:
|
||||
post_id = _validate_post_id(post_id)
|
||||
return await self.client.post(
|
||||
f"{_ADMIN_NS}/elementor/{post_id}/regen-css",
|
||||
json_data={},
|
||||
use_custom_namespace=True,
|
||||
)
|
||||
|
||||
async def wp_elementor_template_list(self, **_: Any) -> dict[str, Any]:
|
||||
return await self.client.get(
|
||||
f"{_ADMIN_NS}/elementor/templates",
|
||||
use_custom_namespace=True,
|
||||
)
|
||||
|
||||
async def wp_elementor_template_apply(
|
||||
self,
|
||||
template_id: int,
|
||||
post_id: int,
|
||||
**_: Any,
|
||||
) -> dict[str, Any]:
|
||||
post_id = _validate_post_id(post_id)
|
||||
if not isinstance(template_id, int) or isinstance(template_id, bool) or template_id <= 0:
|
||||
raise ValueError("template_id must be a positive integer")
|
||||
return await self.client.post(
|
||||
f"{_ADMIN_NS}/elementor/templates/apply",
|
||||
json_data={"template_id": template_id, "post_id": post_id},
|
||||
use_custom_namespace=True,
|
||||
)
|
||||
|
||||
# ── Classic editor ───────────────────────────────────────────────
|
||||
|
||||
async def wp_classic_html_replace(
|
||||
self,
|
||||
post_id: int,
|
||||
html: str,
|
||||
raw_html: bool = False,
|
||||
**_: Any,
|
||||
) -> dict[str, Any]:
|
||||
post_id = _validate_post_id(post_id)
|
||||
if not isinstance(html, str):
|
||||
raise ValueError("html must be a string")
|
||||
return await self.client.post(
|
||||
f"{_ADMIN_NS}/classic/{post_id}/replace",
|
||||
json_data={"html": html, "raw_html": bool(raw_html)},
|
||||
use_custom_namespace=True,
|
||||
)
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────
|
||||
# Block grammar parser (Python port)
|
||||
#
|
||||
# WP's grammar is documented at
|
||||
# https://developer.wordpress.org/block-editor/reference-guides/data/data-core-blocks/
|
||||
# but every block read operation is just round-tripping HTML comments
|
||||
# of the form:
|
||||
# <!-- wp:blockname {"attr":"value"} -->
|
||||
# <p>inner html</p>
|
||||
# <!-- wp:innerName -->...<!-- /wp:innerName -->
|
||||
# <!-- /wp:blockname -->
|
||||
#
|
||||
# Matching the official PHP grammar exactly would require a state
|
||||
# machine; the cases F.19.5 cares about are simpler — we need to
|
||||
# extract the block tree shape (name + attrs + innerHTML + innerBlocks)
|
||||
# so a downstream caller can reason about it. The parser below is
|
||||
# intentionally narrow: it covers `parse_blocks()` output for content
|
||||
# produced by the block editor itself (the only realistic input for
|
||||
# read-back). For freeform / classic-editor content it falls back to a
|
||||
# single ``core/freeform`` block with the original HTML.
|
||||
# ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def _parse_blocks_python(html: str) -> list[dict[str, Any]]:
|
||||
import json
|
||||
import re
|
||||
|
||||
if not html or "<!-- wp:" not in html:
|
||||
if not html:
|
||||
return []
|
||||
return [
|
||||
{
|
||||
"blockName": None,
|
||||
"attrs": {},
|
||||
"innerBlocks": [],
|
||||
"innerHTML": html,
|
||||
"innerContent": [html],
|
||||
}
|
||||
]
|
||||
|
||||
open_re = re.compile(
|
||||
r"<!--\s*wp:([a-z0-9][a-z0-9_/-]*)\s*(\{.*?\})?\s*(/)?-->",
|
||||
re.IGNORECASE | re.DOTALL,
|
||||
)
|
||||
close_re = re.compile(r"<!--\s*/wp:([a-z0-9][a-z0-9_/-]*)\s*-->", re.IGNORECASE)
|
||||
|
||||
pos = 0
|
||||
length = len(html)
|
||||
blocks: list[dict[str, Any]] = []
|
||||
stack: list[dict[str, Any]] = []
|
||||
|
||||
def _attach(block: dict[str, Any]) -> None:
|
||||
if stack:
|
||||
stack[-1]["innerBlocks"].append(block)
|
||||
else:
|
||||
blocks.append(block)
|
||||
|
||||
while pos < length:
|
||||
m_open = open_re.search(html, pos)
|
||||
m_close = close_re.search(html, pos)
|
||||
|
||||
# Pick the earliest match.
|
||||
next_open = m_open.start() if m_open else length
|
||||
next_close = m_close.start() if m_close else length
|
||||
|
||||
if next_open == length and next_close == length:
|
||||
# No more delimiters — flush the rest as freeform on the
|
||||
# outer level (or innerHTML of the open block).
|
||||
tail = html[pos:length]
|
||||
if tail.strip():
|
||||
if stack:
|
||||
stack[-1]["innerHTML"] += tail
|
||||
stack[-1]["innerContent"].append(tail)
|
||||
else:
|
||||
blocks.append(
|
||||
{
|
||||
"blockName": None,
|
||||
"attrs": {},
|
||||
"innerBlocks": [],
|
||||
"innerHTML": tail,
|
||||
"innerContent": [tail],
|
||||
}
|
||||
)
|
||||
break
|
||||
|
||||
if next_open <= next_close and m_open is not None:
|
||||
# Free text before this open tag → inherit by current parent.
|
||||
head = html[pos:next_open]
|
||||
if head:
|
||||
if stack:
|
||||
stack[-1]["innerHTML"] += head
|
||||
stack[-1]["innerContent"].append(head)
|
||||
else:
|
||||
if head.strip():
|
||||
blocks.append(
|
||||
{
|
||||
"blockName": None,
|
||||
"attrs": {},
|
||||
"innerBlocks": [],
|
||||
"innerHTML": head,
|
||||
"innerContent": [head],
|
||||
}
|
||||
)
|
||||
|
||||
name = m_open.group(1)
|
||||
attrs_raw = m_open.group(2)
|
||||
self_closing = m_open.group(3) is not None
|
||||
attrs: dict[str, Any] = {}
|
||||
if attrs_raw:
|
||||
try:
|
||||
attrs = json.loads(attrs_raw)
|
||||
except json.JSONDecodeError:
|
||||
attrs = {"_invalid_json": attrs_raw}
|
||||
block_name = name if "/" in name else f"core/{name}"
|
||||
block = {
|
||||
"blockName": block_name,
|
||||
"attrs": attrs,
|
||||
"innerBlocks": [],
|
||||
"innerHTML": "",
|
||||
"innerContent": [],
|
||||
}
|
||||
pos = m_open.end()
|
||||
if self_closing:
|
||||
_attach(block)
|
||||
else:
|
||||
stack.append(block)
|
||||
elif m_close is not None:
|
||||
head = html[pos:next_close]
|
||||
if head and stack:
|
||||
stack[-1]["innerHTML"] += head
|
||||
stack[-1]["innerContent"].append(head)
|
||||
if stack:
|
||||
closing = stack.pop()
|
||||
_attach(closing)
|
||||
pos = m_close.end()
|
||||
else: # pragma: no cover — guarded by the length check above
|
||||
break
|
||||
|
||||
# Anything left on the stack is a mismatched open — surface it
|
||||
# rather than silently dropping content.
|
||||
while stack:
|
||||
unclosed = stack.pop()
|
||||
_attach(unclosed)
|
||||
|
||||
return blocks
|
||||
407
plugins/wordpress_specialist/handlers/plugins.py
Normal file
407
plugins/wordpress_specialist/handlers/plugins.py
Normal file
@@ -0,0 +1,407 @@
|
||||
"""F.19.2.1 — Plugin write management (install + activate + delete).
|
||||
|
||||
Six tools split across two tiers — the first tools on `wordpress_specialist`
|
||||
that exercise the `install` and `admin` tiers introduced by F.19.2.0:
|
||||
|
||||
* **install tier** (3 tools) — wp.org slug install, activate / deactivate,
|
||||
update. These hit `Plugin_Upgrader` / `activate_plugin` /
|
||||
`deactivate_plugins` against an already-vetted package source (wp.org
|
||||
curated).
|
||||
* **admin tier** (3 tools) — URL/zip install, plugin delete. These see
|
||||
more attack surface (arbitrary zip contents) or have no undo (delete
|
||||
drops plugin data).
|
||||
|
||||
Surface map:
|
||||
|
||||
* `wp_plugin_install_from_slug(slug, activate?)` — install tier
|
||||
* `wp_plugin_install_from_zip(zip_url | zip_base64, activate?, overwrite?)` — admin tier
|
||||
* `wp_plugin_activate(slug, network_wide?)` — install tier
|
||||
* `wp_plugin_deactivate(slug, network_wide?)` — install tier
|
||||
* `wp_plugin_update(slug)` — install tier
|
||||
* `wp_plugin_delete(slug)` — admin tier
|
||||
|
||||
Security rules layered on top of F.19.2 S-1…S-11 + F.19.5 S-12…S-14
|
||||
+ F.19.7 S-15…S-19 (companion enforces these regardless of MCPHub-side
|
||||
guards):
|
||||
|
||||
* **S-15 (reused)** — `slug` must match a key in `get_plugins()` on the
|
||||
WP side for activate / deactivate / update / delete. install routes
|
||||
validate the slug shape on the wire and let `Plugin_Upgrader` handle
|
||||
fetch / extraction / verification.
|
||||
* **S-18 (reused)** — 50 MB cap on install zip payloads.
|
||||
* **S-20** *(new)* — refuses to delete the Airano MCP Bridge companion
|
||||
itself (`airano-mcp-bridge`). Removing the companion via its own
|
||||
route would brick the MCP connection; operators must use the WP-Admin
|
||||
Plugins page instead.
|
||||
* **S-21** *(new)* — refuses to deactivate / delete an active plugin
|
||||
marked as ``Required: yes`` in its header (rare; some hosts ship
|
||||
must-use plugins this way).
|
||||
|
||||
All tools require Airano MCP Bridge v2.15.0+.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from typing import Any
|
||||
|
||||
from plugins.wordpress.client import WordPressClient
|
||||
|
||||
# Companion admin namespace — same prefix used by F.19.1 / F.19.5 / F.19.7.
|
||||
_ADMIN_NS = "airano-mcp/v1/admin"
|
||||
|
||||
# Mirror of the companion's PLUGIN_ZIP_MAX_BYTES (S-18). The companion
|
||||
# enforces the binding limit; this is a cheap pre-check to avoid uploading
|
||||
# a 200 MB payload that will be rejected at the wire anyway.
|
||||
_PLUGIN_ZIP_MAX_BYTES = 50 * 1024 * 1024 # 50 MB
|
||||
|
||||
# Plugin slugs from wp.org are conventionally lowercase letters / digits /
|
||||
# dashes. Some legacy plugins use underscores; the regex permits both.
|
||||
# Length cap mirrors WP's own slug sanitiser (sanitize_key + 64 chars).
|
||||
_PLUGIN_SLUG_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9_-]{0,63}$")
|
||||
|
||||
|
||||
def get_tool_specifications() -> list[dict[str, Any]]:
|
||||
"""Return tool specs for the F.19.2.1 plugin write surface."""
|
||||
return [
|
||||
# ───── Install tier (3) ──────────────────────────────────────
|
||||
{
|
||||
"name": "wp_plugin_install_from_slug",
|
||||
"method_name": "wp_plugin_install_from_slug",
|
||||
"description": (
|
||||
"Install a plugin from a wp.org slug (e.g. 'akismet'). "
|
||||
"Companion calls plugins_api() to resolve the package URL, "
|
||||
"downloads via download_url() (so the WP filesystem "
|
||||
"abstraction stays engaged), then runs WP core's "
|
||||
"Plugin_Upgrader. Set activate=true to activate "
|
||||
"immediately on success — activation requires "
|
||||
"activate_plugins; companion rejects with rest_forbidden "
|
||||
"if missing. Requires Airano MCP Bridge v2.15.0+ and a "
|
||||
"WordPress user with install_plugins."
|
||||
),
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"slug": {
|
||||
"type": "string",
|
||||
"description": (
|
||||
"wp.org plugin slug (the 'foo' in "
|
||||
"https://wordpress.org/plugins/foo/). "
|
||||
"Alphanumerics, dashes, underscores only. "
|
||||
"Accepts the ``folder/file.php`` form returned "
|
||||
"by the capabilities probe — everything after "
|
||||
"the first slash is stripped."
|
||||
),
|
||||
},
|
||||
"activate": {
|
||||
"type": "boolean",
|
||||
"default": False,
|
||||
"description": ("Activate the plugin immediately after install."),
|
||||
},
|
||||
},
|
||||
"required": ["slug"],
|
||||
},
|
||||
"scope": "install",
|
||||
},
|
||||
{
|
||||
"name": "wp_plugin_activate",
|
||||
"method_name": "wp_plugin_activate",
|
||||
"description": (
|
||||
"Activate an installed plugin by slug. Companion resolves "
|
||||
"the slug to the plugin file via get_plugins() (S-15) and "
|
||||
"calls activate_plugin(). When network_wide=true on a "
|
||||
"multisite install the plugin is activated network-wide "
|
||||
"(requires manage_network_plugins). Requires Airano MCP "
|
||||
"Bridge v2.15.0+ and activate_plugins."
|
||||
),
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"slug": {
|
||||
"type": "string",
|
||||
"description": (
|
||||
"Plugin slug (from wp_plugin_list). Accepts "
|
||||
"either the folder-only form (`woocommerce`) "
|
||||
"or the `folder/file.php` form returned by the "
|
||||
"capabilities probe — both are normalised "
|
||||
"client-side."
|
||||
),
|
||||
},
|
||||
"network_wide": {
|
||||
"type": "boolean",
|
||||
"default": False,
|
||||
"description": (
|
||||
"Activate network-wide on multisite. Ignored "
|
||||
"on single-site installs."
|
||||
),
|
||||
},
|
||||
},
|
||||
"required": ["slug"],
|
||||
},
|
||||
"scope": "install",
|
||||
},
|
||||
{
|
||||
"name": "wp_plugin_deactivate",
|
||||
"method_name": "wp_plugin_deactivate",
|
||||
"description": (
|
||||
"Deactivate an installed plugin by slug. Refuses to "
|
||||
"deactivate the Airano MCP Bridge companion itself — "
|
||||
"doing so would brick the MCP connection (S-20). "
|
||||
"Requires Airano MCP Bridge v2.15.0+ and activate_plugins."
|
||||
),
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"slug": {
|
||||
"type": "string",
|
||||
"description": (
|
||||
"Plugin slug. Accepts folder-only "
|
||||
"(`woocommerce`) or `folder/file.php` form."
|
||||
),
|
||||
},
|
||||
"network_wide": {
|
||||
"type": "boolean",
|
||||
"default": False,
|
||||
"description": ("Deactivate network-wide on multisite."),
|
||||
},
|
||||
},
|
||||
"required": ["slug"],
|
||||
},
|
||||
"scope": "install",
|
||||
},
|
||||
{
|
||||
"name": "wp_plugin_update",
|
||||
"method_name": "wp_plugin_update",
|
||||
"description": (
|
||||
"Update an installed plugin to the latest wp.org version. "
|
||||
"Companion checks the cached update_plugins transient + "
|
||||
"runs Plugin_Upgrader::upgrade(). Returns no-op (with "
|
||||
"``up_to_date: true``) when no update is available rather "
|
||||
"than erroring. Requires Airano MCP Bridge v2.15.0+ and "
|
||||
"update_plugins."
|
||||
),
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"slug": {
|
||||
"type": "string",
|
||||
"description": (
|
||||
"Plugin slug. Accepts folder-only "
|
||||
"(`woocommerce`) or `folder/file.php` form."
|
||||
),
|
||||
},
|
||||
},
|
||||
"required": ["slug"],
|
||||
},
|
||||
"scope": "install",
|
||||
},
|
||||
# ───── Admin tier (3) ────────────────────────────────────────
|
||||
{
|
||||
"name": "wp_plugin_install_from_zip",
|
||||
"method_name": "wp_plugin_install_from_zip",
|
||||
"description": (
|
||||
"Install a plugin from a remote URL or inline base64 zip. "
|
||||
"Sees more attack surface than slug install (arbitrary "
|
||||
"zip contents) so this lands on the admin tier. Companion "
|
||||
"still runs Plugin_Upgrader so signature checks stay "
|
||||
"engaged. Pass exactly one of zip_url (companion fetches "
|
||||
"via wp_safe_remote_get) or zip_base64 (decoded "
|
||||
"server-side). Capped at 50 MB per zip (S-18). Requires "
|
||||
"Airano MCP Bridge v2.15.0+ and install_plugins."
|
||||
),
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"zip_url": {
|
||||
"type": "string",
|
||||
"description": (
|
||||
"https URL the companion will download. "
|
||||
"Mutually exclusive with zip_base64."
|
||||
),
|
||||
},
|
||||
"zip_base64": {
|
||||
"type": "string",
|
||||
"description": (
|
||||
"Base64-encoded plugin zip body. Capped at " "~50 MB after decode."
|
||||
),
|
||||
},
|
||||
"activate": {
|
||||
"type": "boolean",
|
||||
"default": False,
|
||||
"description": "Activate after install.",
|
||||
},
|
||||
"overwrite": {
|
||||
"type": "boolean",
|
||||
"default": False,
|
||||
"description": (
|
||||
"Permit overwriting an existing plugin with " "the same slug."
|
||||
),
|
||||
},
|
||||
},
|
||||
},
|
||||
"scope": "admin",
|
||||
},
|
||||
{
|
||||
"name": "wp_plugin_delete",
|
||||
"method_name": "wp_plugin_delete",
|
||||
"description": (
|
||||
"Delete an installed plugin by slug. Refuses to delete "
|
||||
"the Airano MCP Bridge companion itself (S-20) and any "
|
||||
"currently-active plugin (caller must deactivate first). "
|
||||
"Lands on the admin tier because plugin delete drops "
|
||||
"the plugin's database tables / options on uninstall — "
|
||||
"no undo. Requires Airano MCP Bridge v2.15.0+ and "
|
||||
"delete_plugins."
|
||||
),
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"slug": {
|
||||
"type": "string",
|
||||
"description": (
|
||||
"Plugin slug. Accepts folder-only "
|
||||
"(`woocommerce`) or `folder/file.php` form."
|
||||
),
|
||||
},
|
||||
},
|
||||
"required": ["slug"],
|
||||
},
|
||||
"scope": "admin",
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────
|
||||
# Client-side validation helpers
|
||||
# ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def _validate_plugin_slug(slug: Any) -> str:
|
||||
"""Reject obviously-malformed plugin slugs before the wire.
|
||||
|
||||
Accepts both the folder-only form (``woocommerce``) and the
|
||||
``folder/file.php`` form returned by the WP capabilities probe —
|
||||
everything from the first slash onward is stripped, mirroring the
|
||||
companion's own normalisation. The companion still does the binding
|
||||
``get_plugins()`` membership check (S-15) for activate / deactivate /
|
||||
update / delete — this is just structural defence-in-depth.
|
||||
"""
|
||||
if not isinstance(slug, str):
|
||||
raise ValueError("slug must be a string")
|
||||
stripped = slug.strip()
|
||||
if not stripped:
|
||||
raise ValueError("slug must be a non-empty string")
|
||||
folder = stripped.split("/", 1)[0]
|
||||
if not _PLUGIN_SLUG_RE.match(folder):
|
||||
raise ValueError(
|
||||
f"slug must be alphanumerics + dashes + underscores "
|
||||
f"(<=64 chars, no leading dash); got {slug!r}"
|
||||
)
|
||||
return folder
|
||||
|
||||
|
||||
class PluginsHandler:
|
||||
"""Plugin install / activate / deactivate / update / delete surface
|
||||
(F.19.2.1).
|
||||
|
||||
Each method returns the parsed JSON envelope from the companion. The
|
||||
plugin.py wrapper layer is responsible for serialising the dict for
|
||||
MCP transport.
|
||||
"""
|
||||
|
||||
def __init__(self, client: WordPressClient) -> None:
|
||||
self.client = client
|
||||
|
||||
# ── Install tier ────────────────────────────────────────────────
|
||||
|
||||
async def wp_plugin_install_from_slug(
|
||||
self,
|
||||
slug: str,
|
||||
activate: bool = False,
|
||||
**_: Any,
|
||||
) -> dict[str, Any]:
|
||||
slug = _validate_plugin_slug(slug)
|
||||
return await self.client.post(
|
||||
f"{_ADMIN_NS}/plugins/install",
|
||||
json_data={"slug": slug, "activate": bool(activate)},
|
||||
use_custom_namespace=True,
|
||||
)
|
||||
|
||||
async def wp_plugin_activate(
|
||||
self,
|
||||
slug: str,
|
||||
network_wide: bool = False,
|
||||
**_: Any,
|
||||
) -> dict[str, Any]:
|
||||
slug = _validate_plugin_slug(slug)
|
||||
return await self.client.post(
|
||||
f"{_ADMIN_NS}/plugins/{slug}/activate",
|
||||
json_data={"network_wide": bool(network_wide)},
|
||||
use_custom_namespace=True,
|
||||
)
|
||||
|
||||
async def wp_plugin_deactivate(
|
||||
self,
|
||||
slug: str,
|
||||
network_wide: bool = False,
|
||||
**_: Any,
|
||||
) -> dict[str, Any]:
|
||||
slug = _validate_plugin_slug(slug)
|
||||
return await self.client.post(
|
||||
f"{_ADMIN_NS}/plugins/{slug}/deactivate",
|
||||
json_data={"network_wide": bool(network_wide)},
|
||||
use_custom_namespace=True,
|
||||
)
|
||||
|
||||
async def wp_plugin_update(self, slug: str, **_: Any) -> dict[str, Any]:
|
||||
slug = _validate_plugin_slug(slug)
|
||||
return await self.client.post(
|
||||
f"{_ADMIN_NS}/plugins/{slug}/update",
|
||||
json_data={},
|
||||
use_custom_namespace=True,
|
||||
)
|
||||
|
||||
# ── Admin tier ──────────────────────────────────────────────────
|
||||
|
||||
async def wp_plugin_install_from_zip(
|
||||
self,
|
||||
zip_url: str | None = None,
|
||||
zip_base64: str | None = None,
|
||||
activate: bool = False,
|
||||
overwrite: bool = False,
|
||||
**_: Any,
|
||||
) -> dict[str, Any]:
|
||||
if not zip_url and not zip_base64:
|
||||
raise ValueError("wp_plugin_install_from_zip requires zip_url or zip_base64")
|
||||
if zip_url and zip_base64:
|
||||
raise ValueError("wp_plugin_install_from_zip accepts zip_url OR zip_base64, not both")
|
||||
body: dict[str, Any] = {
|
||||
"activate": bool(activate),
|
||||
"overwrite": bool(overwrite),
|
||||
}
|
||||
if zip_url:
|
||||
if not isinstance(zip_url, str):
|
||||
raise ValueError("zip_url must be a string")
|
||||
body["zip_url"] = zip_url
|
||||
else:
|
||||
if not isinstance(zip_base64, str):
|
||||
raise ValueError("zip_base64 must be a string")
|
||||
decoded_size_upper_bound = len(zip_base64) * 3 // 4
|
||||
if decoded_size_upper_bound > _PLUGIN_ZIP_MAX_BYTES:
|
||||
raise ValueError(
|
||||
f"zip_base64 decodes to roughly {decoded_size_upper_bound} bytes "
|
||||
f"— exceeds {_PLUGIN_ZIP_MAX_BYTES} byte cap (S-18)"
|
||||
)
|
||||
body["zip_base64"] = zip_base64
|
||||
return await self.client.post(
|
||||
f"{_ADMIN_NS}/plugins/install",
|
||||
json_data=body,
|
||||
use_custom_namespace=True,
|
||||
)
|
||||
|
||||
async def wp_plugin_delete(self, slug: str, **_: Any) -> dict[str, Any]:
|
||||
slug = _validate_plugin_slug(slug)
|
||||
return await self.client.delete(
|
||||
f"{_ADMIN_NS}/plugins/{slug}",
|
||||
use_custom_namespace=True,
|
||||
)
|
||||
423
plugins/wordpress_specialist/handlers/site_config.py
Normal file
423
plugins/wordpress_specialist/handlers/site_config.py
Normal file
@@ -0,0 +1,423 @@
|
||||
"""F.19.6.A — Site config surface (identity + reading + permalinks).
|
||||
|
||||
First consumer of the ``settings`` tier introduced by F.19.2.0.
|
||||
Six tools split across three small surfaces — every one of them is
|
||||
reachable from the WP-Admin Settings menu, no companion magic, just
|
||||
a typed REST face for what an editor would otherwise click through.
|
||||
|
||||
Surface map (all on the ``settings`` tier):
|
||||
|
||||
* **Site identity** (2 tools, ``/admin/site/identity``):
|
||||
``wp_site_identity_get`` reads title / tagline / site_icon /
|
||||
custom_logo / blog_charset / WP version. ``wp_site_identity_set``
|
||||
writes title / tagline / site_icon_id / custom_logo_id.
|
||||
* **Reading** (2 tools, ``/admin/site/reading``):
|
||||
``wp_reading_settings_get`` / ``wp_reading_settings_set`` cover
|
||||
show_on_front (posts vs page), page_on_front, page_for_posts,
|
||||
posts_per_page, blog_public (search-engine visibility). The
|
||||
``blog_public=false`` write surfaces a hint reminding the caller
|
||||
the change asks search engines not to index but is non-binding.
|
||||
* **Permalinks** (2 tools, ``/admin/permalinks``):
|
||||
``wp_permalinks_get`` / ``wp_permalinks_set``. After a write the
|
||||
companion calls ``flush_rewrite_rules()`` so the new structure
|
||||
takes effect immediately — same as the manual save on
|
||||
Settings → Permalinks.
|
||||
|
||||
Security rules: route-level ``manage_options`` only. No new S-rules
|
||||
this round — every operation maps to a stock WP option write that
|
||||
WP-Admin would do in a single click. WP's own ``sanitize_option_*``
|
||||
hooks fire on each ``update_option`` and provide the safe-input gate.
|
||||
|
||||
All tools require Airano MCP Bridge v2.16.0+.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from typing import Any
|
||||
|
||||
from plugins.wordpress.client import WordPressClient
|
||||
|
||||
# Companion admin namespace — same prefix used by F.19.1 / F.19.5 /
|
||||
# F.19.7 / F.19.2.1.
|
||||
_ADMIN_NS = "airano-mcp/v1/admin"
|
||||
|
||||
# Permalink structure tokens WP recognises. Anything else is rejected
|
||||
# client-side as a cheap pre-check (the companion is the binding gate
|
||||
# — it round-trips through WP's own ``permalink_structure`` sanitiser).
|
||||
_PERMALINK_TOKEN_RE = re.compile(r"^(/|%[a-z_]+%|[A-Za-z0-9_\-])+$")
|
||||
_PERMALINK_MAX_LEN = 256
|
||||
|
||||
# Reading-settings ``show_on_front`` accepts only these two strings —
|
||||
# matches WP's own constant enum.
|
||||
_SHOW_ON_FRONT_VALUES = {"posts", "page"}
|
||||
|
||||
|
||||
def get_tool_specifications() -> list[dict[str, Any]]:
|
||||
"""Return tool specs for the F.19.6.A site config surface."""
|
||||
return [
|
||||
# ───── Site identity (2) ─────────────────────────────────────
|
||||
{
|
||||
"name": "wp_site_identity_get",
|
||||
"method_name": "wp_site_identity_get",
|
||||
"description": (
|
||||
"Read site identity: title (blogname), tagline "
|
||||
"(blogdescription), site_icon (favicon attachment id), "
|
||||
"custom_logo (theme logo attachment id), blog_charset, "
|
||||
"WP version, and admin email. Companion-backed read so "
|
||||
"responses stay consistent with the writer route. "
|
||||
"Requires Airano MCP Bridge v2.16.0+ and manage_options."
|
||||
),
|
||||
"schema": {"type": "object", "properties": {}},
|
||||
"scope": "read",
|
||||
},
|
||||
{
|
||||
"name": "wp_site_identity_set",
|
||||
"method_name": "wp_site_identity_set",
|
||||
"description": (
|
||||
"Update site identity. Pass any subset of title / "
|
||||
"tagline / site_icon_id / custom_logo_id (omitted keys "
|
||||
"are left untouched). Attachment ids are validated by "
|
||||
"the companion against WP's media library — invalid "
|
||||
"ids return ``invalid_attachment``. Requires Airano "
|
||||
"MCP Bridge v2.16.0+ and manage_options."
|
||||
),
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"title": {
|
||||
"type": "string",
|
||||
"description": "Site title (option ``blogname``).",
|
||||
"maxLength": 255,
|
||||
},
|
||||
"tagline": {
|
||||
"type": "string",
|
||||
"description": "Site tagline (option ``blogdescription``).",
|
||||
"maxLength": 1024,
|
||||
},
|
||||
"site_icon_id": {
|
||||
"type": "integer",
|
||||
"minimum": 0,
|
||||
"description": (
|
||||
"Attachment id for the site icon (favicon). "
|
||||
"Pass 0 to clear; pass an integer >= 1 to set."
|
||||
),
|
||||
},
|
||||
"custom_logo_id": {
|
||||
"type": "integer",
|
||||
"minimum": 0,
|
||||
"description": (
|
||||
"Attachment id for the theme custom logo. "
|
||||
"Pass 0 to clear; the active theme must "
|
||||
"declare ``custom-logo`` support."
|
||||
),
|
||||
},
|
||||
},
|
||||
},
|
||||
"scope": "settings",
|
||||
},
|
||||
# ───── Reading settings (2) ──────────────────────────────────
|
||||
{
|
||||
"name": "wp_reading_settings_get",
|
||||
"method_name": "wp_reading_settings_get",
|
||||
"description": (
|
||||
"Read the Settings → Reading panel: show_on_front "
|
||||
"(``posts`` or ``page``), page_on_front, "
|
||||
"page_for_posts, posts_per_page, posts_per_rss, and "
|
||||
"blog_public (search-engine visibility flag — 0 means "
|
||||
"WP asks crawlers not to index). Requires Airano MCP "
|
||||
"Bridge v2.16.0+ and manage_options."
|
||||
),
|
||||
"schema": {"type": "object", "properties": {}},
|
||||
"scope": "read",
|
||||
},
|
||||
{
|
||||
"name": "wp_reading_settings_set",
|
||||
"method_name": "wp_reading_settings_set",
|
||||
"description": (
|
||||
"Update Settings → Reading values. Pass any subset of "
|
||||
"show_on_front / page_on_front / page_for_posts / "
|
||||
"posts_per_page / posts_per_rss / blog_public. When "
|
||||
"show_on_front=='page', page_on_front must be set to a "
|
||||
"published Page id (companion validates). Setting "
|
||||
"blog_public=false tells crawlers not to index — the "
|
||||
"directive is non-binding, real privacy still belongs "
|
||||
"to httpd auth or membership plugins. Requires Airano "
|
||||
"MCP Bridge v2.16.0+ and manage_options."
|
||||
),
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"show_on_front": {
|
||||
"type": "string",
|
||||
"enum": ["posts", "page"],
|
||||
"description": (
|
||||
"``posts`` shows the latest blog posts on "
|
||||
"the home URL; ``page`` uses two static "
|
||||
"pages (front + posts archive)."
|
||||
),
|
||||
},
|
||||
"page_on_front": {
|
||||
"type": "integer",
|
||||
"minimum": 0,
|
||||
"description": (
|
||||
"Page id used as the static front page "
|
||||
"(only consulted when show_on_front=page)."
|
||||
),
|
||||
},
|
||||
"page_for_posts": {
|
||||
"type": "integer",
|
||||
"minimum": 0,
|
||||
"description": (
|
||||
"Page id used as the blog posts archive "
|
||||
"(only consulted when show_on_front=page)."
|
||||
),
|
||||
},
|
||||
"posts_per_page": {
|
||||
"type": "integer",
|
||||
"minimum": 1,
|
||||
"maximum": 100,
|
||||
"description": "Posts shown per page on archives + the home feed.",
|
||||
},
|
||||
"posts_per_rss": {
|
||||
"type": "integer",
|
||||
"minimum": 1,
|
||||
"maximum": 100,
|
||||
"description": "Items emitted in RSS / Atom feeds.",
|
||||
},
|
||||
"blog_public": {
|
||||
"type": "boolean",
|
||||
"description": (
|
||||
"true = invite search engines to index; "
|
||||
"false = ask crawlers not to. Non-binding."
|
||||
),
|
||||
},
|
||||
},
|
||||
},
|
||||
"scope": "settings",
|
||||
},
|
||||
# ───── Permalinks (2) ────────────────────────────────────────
|
||||
{
|
||||
"name": "wp_permalinks_get",
|
||||
"method_name": "wp_permalinks_get",
|
||||
"description": (
|
||||
"Read the current permalink structure (option "
|
||||
"``permalink_structure``) plus the category_base + "
|
||||
"tag_base prefixes. Empty string in ``structure`` "
|
||||
'means "plain" permalinks (?p=N). Requires Airano '
|
||||
"MCP Bridge v2.16.0+ and manage_options."
|
||||
),
|
||||
"schema": {"type": "object", "properties": {}},
|
||||
"scope": "read",
|
||||
},
|
||||
{
|
||||
"name": "wp_permalinks_set",
|
||||
"method_name": "wp_permalinks_set",
|
||||
"description": (
|
||||
"Update the permalink structure. Common safe values: "
|
||||
"``/%postname%/``, ``/%year%/%monthnum%/%postname%/``, "
|
||||
"``/%category%/%postname%/``. Pass an empty string for "
|
||||
"plain permalinks. The companion writes the option "
|
||||
"then calls ``flush_rewrite_rules()`` so the new "
|
||||
"structure takes effect immediately. Optional "
|
||||
"category_base / tag_base override the default "
|
||||
"``category`` / ``tag`` prefixes. Requires Airano MCP "
|
||||
"Bridge v2.16.0+ and manage_options."
|
||||
),
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"structure": {
|
||||
"type": "string",
|
||||
"description": (
|
||||
"Permalink template using WP tokens "
|
||||
"(``%postname%``, ``%post_id%``, ``%year%``, "
|
||||
"``%monthnum%``, ``%day%``, ``%hour%``, "
|
||||
"``%minute%``, ``%second%``, ``%category%``, "
|
||||
"``%author%``). Empty string = plain."
|
||||
),
|
||||
"maxLength": _PERMALINK_MAX_LEN,
|
||||
},
|
||||
"category_base": {
|
||||
"type": "string",
|
||||
"description": "Category archive prefix (default ``category``).",
|
||||
"maxLength": 64,
|
||||
},
|
||||
"tag_base": {
|
||||
"type": "string",
|
||||
"description": "Tag archive prefix (default ``tag``).",
|
||||
"maxLength": 64,
|
||||
},
|
||||
},
|
||||
"required": ["structure"],
|
||||
},
|
||||
"scope": "settings",
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────
|
||||
# Client-side validation helpers
|
||||
# ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def _validate_permalink_structure(structure: Any) -> str:
|
||||
"""Cheap structural pre-check for permalink_structure.
|
||||
|
||||
Empty string ("plain" permalinks) is valid. Otherwise we accept
|
||||
only `/`, `%token%`, alphanumerics, `_`, and `-`. The companion's
|
||||
``sanitize_option_permalink_structure`` is the binding sanitiser.
|
||||
"""
|
||||
if not isinstance(structure, str):
|
||||
raise ValueError("structure must be a string")
|
||||
if structure == "":
|
||||
return ""
|
||||
if len(structure) > _PERMALINK_MAX_LEN:
|
||||
raise ValueError(f"structure exceeds {_PERMALINK_MAX_LEN} char cap")
|
||||
if "\x00" in structure:
|
||||
raise ValueError("structure must not contain null bytes")
|
||||
if not _PERMALINK_TOKEN_RE.match(structure):
|
||||
raise ValueError(
|
||||
"structure may only contain `/`, `%token%`, alnum, `_`, `-` "
|
||||
"(got unexpected character)"
|
||||
)
|
||||
return structure
|
||||
|
||||
|
||||
def _validate_attachment_id(value: Any, field: str) -> int:
|
||||
if not isinstance(value, int) or isinstance(value, bool) or value < 0:
|
||||
raise ValueError(f"{field} must be a non-negative integer (got {value!r})")
|
||||
return value
|
||||
|
||||
|
||||
class SiteConfigHandler:
|
||||
"""Site identity + reading + permalinks surface (F.19.6.A).
|
||||
|
||||
Each method returns the parsed JSON envelope from the companion.
|
||||
The plugin.py wrapper is responsible for serialising for MCP
|
||||
transport.
|
||||
"""
|
||||
|
||||
def __init__(self, client: WordPressClient) -> None:
|
||||
self.client = client
|
||||
|
||||
# ── Identity ────────────────────────────────────────────────────
|
||||
|
||||
async def wp_site_identity_get(self, **_: Any) -> dict[str, Any]:
|
||||
return await self.client.get(
|
||||
f"{_ADMIN_NS}/site/identity",
|
||||
use_custom_namespace=True,
|
||||
)
|
||||
|
||||
async def wp_site_identity_set(
|
||||
self,
|
||||
title: str | None = None,
|
||||
tagline: str | None = None,
|
||||
site_icon_id: int | None = None,
|
||||
custom_logo_id: int | None = None,
|
||||
**_: Any,
|
||||
) -> dict[str, Any]:
|
||||
body: dict[str, Any] = {}
|
||||
if title is not None:
|
||||
if not isinstance(title, str):
|
||||
raise ValueError("title must be a string")
|
||||
body["title"] = title
|
||||
if tagline is not None:
|
||||
if not isinstance(tagline, str):
|
||||
raise ValueError("tagline must be a string")
|
||||
body["tagline"] = tagline
|
||||
if site_icon_id is not None:
|
||||
body["site_icon_id"] = _validate_attachment_id(site_icon_id, "site_icon_id")
|
||||
if custom_logo_id is not None:
|
||||
body["custom_logo_id"] = _validate_attachment_id(custom_logo_id, "custom_logo_id")
|
||||
if not body:
|
||||
raise ValueError("wp_site_identity_set requires at least one field to update")
|
||||
return await self.client.post(
|
||||
f"{_ADMIN_NS}/site/identity",
|
||||
json_data=body,
|
||||
use_custom_namespace=True,
|
||||
)
|
||||
|
||||
# ── Reading ─────────────────────────────────────────────────────
|
||||
|
||||
async def wp_reading_settings_get(self, **_: Any) -> dict[str, Any]:
|
||||
return await self.client.get(
|
||||
f"{_ADMIN_NS}/site/reading",
|
||||
use_custom_namespace=True,
|
||||
)
|
||||
|
||||
async def wp_reading_settings_set(
|
||||
self,
|
||||
show_on_front: str | None = None,
|
||||
page_on_front: int | None = None,
|
||||
page_for_posts: int | None = None,
|
||||
posts_per_page: int | None = None,
|
||||
posts_per_rss: int | None = None,
|
||||
blog_public: bool | None = None,
|
||||
**_: Any,
|
||||
) -> dict[str, Any]:
|
||||
body: dict[str, Any] = {}
|
||||
if show_on_front is not None:
|
||||
if show_on_front not in _SHOW_ON_FRONT_VALUES:
|
||||
raise ValueError(
|
||||
f"show_on_front must be one of {sorted(_SHOW_ON_FRONT_VALUES)} "
|
||||
f"(got {show_on_front!r})"
|
||||
)
|
||||
body["show_on_front"] = show_on_front
|
||||
if page_on_front is not None:
|
||||
body["page_on_front"] = _validate_attachment_id(page_on_front, "page_on_front")
|
||||
if page_for_posts is not None:
|
||||
body["page_for_posts"] = _validate_attachment_id(page_for_posts, "page_for_posts")
|
||||
if posts_per_page is not None:
|
||||
if not isinstance(posts_per_page, int) or isinstance(posts_per_page, bool):
|
||||
raise ValueError("posts_per_page must be an integer")
|
||||
if posts_per_page < 1 or posts_per_page > 100:
|
||||
raise ValueError("posts_per_page must be between 1 and 100")
|
||||
body["posts_per_page"] = posts_per_page
|
||||
if posts_per_rss is not None:
|
||||
if not isinstance(posts_per_rss, int) or isinstance(posts_per_rss, bool):
|
||||
raise ValueError("posts_per_rss must be an integer")
|
||||
if posts_per_rss < 1 or posts_per_rss > 100:
|
||||
raise ValueError("posts_per_rss must be between 1 and 100")
|
||||
body["posts_per_rss"] = posts_per_rss
|
||||
if blog_public is not None:
|
||||
if not isinstance(blog_public, bool):
|
||||
raise ValueError("blog_public must be a boolean")
|
||||
body["blog_public"] = blog_public
|
||||
if not body:
|
||||
raise ValueError("wp_reading_settings_set requires at least one field to update")
|
||||
return await self.client.post(
|
||||
f"{_ADMIN_NS}/site/reading",
|
||||
json_data=body,
|
||||
use_custom_namespace=True,
|
||||
)
|
||||
|
||||
# ── Permalinks ──────────────────────────────────────────────────
|
||||
|
||||
async def wp_permalinks_get(self, **_: Any) -> dict[str, Any]:
|
||||
return await self.client.get(
|
||||
f"{_ADMIN_NS}/permalinks",
|
||||
use_custom_namespace=True,
|
||||
)
|
||||
|
||||
async def wp_permalinks_set(
|
||||
self,
|
||||
structure: str,
|
||||
category_base: str | None = None,
|
||||
tag_base: str | None = None,
|
||||
**_: Any,
|
||||
) -> dict[str, Any]:
|
||||
body: dict[str, Any] = {"structure": _validate_permalink_structure(structure)}
|
||||
if category_base is not None:
|
||||
if not isinstance(category_base, str) or len(category_base) > 64:
|
||||
raise ValueError("category_base must be a string up to 64 chars")
|
||||
body["category_base"] = category_base
|
||||
if tag_base is not None:
|
||||
if not isinstance(tag_base, str) or len(tag_base) > 64:
|
||||
raise ValueError("tag_base must be a string up to 64 chars")
|
||||
body["tag_base"] = tag_base
|
||||
return await self.client.post(
|
||||
f"{_ADMIN_NS}/permalinks",
|
||||
json_data=body,
|
||||
use_custom_namespace=True,
|
||||
)
|
||||
480
plugins/wordpress_specialist/handlers/site_layout.py
Normal file
480
plugins/wordpress_specialist/handlers/site_layout.py
Normal file
@@ -0,0 +1,480 @@
|
||||
"""F.19.6.B — Site layout surface (menus + widgets + customizer).
|
||||
|
||||
Closes the Settings → Menus, Appearance → Widgets, and Customizer gaps
|
||||
on ``wordpress_specialist``. Sits on the same ``settings`` tier as the
|
||||
F.19.6.A site config surface — same risk class, same dashboard preset,
|
||||
no new tier this round.
|
||||
|
||||
Surface map:
|
||||
|
||||
* **Menus** (3 tools, ``/admin/menus``):
|
||||
``wp_menu_list`` enumerates every nav menu with its theme-location
|
||||
bindings + item count. ``wp_menu_get`` reads one menu's items.
|
||||
``wp_menu_set`` does a full-replace write — items not in the array
|
||||
are deleted, new items are created, existing items (matched by id)
|
||||
are updated. Slug stays frozen so ``theme_location`` mapping survives.
|
||||
|
||||
* **Widgets** (3 tools, ``/admin/widgets/*``):
|
||||
``wp_widget_areas_list`` enumerates registered sidebar areas with
|
||||
their kind (``block`` or ``legacy``). ``wp_widget_get`` reads one
|
||||
area; block-kind areas return parsed block trees + raw HTML for
|
||||
roundtrip, legacy-kind areas return option-keyed settings.
|
||||
``wp_widget_set`` does a full-replace write — block areas accept any
|
||||
block raw HTML; legacy areas accept ``text`` widget settings only
|
||||
this round (other legacy types are read-only).
|
||||
|
||||
* **Customizer** (1 tool, ``/admin/customizer/changeset``):
|
||||
``wp_customizer_changeset`` wraps the customizer changeset queue with
|
||||
a single action enum (``get`` / ``apply`` / ``discard``). Lower
|
||||
priority — most modern themes use the FSE site editor instead of the
|
||||
customizer.
|
||||
|
||||
Security rules (extending S-1…S-21):
|
||||
|
||||
* **S-22** — Nav-menu items reference posts/terms via ``object_id``.
|
||||
Companion dispatches by item ``type``: ``post_type`` checks
|
||||
``read_post`` meta cap; ``taxonomy`` allows public taxonomies and
|
||||
otherwise requires the taxonomy's ``assign_terms`` cap (deliberately
|
||||
NOT ``manage_categories`` — that's a write cap and would refuse
|
||||
routine "add public Category X to footer" flows for editors who
|
||||
don't manage taxonomies); ``custom`` URL items skip the object check.
|
||||
Refusals surface as ``forbidden_object_id`` 403/404.
|
||||
* **S-23** — Widget HTML (block ``content`` and legacy ``text.text``)
|
||||
is sanitised via ``wp_kses_post`` unless the caller has
|
||||
``unfiltered_html``. Mirrors S-13 from F.19.5.
|
||||
* **S-24** — Customizer ``apply`` requires the caller to also hold
|
||||
``customize`` (the cap WP uses for ``/wp-admin/customize.php``).
|
||||
``manage_options`` alone is not enough — same as the WP UI. ``get``
|
||||
and ``discard`` only need ``manage_options``.
|
||||
|
||||
All tools require Airano MCP Bridge v2.17.0+.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from plugins.wordpress.client import WordPressClient
|
||||
|
||||
# Companion admin namespace — same prefix used by every F.19.* surface.
|
||||
_ADMIN_NS = "airano-mcp/v1/admin"
|
||||
|
||||
# Nav-menu item types WP recognises. Anything else is rejected client-side
|
||||
# as a cheap pre-check — the companion's S-22 dispatcher is the binding gate.
|
||||
_MENU_ITEM_TYPES = {"post_type", "taxonomy", "custom"}
|
||||
|
||||
# Customizer changeset actions.
|
||||
_CUSTOMIZER_ACTIONS = {"get", "apply", "discard"}
|
||||
|
||||
|
||||
def get_tool_specifications() -> list[dict[str, Any]]:
|
||||
"""Return tool specs for the F.19.6.B site layout surface."""
|
||||
return [
|
||||
# ───── Menus (3) ─────────────────────────────────────────────
|
||||
{
|
||||
"name": "wp_menu_list",
|
||||
"method_name": "wp_menu_list",
|
||||
"description": (
|
||||
"List every WordPress nav-menu with id / name / slug, "
|
||||
"the theme locations bound to it, and the item count. "
|
||||
"Use to discover menu_id before calling wp_menu_get / "
|
||||
"wp_menu_set. Requires Airano MCP Bridge v2.17.0+ and "
|
||||
"manage_options."
|
||||
),
|
||||
"schema": {"type": "object", "properties": {}},
|
||||
"scope": "read",
|
||||
},
|
||||
{
|
||||
"name": "wp_menu_get",
|
||||
"method_name": "wp_menu_get",
|
||||
"description": (
|
||||
"Read a single nav-menu's items. Returns "
|
||||
"``{id, name, slug, items: [{id, title, type, object, "
|
||||
"object_id, parent, order, url, target, classes, xfn}]}``. "
|
||||
"``type`` is one of ``post_type`` / ``taxonomy`` / "
|
||||
"``custom``. Requires Airano MCP Bridge v2.17.0+ and "
|
||||
"manage_options."
|
||||
),
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"menu_id": {
|
||||
"type": "integer",
|
||||
"minimum": 1,
|
||||
"description": "Menu term id (from wp_menu_list).",
|
||||
},
|
||||
},
|
||||
"required": ["menu_id"],
|
||||
},
|
||||
"scope": "read",
|
||||
},
|
||||
{
|
||||
"name": "wp_menu_set",
|
||||
"method_name": "wp_menu_set",
|
||||
"description": (
|
||||
"Full-replace a nav-menu's items. Pass the complete "
|
||||
"items array — items not in the array are deleted, new "
|
||||
"items are created (omit ``id`` or set it to 0), "
|
||||
"existing items (matched by ``id``) are updated. Slug "
|
||||
"stays frozen so the theme_location mapping survives. "
|
||||
"Optional ``name`` renames the menu. S-22: each item's "
|
||||
"``object_id`` is validated against the caller's read "
|
||||
"permissions — ``post_type`` items require ``read_post`` "
|
||||
"on the target; non-public ``taxonomy`` items require "
|
||||
"the taxonomy's ``assign_terms`` cap; ``custom`` URL "
|
||||
"items skip the object check. Validation runs across "
|
||||
"every item before any mutation, so a refusal mid-array "
|
||||
"leaves the menu untouched. Requires Airano MCP Bridge "
|
||||
"v2.17.0+ and manage_options."
|
||||
),
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"menu_id": {
|
||||
"type": "integer",
|
||||
"minimum": 1,
|
||||
"description": "Menu term id to overwrite.",
|
||||
},
|
||||
"items": {
|
||||
"type": "array",
|
||||
"description": "Full menu items array.",
|
||||
"items": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"id": {
|
||||
"type": "integer",
|
||||
"description": (
|
||||
"Existing item id to update; "
|
||||
"omit or 0 to create a new item."
|
||||
),
|
||||
},
|
||||
"title": {"type": "string"},
|
||||
"type": {
|
||||
"type": "string",
|
||||
"enum": ["post_type", "taxonomy", "custom"],
|
||||
},
|
||||
"object": {
|
||||
"type": "string",
|
||||
"description": (
|
||||
"post_type slug for ``post_type`` "
|
||||
"items, taxonomy slug for "
|
||||
"``taxonomy`` items; ignored for "
|
||||
"``custom``."
|
||||
),
|
||||
},
|
||||
"object_id": {
|
||||
"type": "integer",
|
||||
"minimum": 0,
|
||||
"description": (
|
||||
"Referenced post / term id; " "omit for ``custom`` items."
|
||||
),
|
||||
},
|
||||
"parent": {"type": "integer", "minimum": 0},
|
||||
"order": {"type": "integer", "minimum": 0},
|
||||
"url": {
|
||||
"type": "string",
|
||||
"description": (
|
||||
"URL for ``custom`` items; "
|
||||
"ignored for post_type / taxonomy."
|
||||
),
|
||||
},
|
||||
"target": {"type": "string"},
|
||||
},
|
||||
},
|
||||
},
|
||||
"name": {
|
||||
"type": "string",
|
||||
"description": "Optional rename. Slug stays frozen.",
|
||||
},
|
||||
},
|
||||
"required": ["menu_id", "items"],
|
||||
},
|
||||
"scope": "settings",
|
||||
},
|
||||
# ───── Widgets (3) ───────────────────────────────────────────
|
||||
{
|
||||
"name": "wp_widget_areas_list",
|
||||
"method_name": "wp_widget_areas_list",
|
||||
"description": (
|
||||
"List every registered sidebar / widget area with id, "
|
||||
"name, theme_location, widget_count, and kind "
|
||||
"(``block`` or ``legacy``). Block areas store widgets "
|
||||
"as block instances; legacy areas use per-widget option "
|
||||
"keys. Requires Airano MCP Bridge v2.17.0+ and "
|
||||
"manage_options."
|
||||
),
|
||||
"schema": {"type": "object", "properties": {}},
|
||||
"scope": "read",
|
||||
},
|
||||
{
|
||||
"name": "wp_widget_get",
|
||||
"method_name": "wp_widget_get",
|
||||
"description": (
|
||||
"Read one widget area's contents. Returns "
|
||||
"``{area_id, kind, widgets: [...]}``. For ``block`` "
|
||||
"kind, each widget is "
|
||||
"``{id, type:'block', blocks:[...parsed], raw:'<!-- wp:... -->'}`` "
|
||||
"— ``raw`` is the round-trippable HTML; ``blocks`` is "
|
||||
"the parsed tree for inspection. For ``legacy`` kind, "
|
||||
"each widget is "
|
||||
"``{id, type, settings:{...}}`` keyed by the widget's "
|
||||
"option store. Requires Airano MCP Bridge v2.17.0+ and "
|
||||
"manage_options."
|
||||
),
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"area_id": {
|
||||
"type": "string",
|
||||
"description": "Sidebar id (from wp_widget_areas_list).",
|
||||
},
|
||||
},
|
||||
"required": ["area_id"],
|
||||
},
|
||||
"scope": "read",
|
||||
},
|
||||
{
|
||||
"name": "wp_widget_set",
|
||||
"method_name": "wp_widget_set",
|
||||
"description": (
|
||||
"Full-replace a widget area's contents. Block-kind "
|
||||
"areas accept any widget with ``raw`` (block HTML) or "
|
||||
"``blocks`` (block tree, server serialises). Legacy "
|
||||
"areas accept ``text`` widget settings only this round "
|
||||
"— other legacy widget types remain read-only. Caller-"
|
||||
"side ``kind`` is ignored: area kind is determined by "
|
||||
"the area itself; block↔legacy conversion is a theme-"
|
||||
"level decision, not an MCP one. S-23: HTML payloads "
|
||||
"are sanitised via ``wp_kses_post`` unless the caller "
|
||||
"has ``unfiltered_html``. Requires Airano MCP Bridge "
|
||||
"v2.17.0+ and manage_options."
|
||||
),
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"area_id": {"type": "string"},
|
||||
"widgets": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"type": {
|
||||
"type": "string",
|
||||
"description": (
|
||||
"``block`` for block-kind " "areas; ``text`` for legacy."
|
||||
),
|
||||
},
|
||||
"raw": {
|
||||
"type": "string",
|
||||
"description": ("Block HTML (block kind only)."),
|
||||
},
|
||||
"blocks": {
|
||||
"type": "array",
|
||||
"description": (
|
||||
"Parsed block tree (block kind "
|
||||
"only); used when ``raw`` is "
|
||||
"absent."
|
||||
),
|
||||
},
|
||||
"settings": {
|
||||
"type": "object",
|
||||
"description": (
|
||||
"Widget settings (legacy kind). "
|
||||
"For ``text`` widgets: "
|
||||
"{title, text, filter?, visual?}."
|
||||
),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
"required": ["area_id", "widgets"],
|
||||
},
|
||||
"scope": "settings",
|
||||
},
|
||||
# ───── Customizer (1) ────────────────────────────────────────
|
||||
{
|
||||
"name": "wp_customizer_changeset",
|
||||
"method_name": "wp_customizer_changeset",
|
||||
"description": (
|
||||
"Inspect or commit the pending customizer changeset. "
|
||||
"``action='get'`` returns the pending changeset payload "
|
||||
"(or ``{status:'empty'}`` when nothing is queued). "
|
||||
"``action='apply'`` publishes it; ``action='discard'`` "
|
||||
"trashes it. S-24: ``apply`` requires the caller to "
|
||||
"also hold the ``customize`` cap (same bar as "
|
||||
"``/wp-admin/customize.php``); ``manage_options`` alone "
|
||||
"is not enough. Apply is racy with concurrent edits "
|
||||
"via the customizer UI — this is intentional and "
|
||||
"mirrors WP's own behaviour. Requires Airano MCP "
|
||||
"Bridge v2.17.0+."
|
||||
),
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"action": {
|
||||
"type": "string",
|
||||
"enum": ["get", "apply", "discard"],
|
||||
},
|
||||
},
|
||||
"required": ["action"],
|
||||
},
|
||||
"scope": "settings",
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────
|
||||
# Client-side validation helpers
|
||||
# ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def _validate_post_id(value: Any, field: str) -> int:
|
||||
"""Validate a post / term id reference.
|
||||
|
||||
Sibling to ``site_config._validate_attachment_id`` — separate so the
|
||||
misnaming in F.19.6.A doesn't propagate. Both are non-negative-int
|
||||
pre-checks; the binding existence + capability gate lives server-side
|
||||
in the companion (S-22 dispatcher for menus, attachment lookup for
|
||||
site identity).
|
||||
"""
|
||||
if not isinstance(value, int) or isinstance(value, bool) or value < 0:
|
||||
raise ValueError(f"{field} must be a non-negative integer (got {value!r})")
|
||||
return value
|
||||
|
||||
|
||||
def _validate_menu_item(item: Any, idx: int) -> dict[str, Any]:
|
||||
"""Cheap structural pre-check for a nav-menu item.
|
||||
|
||||
The S-22 capability dispatch is server-side; this only catches
|
||||
obvious shape errors so the caller gets fast feedback without a
|
||||
round-trip. ``custom`` items skip the ``object_id`` check (URL is
|
||||
sanitised by the companion via ``esc_url_raw``).
|
||||
"""
|
||||
if not isinstance(item, dict):
|
||||
raise ValueError(f"items[{idx}] must be an object")
|
||||
item_type = item.get("type", "custom")
|
||||
if item_type not in _MENU_ITEM_TYPES:
|
||||
raise ValueError(
|
||||
f"items[{idx}].type must be one of {sorted(_MENU_ITEM_TYPES)} " f"(got {item_type!r})"
|
||||
)
|
||||
if item_type != "custom":
|
||||
# post_type + taxonomy require an object_id.
|
||||
object_id = item.get("object_id", 0)
|
||||
_validate_post_id(object_id, f"items[{idx}].object_id")
|
||||
if object_id == 0:
|
||||
raise ValueError(f"items[{idx}].object_id is required for {item_type} items")
|
||||
return item
|
||||
|
||||
|
||||
class SiteLayoutHandler:
|
||||
"""Site layout surface (F.19.6.B) — menus + widgets + customizer.
|
||||
|
||||
Each method returns the parsed JSON envelope from the companion.
|
||||
The plugin.py wrapper serialises for MCP transport. Server errors
|
||||
(403 forbidden_object_id, 400 invalid_action, etc.) are relayed
|
||||
untouched — the companion is the binding gate.
|
||||
"""
|
||||
|
||||
def __init__(self, client: WordPressClient) -> None:
|
||||
self.client = client
|
||||
|
||||
# ── Menus ───────────────────────────────────────────────────────
|
||||
|
||||
async def wp_menu_list(self, **_: Any) -> dict[str, Any]:
|
||||
return await self.client.get(
|
||||
f"{_ADMIN_NS}/menus",
|
||||
use_custom_namespace=True,
|
||||
)
|
||||
|
||||
async def wp_menu_get(self, menu_id: int, **_: Any) -> dict[str, Any]:
|
||||
_validate_post_id(menu_id, "menu_id")
|
||||
if menu_id == 0:
|
||||
raise ValueError("menu_id is required (got 0)")
|
||||
return await self.client.get(
|
||||
f"{_ADMIN_NS}/menus/{menu_id}",
|
||||
use_custom_namespace=True,
|
||||
)
|
||||
|
||||
async def wp_menu_set(
|
||||
self,
|
||||
menu_id: int,
|
||||
items: list[dict[str, Any]],
|
||||
name: str | None = None,
|
||||
**_: Any,
|
||||
) -> dict[str, Any]:
|
||||
_validate_post_id(menu_id, "menu_id")
|
||||
if menu_id == 0:
|
||||
raise ValueError("menu_id is required (got 0)")
|
||||
if not isinstance(items, list):
|
||||
raise ValueError("items must be a list")
|
||||
for idx, item in enumerate(items):
|
||||
_validate_menu_item(item, idx)
|
||||
body: dict[str, Any] = {"items": items}
|
||||
if name is not None:
|
||||
if not isinstance(name, str) or not name.strip():
|
||||
raise ValueError("name must be a non-empty string")
|
||||
body["name"] = name
|
||||
return await self.client.put(
|
||||
f"{_ADMIN_NS}/menus/{menu_id}",
|
||||
json_data=body,
|
||||
use_custom_namespace=True,
|
||||
)
|
||||
|
||||
# ── Widgets ─────────────────────────────────────────────────────
|
||||
|
||||
async def wp_widget_areas_list(self, **_: Any) -> dict[str, Any]:
|
||||
return await self.client.get(
|
||||
f"{_ADMIN_NS}/widgets/areas",
|
||||
use_custom_namespace=True,
|
||||
)
|
||||
|
||||
async def wp_widget_get(self, area_id: str, **_: Any) -> dict[str, Any]:
|
||||
if not isinstance(area_id, str) or not area_id:
|
||||
raise ValueError("area_id must be a non-empty string")
|
||||
return await self.client.get(
|
||||
f"{_ADMIN_NS}/widgets/{area_id}",
|
||||
use_custom_namespace=True,
|
||||
)
|
||||
|
||||
async def wp_widget_set(
|
||||
self,
|
||||
area_id: str,
|
||||
widgets: list[dict[str, Any]],
|
||||
**_: Any,
|
||||
) -> dict[str, Any]:
|
||||
if not isinstance(area_id, str) or not area_id:
|
||||
raise ValueError("area_id must be a non-empty string")
|
||||
if not isinstance(widgets, list):
|
||||
raise ValueError("widgets must be a list")
|
||||
# Strip caller-side ``kind`` if present — area kind is determined
|
||||
# by the area itself, not the request. Block↔legacy conversion
|
||||
# is a theme-level decision.
|
||||
cleaned: list[dict[str, Any]] = []
|
||||
for idx, w in enumerate(widgets):
|
||||
if not isinstance(w, dict):
|
||||
raise ValueError(f"widgets[{idx}] must be an object")
|
||||
cleaned.append({k: v for k, v in w.items() if k != "kind"})
|
||||
body = {"widgets": cleaned}
|
||||
return await self.client.put(
|
||||
f"{_ADMIN_NS}/widgets/{area_id}",
|
||||
json_data=body,
|
||||
use_custom_namespace=True,
|
||||
)
|
||||
|
||||
# ── Customizer ──────────────────────────────────────────────────
|
||||
|
||||
async def wp_customizer_changeset(
|
||||
self,
|
||||
action: str,
|
||||
**_: Any,
|
||||
) -> dict[str, Any]:
|
||||
if action not in _CUSTOMIZER_ACTIONS:
|
||||
raise ValueError(
|
||||
f"action must be one of {sorted(_CUSTOMIZER_ACTIONS)} " f"(got {action!r})"
|
||||
)
|
||||
return await self.client.post(
|
||||
f"{_ADMIN_NS}/customizer/changeset",
|
||||
json_data={"action": action},
|
||||
use_custom_namespace=True,
|
||||
)
|
||||
538
plugins/wordpress_specialist/handlers/themes.py
Normal file
538
plugins/wordpress_specialist/handlers/themes.py
Normal file
@@ -0,0 +1,538 @@
|
||||
"""F.19.7 — Theme dev surface (install + file CRUD).
|
||||
|
||||
Seven tools split across two surfaces. Both ride the same companion
|
||||
plugin (Airano MCP Bridge v2.14.0+) and stay on the existing ``editor``
|
||||
tier introduced by F.19.5 — theme work is the same risk class as page
|
||||
editing, no new tier needed.
|
||||
|
||||
Surface map:
|
||||
|
||||
* **Theme management** (3 tools, companion v2.14.0 routes
|
||||
``/admin/themes/*``): ``wp_theme_install_from_zip`` (POST install),
|
||||
``wp_theme_activate``, ``wp_theme_delete``. Install accepts either
|
||||
a remote ``zip_url`` (companion downloads via ``wp_safe_remote_get``)
|
||||
or an inline ``zip_base64`` (cap 50 MB, decoded server-side). All three
|
||||
ride WP core's ``Theme_Upgrader`` so signature checks and the existing
|
||||
filesystem abstraction stay engaged.
|
||||
* **Theme file CRUD** (4 tools, ``/admin/themes/files/*``):
|
||||
``wp_theme_file_list`` (glob walk), ``wp_theme_file_read``,
|
||||
``wp_theme_file_write``, ``wp_theme_file_delete``. Reads/writes go
|
||||
through ``WP_Filesystem_Direct`` server-side; payloads round-trip as
|
||||
base64 so the JSON envelope stays binary-safe (favicons, fonts, MO
|
||||
files, etc.).
|
||||
|
||||
Security rules layered on top of F.19.2 S-1…S-11 + F.19.5 S-12…S-14
|
||||
(companion enforces these regardless of MCPHub-side guards):
|
||||
|
||||
* **S-15** — ``theme_slug`` must match a key in ``wp_get_themes()``.
|
||||
Companion rejects anything else with ``theme_not_found`` (404).
|
||||
MCPHub-side: a structural slug guard (alphanumerics, dashes,
|
||||
underscores; no slashes / dots / null bytes / leading dash) so
|
||||
malformed slugs don't reach the wire.
|
||||
* **S-16** — Path canonicalisation. Every file route resolves
|
||||
``wp-content/themes/{slug}/{path}`` via ``realpath()`` and rejects
|
||||
results that escape the slug directory. Blocks ``..``, symlinks
|
||||
pointing outside, absolute paths, null bytes. MCPHub-side does a
|
||||
best-effort structural pre-check (the companion's realpath is the
|
||||
binding gate).
|
||||
* **S-17** — Writing PHP files requires ``current_user_can('edit_themes')``
|
||||
AND ``!defined('DISALLOW_FILE_EDIT') || !DISALLOW_FILE_EDIT``. Non-PHP
|
||||
files (CSS, JSON, MO/PO, JS, images, fonts) skip the
|
||||
``DISALLOW_FILE_EDIT`` check but still require ``edit_themes``.
|
||||
* **S-18** — Per-call caps: 5 MB per file, 1000 files per list, 50 MB
|
||||
per theme install zip. Companion enforces in PHP; MCPHub rejects
|
||||
obviously-oversized payloads before the wire.
|
||||
* **S-19** — Optimistic concurrency. When ``expected_sha256`` is
|
||||
provided on write, the companion compares against the current file
|
||||
sha256 and returns ``sha_mismatch`` (409) if it doesn't match. Lets
|
||||
agents reason about conflicting edits without locking.
|
||||
|
||||
All tools require Airano MCP Bridge v2.14.0+.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from typing import Any
|
||||
from urllib.parse import quote
|
||||
|
||||
from plugins.wordpress.client import WordPressClient
|
||||
|
||||
# Companion admin namespace — same prefix used by F.19.1 / F.19.5.
|
||||
_ADMIN_NS = "airano-mcp/v1/admin"
|
||||
|
||||
# Mirrored from the companion's THEME_FILE_MAX_BYTES /
|
||||
# THEME_LIST_MAX_FILES / THEME_ZIP_MAX_BYTES so MCPHub can reject
|
||||
# obviously-oversized payloads before they reach the wire (S-18). The
|
||||
# companion enforces the real limit.
|
||||
_THEME_FILE_MAX_BYTES = 5 * 1024 * 1024 # 5 MB per file
|
||||
_THEME_LIST_MAX_FILES = 1000 # files per list call
|
||||
_THEME_ZIP_MAX_BYTES = 50 * 1024 * 1024 # 50 MB per install zip
|
||||
|
||||
# A theme slug from ``wp_get_themes()`` is the directory name under
|
||||
# ``wp-content/themes`` — WP itself permits letters, digits, hyphens,
|
||||
# underscores. We add a hard structural guard here as defence-in-depth
|
||||
# alongside S-15 (the companion's ``wp_get_themes()`` whitelist is the
|
||||
# binding check).
|
||||
_THEME_SLUG_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9_-]{0,63}$")
|
||||
|
||||
|
||||
def get_tool_specifications() -> list[dict[str, Any]]:
|
||||
"""Return tool specs for the F.19.7 theme dev surface."""
|
||||
return [
|
||||
# ───── Theme management ──────────────────────────────────────
|
||||
{
|
||||
"name": "wp_theme_install_from_zip",
|
||||
"method_name": "wp_theme_install_from_zip",
|
||||
"description": (
|
||||
"Install a theme from a remote URL or inline base64 zip. "
|
||||
"Companion runs WP core's Theme_Upgrader so signature checks "
|
||||
"and the WP filesystem abstraction stay engaged. Pass exactly "
|
||||
"one of zip_url (companion fetches via wp_safe_remote_get) or "
|
||||
"zip_base64 (decoded server-side). Capped at 50 MB per zip "
|
||||
"(S-18). Set activate=true to make the new theme active "
|
||||
"after install; overwrite=true permits re-installing a slug "
|
||||
"that already exists. Requires Airano MCP Bridge v2.14.0+ "
|
||||
"and a WordPress user with install_themes."
|
||||
),
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"zip_url": {
|
||||
"type": "string",
|
||||
"description": (
|
||||
"https URL the companion will download via "
|
||||
"wp_safe_remote_get. Mutually exclusive with "
|
||||
"zip_base64."
|
||||
),
|
||||
},
|
||||
"zip_base64": {
|
||||
"type": "string",
|
||||
"description": (
|
||||
"Base64-encoded theme zip body. Capped at "
|
||||
"~50 MB after decode (S-18). Mutually exclusive "
|
||||
"with zip_url."
|
||||
),
|
||||
},
|
||||
"activate": {
|
||||
"type": "boolean",
|
||||
"default": False,
|
||||
"description": (
|
||||
"Activate the installed theme on success. "
|
||||
"Activation requires switch_themes — companion "
|
||||
"rejects with rest_forbidden if missing."
|
||||
),
|
||||
},
|
||||
"overwrite": {
|
||||
"type": "boolean",
|
||||
"default": False,
|
||||
"description": (
|
||||
"Permit overwriting an existing theme with the "
|
||||
"same slug. Required if a previous install is "
|
||||
"already on disk."
|
||||
),
|
||||
},
|
||||
},
|
||||
},
|
||||
"scope": "editor",
|
||||
},
|
||||
{
|
||||
"name": "wp_theme_activate",
|
||||
"method_name": "wp_theme_activate",
|
||||
"description": (
|
||||
"Switch the active theme to ``slug``. Companion verifies the "
|
||||
"slug exists in wp_get_themes() (S-15) and the caller holds "
|
||||
"switch_themes. Returns the active stylesheet + template "
|
||||
"after the switch — useful when activating a child theme "
|
||||
"(stylesheet differs from template). Requires Airano MCP "
|
||||
"Bridge v2.14.0+."
|
||||
),
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"slug": {
|
||||
"type": "string",
|
||||
"description": (
|
||||
"Theme directory name (key in wp_get_themes()). "
|
||||
"Alphanumerics, dashes, underscores only."
|
||||
),
|
||||
},
|
||||
},
|
||||
"required": ["slug"],
|
||||
},
|
||||
"scope": "editor",
|
||||
},
|
||||
{
|
||||
"name": "wp_theme_delete",
|
||||
"method_name": "wp_theme_delete",
|
||||
"description": (
|
||||
"Delete an installed theme by slug. Companion refuses to "
|
||||
"delete the active theme (returns ``theme_active``) and the "
|
||||
"current default theme. Caller must hold delete_themes. "
|
||||
"Requires Airano MCP Bridge v2.14.0+."
|
||||
),
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"slug": {
|
||||
"type": "string",
|
||||
"description": "Theme directory name to delete.",
|
||||
},
|
||||
},
|
||||
"required": ["slug"],
|
||||
},
|
||||
"scope": "editor",
|
||||
},
|
||||
# ───── Theme file CRUD ───────────────────────────────────────
|
||||
{
|
||||
"name": "wp_theme_file_list",
|
||||
"method_name": "wp_theme_file_list",
|
||||
"description": (
|
||||
"List files inside a theme directory. Walks "
|
||||
"``wp-content/themes/{theme_slug}`` and returns each file's "
|
||||
"relative path, size, mime, sha256, and modified_at (epoch). "
|
||||
"Optional ``glob`` filters by fnmatch pattern (default "
|
||||
"``**/*``). Capped at 1000 files per call (S-18); when the "
|
||||
"walk truncates, the response carries ``truncated: true``. "
|
||||
"Requires Airano MCP Bridge v2.14.0+."
|
||||
),
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"theme_slug": {
|
||||
"type": "string",
|
||||
"description": "Theme directory name (S-15).",
|
||||
},
|
||||
"glob": {
|
||||
"type": "string",
|
||||
"description": (
|
||||
"fnmatch glob (e.g. ``**/*.php``). Defaults to "
|
||||
"``**/*`` — every file."
|
||||
),
|
||||
"default": "**/*",
|
||||
},
|
||||
"max_files": {
|
||||
"type": "integer",
|
||||
"minimum": 1,
|
||||
"maximum": _THEME_LIST_MAX_FILES,
|
||||
"default": _THEME_LIST_MAX_FILES,
|
||||
"description": (
|
||||
"Hard cap on entries returned. Companion stops "
|
||||
"the walk and sets truncated=true on overflow."
|
||||
),
|
||||
},
|
||||
},
|
||||
"required": ["theme_slug"],
|
||||
},
|
||||
"scope": "read",
|
||||
},
|
||||
{
|
||||
"name": "wp_theme_file_read",
|
||||
"method_name": "wp_theme_file_read",
|
||||
"description": (
|
||||
"Read a file inside a theme as base64. Returns "
|
||||
"``{content_base64, mime, size, sha256, modified_at}``. Path "
|
||||
"must resolve under ``wp-content/themes/{theme_slug}`` "
|
||||
"(S-16); ``..``, absolute paths, null bytes, and symlinks "
|
||||
"that escape are rejected by the companion's realpath gate. "
|
||||
"Files larger than 5 MB return ``file_too_large`` (S-18). "
|
||||
"Requires Airano MCP Bridge v2.14.0+."
|
||||
),
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"theme_slug": {"type": "string"},
|
||||
"path": {
|
||||
"type": "string",
|
||||
"description": (
|
||||
"Theme-relative path. ``style.css``, "
|
||||
"``parts/header.html``, ``functions.php``, etc."
|
||||
),
|
||||
},
|
||||
},
|
||||
"required": ["theme_slug", "path"],
|
||||
},
|
||||
"scope": "read",
|
||||
},
|
||||
{
|
||||
"name": "wp_theme_file_write",
|
||||
"method_name": "wp_theme_file_write",
|
||||
"description": (
|
||||
"Write a file inside a theme. ``content_base64`` is the "
|
||||
"decoded body (capped at 5 MB, S-18). PHP file writes "
|
||||
"additionally require ``edit_themes`` AND "
|
||||
"``!DISALLOW_FILE_EDIT`` (S-17); non-PHP writes only need "
|
||||
"``edit_themes``. Pass ``expected_sha256`` for optimistic "
|
||||
"concurrency: the companion compares against the current "
|
||||
"file's sha256 and returns ``sha_mismatch`` (409) on drift "
|
||||
"(S-19). When ``create_dirs`` is true (default) any missing "
|
||||
"parent directories are created. Requires Airano MCP Bridge "
|
||||
"v2.14.0+."
|
||||
),
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"theme_slug": {"type": "string"},
|
||||
"path": {"type": "string"},
|
||||
"content_base64": {
|
||||
"type": "string",
|
||||
"description": (
|
||||
"Base64-encoded file body. Decoded server-side; "
|
||||
"5 MB hard cap (S-18)."
|
||||
),
|
||||
},
|
||||
"expected_sha256": {
|
||||
"type": "string",
|
||||
"description": (
|
||||
"Optional sha256 of the on-disk file the caller "
|
||||
"based their edit on. When supplied, companion "
|
||||
"rejects with sha_mismatch on drift (S-19). "
|
||||
"Omit to perform an unconditional write."
|
||||
),
|
||||
},
|
||||
"create_dirs": {
|
||||
"type": "boolean",
|
||||
"default": True,
|
||||
"description": (
|
||||
"Create any missing parent directories. Set to "
|
||||
"false to require the directory already exists."
|
||||
),
|
||||
},
|
||||
},
|
||||
"required": ["theme_slug", "path", "content_base64"],
|
||||
},
|
||||
"scope": "editor",
|
||||
},
|
||||
{
|
||||
"name": "wp_theme_file_delete",
|
||||
"method_name": "wp_theme_file_delete",
|
||||
"description": (
|
||||
"Delete a file inside a theme. Path resolution is identical "
|
||||
"to wp_theme_file_read (S-16). Refuses to delete "
|
||||
"``style.css`` of the active theme — that would break the "
|
||||
"front-end. Requires Airano MCP Bridge v2.14.0+ and "
|
||||
"``edit_themes``."
|
||||
),
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"theme_slug": {"type": "string"},
|
||||
"path": {"type": "string"},
|
||||
},
|
||||
"required": ["theme_slug", "path"],
|
||||
},
|
||||
"scope": "editor",
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────
|
||||
# Client-side validation helpers
|
||||
#
|
||||
# These are structural guards so the obvious bad calls don't reach the
|
||||
# wire. The binding security check for both rules is server-side: the
|
||||
# companion canonicalises paths via ``realpath()`` (S-16) and intersects
|
||||
# the slug list with ``wp_get_themes()`` (S-15).
|
||||
# ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def _validate_theme_slug(slug: Any) -> str:
|
||||
"""S-15 client-side guard.
|
||||
|
||||
Reject obviously-malformed slugs before the wire. The companion
|
||||
still does the real ``wp_get_themes()`` membership check.
|
||||
"""
|
||||
if not isinstance(slug, str) or not slug:
|
||||
raise ValueError("theme_slug must be a non-empty string")
|
||||
if not _THEME_SLUG_RE.match(slug):
|
||||
raise ValueError(
|
||||
f"theme_slug must be alphanumerics + dashes + underscores "
|
||||
f"(<=64 chars, no leading dash); got {slug!r}"
|
||||
)
|
||||
return slug
|
||||
|
||||
|
||||
def _validate_theme_file_path(path: Any) -> str:
|
||||
"""S-16 client-side guard.
|
||||
|
||||
Reject the obvious traversal shapes — ``..`` segments, leading
|
||||
slashes, null bytes, backslashes (Windows-style escapes). The
|
||||
companion's ``realpath()`` is the binding gate.
|
||||
"""
|
||||
if not isinstance(path, str) or not path:
|
||||
raise ValueError("path must be a non-empty string")
|
||||
if "\x00" in path:
|
||||
raise ValueError("path must not contain null bytes")
|
||||
if "\\" in path:
|
||||
raise ValueError("path must use forward slashes only")
|
||||
if path.startswith("/"):
|
||||
raise ValueError("path must be theme-relative (no leading slash)")
|
||||
# Reject any segment equal to ``..`` — accepts ``..foo`` (a real
|
||||
# filename) but blocks the traversal idiom.
|
||||
parts = [p for p in path.split("/") if p]
|
||||
if any(p == ".." for p in parts):
|
||||
raise ValueError("path must not contain `..` segments")
|
||||
if not parts:
|
||||
raise ValueError("path must reference a file, not the theme root")
|
||||
return "/".join(parts)
|
||||
|
||||
|
||||
def _quote_path(path: str) -> str:
|
||||
"""Percent-encode a theme-relative path while keeping ``/`` literal."""
|
||||
return quote(path, safe="/")
|
||||
|
||||
|
||||
class ThemesHandler:
|
||||
"""Theme management + theme file CRUD surface (F.19.7).
|
||||
|
||||
Each method returns the parsed JSON envelope from the companion. The
|
||||
plugin.py wrapper layer is responsible for serialising the dict for
|
||||
MCP transport.
|
||||
"""
|
||||
|
||||
def __init__(self, client: WordPressClient) -> None:
|
||||
self.client = client
|
||||
|
||||
# ── Theme management ────────────────────────────────────────────
|
||||
|
||||
async def wp_theme_install_from_zip(
|
||||
self,
|
||||
zip_url: str | None = None,
|
||||
zip_base64: str | None = None,
|
||||
activate: bool = False,
|
||||
overwrite: bool = False,
|
||||
**_: Any,
|
||||
) -> dict[str, Any]:
|
||||
# Exactly one source must be supplied — both the prompt and the
|
||||
# companion route reject the empty + double-supply cases.
|
||||
if not zip_url and not zip_base64:
|
||||
raise ValueError("wp_theme_install_from_zip requires zip_url or zip_base64")
|
||||
if zip_url and zip_base64:
|
||||
raise ValueError("wp_theme_install_from_zip accepts zip_url OR zip_base64, not both")
|
||||
body: dict[str, Any] = {
|
||||
"activate": bool(activate),
|
||||
"overwrite": bool(overwrite),
|
||||
}
|
||||
if zip_url:
|
||||
if not isinstance(zip_url, str):
|
||||
raise ValueError("zip_url must be a string")
|
||||
body["zip_url"] = zip_url
|
||||
else:
|
||||
if not isinstance(zip_base64, str):
|
||||
raise ValueError("zip_base64 must be a string")
|
||||
# Cheap pre-cap: base64 expands by ~4/3, so ``len * 3 // 4``
|
||||
# is an upper bound on the decoded byte count. This catches
|
||||
# the obviously-too-big payloads without doing a full
|
||||
# decode (which would double the memory usage).
|
||||
decoded_size_upper_bound = len(zip_base64) * 3 // 4
|
||||
if decoded_size_upper_bound > _THEME_ZIP_MAX_BYTES:
|
||||
raise ValueError(
|
||||
f"zip_base64 decodes to roughly {decoded_size_upper_bound} bytes "
|
||||
f"— exceeds {_THEME_ZIP_MAX_BYTES} byte cap (S-18)"
|
||||
)
|
||||
body["zip_base64"] = zip_base64
|
||||
return await self.client.post(
|
||||
f"{_ADMIN_NS}/themes/install",
|
||||
json_data=body,
|
||||
use_custom_namespace=True,
|
||||
)
|
||||
|
||||
async def wp_theme_activate(self, slug: str, **_: Any) -> dict[str, Any]:
|
||||
slug = _validate_theme_slug(slug)
|
||||
return await self.client.post(
|
||||
f"{_ADMIN_NS}/themes/{slug}/activate",
|
||||
json_data={},
|
||||
use_custom_namespace=True,
|
||||
)
|
||||
|
||||
async def wp_theme_delete(self, slug: str, **_: Any) -> dict[str, Any]:
|
||||
slug = _validate_theme_slug(slug)
|
||||
return await self.client.delete(
|
||||
f"{_ADMIN_NS}/themes/{slug}",
|
||||
use_custom_namespace=True,
|
||||
)
|
||||
|
||||
# ── Theme file CRUD ─────────────────────────────────────────────
|
||||
|
||||
async def wp_theme_file_list(
|
||||
self,
|
||||
theme_slug: str,
|
||||
glob: str = "**/*",
|
||||
max_files: int = _THEME_LIST_MAX_FILES,
|
||||
**_: Any,
|
||||
) -> dict[str, Any]:
|
||||
theme_slug = _validate_theme_slug(theme_slug)
|
||||
if not isinstance(glob, str) or not glob:
|
||||
raise ValueError("glob must be a non-empty string")
|
||||
if not isinstance(max_files, int) or isinstance(max_files, bool) or max_files <= 0:
|
||||
raise ValueError("max_files must be a positive integer")
|
||||
if max_files > _THEME_LIST_MAX_FILES:
|
||||
raise ValueError(
|
||||
f"max_files {max_files} exceeds the {_THEME_LIST_MAX_FILES} per-call cap (S-18)"
|
||||
)
|
||||
return await self.client.get(
|
||||
f"{_ADMIN_NS}/themes/files/{theme_slug}",
|
||||
params={"glob": glob, "max_files": max_files},
|
||||
use_custom_namespace=True,
|
||||
)
|
||||
|
||||
async def wp_theme_file_read(
|
||||
self,
|
||||
theme_slug: str,
|
||||
path: str,
|
||||
**_: Any,
|
||||
) -> dict[str, Any]:
|
||||
theme_slug = _validate_theme_slug(theme_slug)
|
||||
path = _validate_theme_file_path(path)
|
||||
return await self.client.get(
|
||||
f"{_ADMIN_NS}/themes/files/{theme_slug}/{_quote_path(path)}",
|
||||
use_custom_namespace=True,
|
||||
)
|
||||
|
||||
async def wp_theme_file_write(
|
||||
self,
|
||||
theme_slug: str,
|
||||
path: str,
|
||||
content_base64: str,
|
||||
expected_sha256: str | None = None,
|
||||
create_dirs: bool = True,
|
||||
**_: Any,
|
||||
) -> dict[str, Any]:
|
||||
theme_slug = _validate_theme_slug(theme_slug)
|
||||
path = _validate_theme_file_path(path)
|
||||
if not isinstance(content_base64, str):
|
||||
raise ValueError("content_base64 must be a string")
|
||||
decoded_size_upper_bound = len(content_base64) * 3 // 4
|
||||
if decoded_size_upper_bound > _THEME_FILE_MAX_BYTES:
|
||||
raise ValueError(
|
||||
f"content_base64 decodes to roughly {decoded_size_upper_bound} bytes "
|
||||
f"— exceeds {_THEME_FILE_MAX_BYTES} byte cap (S-18)"
|
||||
)
|
||||
body: dict[str, Any] = {
|
||||
"content_base64": content_base64,
|
||||
"create_dirs": bool(create_dirs),
|
||||
}
|
||||
if expected_sha256 is not None:
|
||||
if not isinstance(expected_sha256, str) or not re.fullmatch(
|
||||
r"[0-9a-fA-F]{64}", expected_sha256
|
||||
):
|
||||
raise ValueError("expected_sha256 must be a 64-char hex string")
|
||||
body["expected_sha256"] = expected_sha256.lower()
|
||||
return await self.client.put(
|
||||
f"{_ADMIN_NS}/themes/files/{theme_slug}/{_quote_path(path)}",
|
||||
json_data=body,
|
||||
use_custom_namespace=True,
|
||||
)
|
||||
|
||||
async def wp_theme_file_delete(
|
||||
self,
|
||||
theme_slug: str,
|
||||
path: str,
|
||||
**_: Any,
|
||||
) -> dict[str, Any]:
|
||||
theme_slug = _validate_theme_slug(theme_slug)
|
||||
path = _validate_theme_file_path(path)
|
||||
return await self.client.delete(
|
||||
f"{_ADMIN_NS}/themes/files/{theme_slug}/{_quote_path(path)}",
|
||||
use_custom_namespace=True,
|
||||
)
|
||||
358
plugins/wordpress_specialist/plugin.py
Normal file
358
plugins/wordpress_specialist/plugin.py
Normal file
@@ -0,0 +1,358 @@
|
||||
"""WordPress Specialist Plugin (F.19) — companion-backed advanced
|
||||
management for WordPress professionals.
|
||||
|
||||
The companion-backed surface that replaced the deprecated
|
||||
``wordpress_advanced`` plugin (sunset 2026-05-04 in F.19.3.2-.3):
|
||||
|
||||
* ``wordpress_specialist`` — plugins / themes / users / options / cron /
|
||||
maintenance / page editing / site config + layout / db inspection /
|
||||
bulk fan-out via the Airano MCP Bridge companion plugin. Requires
|
||||
only ``url`` + ``username`` + ``app_password`` (the WP user needs
|
||||
``manage_options``). No Docker socket dependency.
|
||||
|
||||
Tool surface (51 tools across read / editor / install / settings /
|
||||
admin tiers — see ``get_tool_specifications`` for the per-section
|
||||
breakdown). Plugin remains admin-only by default — not in
|
||||
``ENABLED_PLUGINS`` until F.19.4 (companion v3.0 wp.org republish)
|
||||
closes the certification loop.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from typing import Any
|
||||
|
||||
from plugins.base import BasePlugin
|
||||
from plugins.wordpress.client import WordPressClient
|
||||
from plugins.wordpress_specialist import handlers
|
||||
|
||||
|
||||
class WordPressSpecialistPlugin(BasePlugin):
|
||||
"""Advanced WordPress management for specialists, companion-backed."""
|
||||
|
||||
@staticmethod
|
||||
def get_plugin_name() -> str:
|
||||
return "wordpress_specialist"
|
||||
|
||||
@staticmethod
|
||||
def get_required_config_keys() -> list[str]:
|
||||
# No ``container`` — this plugin never shells into WP-CLI.
|
||||
return ["url", "username", "app_password"]
|
||||
|
||||
def __init__(self, config: dict[str, Any], project_id: str | None = None) -> None:
|
||||
super().__init__(config, project_id=project_id)
|
||||
|
||||
self.client = WordPressClient(
|
||||
site_url=config["url"],
|
||||
username=config["username"],
|
||||
app_password=config["app_password"],
|
||||
)
|
||||
|
||||
# F.19.1: read-only management handler. F.19.2 will introduce
|
||||
# additional handlers (e.g. installer.py, users_admin.py) but
|
||||
# all will share self.client and stay companion-backed.
|
||||
self.management = handlers.ManagementHandler(self.client)
|
||||
# F.19.5: page editing handler — Gutenberg blocks + Elementor
|
||||
# + Classic. Companion-backed except for the two read paths
|
||||
# that go through stock REST.
|
||||
self.pages = handlers.PagesHandler(self.client)
|
||||
# F.19.7: theme dev surface — install + activate + delete +
|
||||
# file CRUD. Companion-backed (Airano MCP Bridge v2.14.0+).
|
||||
self.themes = handlers.ThemesHandler(self.client)
|
||||
# F.19.2.1: plugin write management — install / activate /
|
||||
# deactivate / update / delete. Companion-backed (Airano MCP
|
||||
# Bridge v2.15.0+). First handler to use the install + admin
|
||||
# tiers introduced by F.19.2.0.
|
||||
self.plugins = handlers.PluginsHandler(self.client)
|
||||
# F.19.6.A: site config — identity / reading / permalinks.
|
||||
# Companion-backed (Airano MCP Bridge v2.16.0+). First consumer
|
||||
# of the ``settings`` tier introduced by F.19.2.0.
|
||||
self.site_config = handlers.SiteConfigHandler(self.client)
|
||||
# F.19.6.B: site layout — menus / widgets / customizer.
|
||||
# Companion-backed (Airano MCP Bridge v2.17.0+). Same
|
||||
# ``settings`` tier as site config — closes the Settings →
|
||||
# Menus + Appearance → Widgets + Customizer gaps.
|
||||
self.site_layout = handlers.SiteLayoutHandler(self.client)
|
||||
# F.19.3.2-.3: database inspection (companion v2.18.0+) — three
|
||||
# read-only tools (db/size, db/tables, db/search). Bundled with
|
||||
# the wordpress_advanced sunset; no SQL exposure (S-25).
|
||||
self.database = handlers.DatabaseHandler(self.client)
|
||||
# F.19.3.2-.3: bulk fan-out — post + term updates via stock
|
||||
# REST batch. No companion route needed; per-item permission
|
||||
# checks happen at the WP layer (S-26 caps at 50 items/call).
|
||||
self.bulk = handlers.BulkHandler(self.client)
|
||||
|
||||
@staticmethod
|
||||
def get_tool_specifications() -> list[dict[str, Any]]:
|
||||
"""Return all tool specs.
|
||||
|
||||
Currently exposes:
|
||||
* F.19.1 read surface — 6 tools (plugins/themes/users/options/
|
||||
cron/maintenance)
|
||||
* F.19.3.1 ports — 3 tools (system_info / phpinfo / disk_usage)
|
||||
* F.19.5 page editing — 11 tools (4 Gutenberg + 6 Elementor + 1
|
||||
Classic)
|
||||
* F.19.7 theme dev surface — 7 tools (3 management + 4 file CRUD)
|
||||
* F.19.2.1 plugin write management — 6 tools (4 install-tier +
|
||||
2 admin-tier)
|
||||
* F.19.6.A site config — 6 tools (identity + reading + permalinks)
|
||||
* F.19.6.B site layout — 7 tools (3 menu + 3 widget + 1 customizer)
|
||||
* F.19.3.2 database inspection — 3 tools (db/size + db/tables + db/search)
|
||||
* F.19.3.3 bulk fan-out — 2 tools (post + term updates)
|
||||
|
||||
Total: 51 tools.
|
||||
"""
|
||||
return [
|
||||
*handlers.get_management_specs(),
|
||||
*handlers.get_pages_specs(),
|
||||
*handlers.get_themes_specs(),
|
||||
*handlers.get_plugins_specs(),
|
||||
*handlers.get_site_config_specs(),
|
||||
*handlers.get_site_layout_specs(),
|
||||
*handlers.get_database_specs(),
|
||||
*handlers.get_bulk_specs(),
|
||||
]
|
||||
|
||||
async def health_check(self) -> dict[str, Any]:
|
||||
"""Probe the companion's admin namespace via the cheapest route.
|
||||
|
||||
``GET /admin/maintenance`` is the smallest payload that exercises
|
||||
the same auth + capability path the rest of the surface uses.
|
||||
Failure surfaces actionable hints: missing companion, missing
|
||||
manage_options, or unreachable site.
|
||||
"""
|
||||
try:
|
||||
payload = await self.management.wp_maintenance_status()
|
||||
return {
|
||||
"healthy": True,
|
||||
"companion": True,
|
||||
"admin_namespace": True,
|
||||
"maintenance_enabled": bool(payload.get("enabled", False)),
|
||||
}
|
||||
except Exception as exc: # pragma: no cover — surfaced to dashboard
|
||||
return {
|
||||
"healthy": False,
|
||||
"companion": False,
|
||||
"error": str(exc),
|
||||
"hint": (
|
||||
"Install Airano MCP Bridge v2.11.0+ on this WordPress "
|
||||
"site and ensure the Application Password belongs to a "
|
||||
"user with manage_options."
|
||||
),
|
||||
}
|
||||
|
||||
# ----------------------------------------------------------
|
||||
# Method delegation — one per tool spec, mirrored to handlers
|
||||
# ----------------------------------------------------------
|
||||
|
||||
async def wp_plugin_list(self, **kwargs):
|
||||
result = await self.management.wp_plugin_list(**kwargs)
|
||||
return json.dumps(result, indent=2) if isinstance(result, dict) else result
|
||||
|
||||
async def wp_theme_list(self, **kwargs):
|
||||
result = await self.management.wp_theme_list(**kwargs)
|
||||
return json.dumps(result, indent=2) if isinstance(result, dict) else result
|
||||
|
||||
async def wp_user_list(self, **kwargs):
|
||||
result = await self.management.wp_user_list(**kwargs)
|
||||
return json.dumps(result, indent=2) if isinstance(result, dict) else result
|
||||
|
||||
async def wp_option_get(self, **kwargs):
|
||||
result = await self.management.wp_option_get(**kwargs)
|
||||
return json.dumps(result, indent=2) if isinstance(result, dict) else result
|
||||
|
||||
async def wp_cron_list(self, **kwargs):
|
||||
result = await self.management.wp_cron_list(**kwargs)
|
||||
return json.dumps(result, indent=2) if isinstance(result, dict) else result
|
||||
|
||||
async def wp_maintenance_status(self, **kwargs):
|
||||
result = await self.management.wp_maintenance_status(**kwargs)
|
||||
return json.dumps(result, indent=2) if isinstance(result, dict) else result
|
||||
|
||||
# F.19.3.1 — system ports (companion v2.12.0+)
|
||||
async def wp_system_info(self, **kwargs):
|
||||
result = await self.management.wp_system_info(**kwargs)
|
||||
return json.dumps(result, indent=2) if isinstance(result, dict) else result
|
||||
|
||||
async def wp_php_info(self, **kwargs):
|
||||
result = await self.management.wp_php_info(**kwargs)
|
||||
return json.dumps(result, indent=2) if isinstance(result, dict) else result
|
||||
|
||||
async def wp_disk_usage(self, **kwargs):
|
||||
result = await self.management.wp_disk_usage(**kwargs)
|
||||
return json.dumps(result, indent=2) if isinstance(result, dict) else result
|
||||
|
||||
# F.19.5 — Page editing (Gutenberg blocks + Elementor + Classic, companion v2.13.0+)
|
||||
async def wp_blocks_get(self, **kwargs):
|
||||
result = await self.pages.wp_blocks_get(**kwargs)
|
||||
return json.dumps(result, indent=2) if isinstance(result, dict) else result
|
||||
|
||||
async def wp_blocks_replace(self, **kwargs):
|
||||
result = await self.pages.wp_blocks_replace(**kwargs)
|
||||
return json.dumps(result, indent=2) if isinstance(result, dict) else result
|
||||
|
||||
async def wp_blocks_insert_at(self, **kwargs):
|
||||
result = await self.pages.wp_blocks_insert_at(**kwargs)
|
||||
return json.dumps(result, indent=2) if isinstance(result, dict) else result
|
||||
|
||||
async def wp_blocks_remove_at(self, **kwargs):
|
||||
result = await self.pages.wp_blocks_remove_at(**kwargs)
|
||||
return json.dumps(result, indent=2) if isinstance(result, dict) else result
|
||||
|
||||
async def wp_elementor_detect(self, **kwargs):
|
||||
result = await self.pages.wp_elementor_detect(**kwargs)
|
||||
return json.dumps(result, indent=2) if isinstance(result, dict) else result
|
||||
|
||||
async def wp_elementor_get(self, **kwargs):
|
||||
result = await self.pages.wp_elementor_get(**kwargs)
|
||||
return json.dumps(result, indent=2) if isinstance(result, dict) else result
|
||||
|
||||
async def wp_elementor_set(self, **kwargs):
|
||||
result = await self.pages.wp_elementor_set(**kwargs)
|
||||
return json.dumps(result, indent=2) if isinstance(result, dict) else result
|
||||
|
||||
async def wp_elementor_render_css(self, **kwargs):
|
||||
result = await self.pages.wp_elementor_render_css(**kwargs)
|
||||
return json.dumps(result, indent=2) if isinstance(result, dict) else result
|
||||
|
||||
async def wp_elementor_template_list(self, **kwargs):
|
||||
result = await self.pages.wp_elementor_template_list(**kwargs)
|
||||
return json.dumps(result, indent=2) if isinstance(result, dict) else result
|
||||
|
||||
async def wp_elementor_template_apply(self, **kwargs):
|
||||
result = await self.pages.wp_elementor_template_apply(**kwargs)
|
||||
return json.dumps(result, indent=2) if isinstance(result, dict) else result
|
||||
|
||||
async def wp_classic_html_replace(self, **kwargs):
|
||||
result = await self.pages.wp_classic_html_replace(**kwargs)
|
||||
return json.dumps(result, indent=2) if isinstance(result, dict) else result
|
||||
|
||||
# F.19.7 — Theme dev surface (install + file CRUD, companion v2.14.0+)
|
||||
async def wp_theme_install_from_zip(self, **kwargs):
|
||||
result = await self.themes.wp_theme_install_from_zip(**kwargs)
|
||||
return json.dumps(result, indent=2) if isinstance(result, dict) else result
|
||||
|
||||
async def wp_theme_activate(self, **kwargs):
|
||||
result = await self.themes.wp_theme_activate(**kwargs)
|
||||
return json.dumps(result, indent=2) if isinstance(result, dict) else result
|
||||
|
||||
async def wp_theme_delete(self, **kwargs):
|
||||
result = await self.themes.wp_theme_delete(**kwargs)
|
||||
return json.dumps(result, indent=2) if isinstance(result, dict) else result
|
||||
|
||||
async def wp_theme_file_list(self, **kwargs):
|
||||
result = await self.themes.wp_theme_file_list(**kwargs)
|
||||
return json.dumps(result, indent=2) if isinstance(result, dict) else result
|
||||
|
||||
async def wp_theme_file_read(self, **kwargs):
|
||||
result = await self.themes.wp_theme_file_read(**kwargs)
|
||||
return json.dumps(result, indent=2) if isinstance(result, dict) else result
|
||||
|
||||
async def wp_theme_file_write(self, **kwargs):
|
||||
result = await self.themes.wp_theme_file_write(**kwargs)
|
||||
return json.dumps(result, indent=2) if isinstance(result, dict) else result
|
||||
|
||||
async def wp_theme_file_delete(self, **kwargs):
|
||||
result = await self.themes.wp_theme_file_delete(**kwargs)
|
||||
return json.dumps(result, indent=2) if isinstance(result, dict) else result
|
||||
|
||||
# F.19.2.1 — Plugin write management (install + admin, companion v2.15.0+)
|
||||
async def wp_plugin_install_from_slug(self, **kwargs):
|
||||
result = await self.plugins.wp_plugin_install_from_slug(**kwargs)
|
||||
return json.dumps(result, indent=2) if isinstance(result, dict) else result
|
||||
|
||||
async def wp_plugin_install_from_zip(self, **kwargs):
|
||||
result = await self.plugins.wp_plugin_install_from_zip(**kwargs)
|
||||
return json.dumps(result, indent=2) if isinstance(result, dict) else result
|
||||
|
||||
async def wp_plugin_activate(self, **kwargs):
|
||||
result = await self.plugins.wp_plugin_activate(**kwargs)
|
||||
return json.dumps(result, indent=2) if isinstance(result, dict) else result
|
||||
|
||||
async def wp_plugin_deactivate(self, **kwargs):
|
||||
result = await self.plugins.wp_plugin_deactivate(**kwargs)
|
||||
return json.dumps(result, indent=2) if isinstance(result, dict) else result
|
||||
|
||||
async def wp_plugin_update(self, **kwargs):
|
||||
result = await self.plugins.wp_plugin_update(**kwargs)
|
||||
return json.dumps(result, indent=2) if isinstance(result, dict) else result
|
||||
|
||||
async def wp_plugin_delete(self, **kwargs):
|
||||
result = await self.plugins.wp_plugin_delete(**kwargs)
|
||||
return json.dumps(result, indent=2) if isinstance(result, dict) else result
|
||||
|
||||
# F.19.6.A — Site config (identity + reading + permalinks, companion v2.16.0+)
|
||||
async def wp_site_identity_get(self, **kwargs):
|
||||
result = await self.site_config.wp_site_identity_get(**kwargs)
|
||||
return json.dumps(result, indent=2) if isinstance(result, dict) else result
|
||||
|
||||
async def wp_site_identity_set(self, **kwargs):
|
||||
result = await self.site_config.wp_site_identity_set(**kwargs)
|
||||
return json.dumps(result, indent=2) if isinstance(result, dict) else result
|
||||
|
||||
async def wp_reading_settings_get(self, **kwargs):
|
||||
result = await self.site_config.wp_reading_settings_get(**kwargs)
|
||||
return json.dumps(result, indent=2) if isinstance(result, dict) else result
|
||||
|
||||
async def wp_reading_settings_set(self, **kwargs):
|
||||
result = await self.site_config.wp_reading_settings_set(**kwargs)
|
||||
return json.dumps(result, indent=2) if isinstance(result, dict) else result
|
||||
|
||||
async def wp_permalinks_get(self, **kwargs):
|
||||
result = await self.site_config.wp_permalinks_get(**kwargs)
|
||||
return json.dumps(result, indent=2) if isinstance(result, dict) else result
|
||||
|
||||
async def wp_permalinks_set(self, **kwargs):
|
||||
result = await self.site_config.wp_permalinks_set(**kwargs)
|
||||
return json.dumps(result, indent=2) if isinstance(result, dict) else result
|
||||
|
||||
# F.19.6.B — Site layout (menus + widgets + customizer, companion v2.17.0+)
|
||||
async def wp_menu_list(self, **kwargs):
|
||||
result = await self.site_layout.wp_menu_list(**kwargs)
|
||||
return json.dumps(result, indent=2) if isinstance(result, dict) else result
|
||||
|
||||
async def wp_menu_get(self, **kwargs):
|
||||
result = await self.site_layout.wp_menu_get(**kwargs)
|
||||
return json.dumps(result, indent=2) if isinstance(result, dict) else result
|
||||
|
||||
async def wp_menu_set(self, **kwargs):
|
||||
result = await self.site_layout.wp_menu_set(**kwargs)
|
||||
return json.dumps(result, indent=2) if isinstance(result, dict) else result
|
||||
|
||||
async def wp_widget_areas_list(self, **kwargs):
|
||||
result = await self.site_layout.wp_widget_areas_list(**kwargs)
|
||||
return json.dumps(result, indent=2) if isinstance(result, dict) else result
|
||||
|
||||
async def wp_widget_get(self, **kwargs):
|
||||
result = await self.site_layout.wp_widget_get(**kwargs)
|
||||
return json.dumps(result, indent=2) if isinstance(result, dict) else result
|
||||
|
||||
async def wp_widget_set(self, **kwargs):
|
||||
result = await self.site_layout.wp_widget_set(**kwargs)
|
||||
return json.dumps(result, indent=2) if isinstance(result, dict) else result
|
||||
|
||||
async def wp_customizer_changeset(self, **kwargs):
|
||||
result = await self.site_layout.wp_customizer_changeset(**kwargs)
|
||||
return json.dumps(result, indent=2) if isinstance(result, dict) else result
|
||||
|
||||
# F.19.3.2 — Database inspection (read-only, companion v2.18.0+)
|
||||
async def wp_db_size(self, **kwargs):
|
||||
result = await self.database.wp_db_size(**kwargs)
|
||||
return json.dumps(result, indent=2) if isinstance(result, dict) else result
|
||||
|
||||
async def wp_db_tables(self, **kwargs):
|
||||
result = await self.database.wp_db_tables(**kwargs)
|
||||
return json.dumps(result, indent=2) if isinstance(result, dict) else result
|
||||
|
||||
async def wp_db_search(self, **kwargs):
|
||||
result = await self.database.wp_db_search(**kwargs)
|
||||
return json.dumps(result, indent=2) if isinstance(result, dict) else result
|
||||
|
||||
# F.19.3.3 — Bulk fan-out (editor tier, stock REST batch)
|
||||
async def wp_bulk_post_update(self, **kwargs):
|
||||
result = await self.bulk.wp_bulk_post_update(**kwargs)
|
||||
return json.dumps(result, indent=2) if isinstance(result, dict) else result
|
||||
|
||||
async def wp_bulk_term_update(self, **kwargs):
|
||||
result = await self.bulk.wp_bulk_term_update(**kwargs)
|
||||
return json.dumps(result, indent=2) if isinstance(result, dict) else result
|
||||
Reference in New Issue
Block a user