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:
2026-05-20 23:33:20 +02:00
parent f203ca88de
commit 43fd2201a0
223 changed files with 36183 additions and 4115 deletions

View 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",
]

View 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",
]

View 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,
}

View 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,
)

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

View 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

View 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,
)

View 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,
)

View 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,
)

View 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,
)

View 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