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

@@ -3,37 +3,44 @@ Plugins package
All project type plugins are here.
F.19.3.2-.3 (2026-05-04): wordpress_advanced sunset — the deprecated
Docker-socket / WP-CLI plugin was removed once
wordpress_specialist absorbed db inspection +
bulk fan-out (companion v2.18.0).
F.19.1 (2026-05-01): WordPress Specialist Plugin added (companion-backed,
no Docker socket).
v2.8.0 (Phase J): Directus CMS Plugin added
v2.6.0 (Phase I): Appwrite Backend Plugin added
v2.5.0 (Phase H): OpenPanel Analytics Plugin added
v2.3.0 (Phase G): Supabase Self-Hosted Plugin added
Note: Appwrite and Directus plugins are retained in the codebase but are
no longer registered by default. They require review and testing before
re-enabling. To restore them, add their imports and registry.register()
calls below.
"""
from plugins.appwrite.plugin import AppwritePlugin
from plugins.base import BasePlugin, PluginRegistry
from plugins.coolify.plugin import CoolifyPlugin
from plugins.directus.plugin import DirectusPlugin
from plugins.gitea.plugin import GiteaPlugin
from plugins.n8n.plugin import N8nPlugin
from plugins.openpanel.plugin import OpenPanelPlugin
from plugins.supabase.plugin import SupabasePlugin
from plugins.woocommerce.plugin import WooCommercePlugin
from plugins.wordpress.plugin import WordPressPlugin
from plugins.wordpress_advanced.plugin import WordPressAdvancedPlugin
from plugins.wordpress_specialist.plugin import WordPressSpecialistPlugin
# Create global registry
registry = PluginRegistry()
# Register available plugins
# Register available plugins (8 active plugins)
registry.register("wordpress", WordPressPlugin)
registry.register("woocommerce", WooCommercePlugin)
registry.register("wordpress_advanced", WordPressAdvancedPlugin)
registry.register("wordpress_specialist", WordPressSpecialistPlugin)
registry.register("gitea", GiteaPlugin)
registry.register("n8n", N8nPlugin)
registry.register("supabase", SupabasePlugin)
registry.register("openpanel", OpenPanelPlugin)
registry.register("appwrite", AppwritePlugin)
registry.register("directus", DirectusPlugin)
registry.register("coolify", CoolifyPlugin)
__all__ = [
@@ -42,12 +49,10 @@ __all__ = [
"registry",
"WordPressPlugin",
"WooCommercePlugin",
"WordPressAdvancedPlugin",
"WordPressSpecialistPlugin",
"GiteaPlugin",
"N8nPlugin",
"SupabasePlugin",
"OpenPanelPlugin",
"AppwritePlugin",
"DirectusPlugin",
"CoolifyPlugin",
]

View File

@@ -124,15 +124,18 @@ class OpenRouterProvider(BaseImageProvider):
requested_model = request.model or _DEFAULT_MODEL
replacement = _DEPRECATED_MODELS.get(requested_model)
if replacement is not None:
raise ProviderError(
"PROVIDER_MODEL_DEPRECATED",
(
f"OpenRouter model '{requested_model}' is deprecated. "
f"Use '{replacement}' instead."
),
{"requested_model": requested_model, "replacement_model": replacement},
# Auto-substitute deprecated → GA so per-site defaults pinned
# before the GA promotion don't break the generate-and-upload
# flow. Logged at WARNING so operators notice and can
# repin the site default in the dashboard.
_logger.warning(
"openrouter: model %r is deprecated; using %r instead",
requested_model,
replacement,
)
model = requested_model
model = replacement
else:
model = requested_model
# Chat-completions shape with image modality declared. The
# prompt is sent as the single user message; size hints are

View File

@@ -370,16 +370,86 @@ async def _apply_metadata_and_attach(
except Exception as exc: # noqa: BLE001
status["warnings"].append(f"featured_set_failed (product): {exc}")
else:
try:
await wp_set_featured_media(client, attach_to_post, media["id"])
# Issue #90 — set_featured used to hit /wp/v2/posts/{id}
# only, which 404s for pages and other CPTs. Now: try
# `posts` first (the common case), and on miss, walk the
# thumbnail-supporting rest_bases (pages, then any other
# CPT that opted in via add_theme_support('post-thumbnails')
# / declares ``thumbnail`` in its supports list).
ctx = await _set_featured_with_fallback(client, attach_to_post, media["id"])
if ctx is not None:
status["featured_set"] = True
status["featured_context"] = "post"
except Exception as exc: # noqa: BLE001
status["warnings"].append(f"featured_set_failed (post): {exc}")
status["featured_context"] = ctx
else:
status["warnings"].append(
f"featured_set_failed: post {attach_to_post} not found "
"in any thumbnail-supporting post type"
)
return status
async def _set_featured_with_fallback(
client: WordPressClient,
target_id: int,
media_id: int,
) -> str | None:
"""Set featured image on ``target_id`` across post / page / CPT.
Tries ``posts`` first (the 99% case — and the path covered by
``wp_set_featured_media`` to keep existing test seams stable),
then falls back to ``pages`` and any other rest_base that declares
``thumbnail`` support via ``/wp/v2/types``. Returns the rest_base on
success, ``None`` when nothing accepted the write.
"""
try:
await wp_set_featured_media(client, target_id, media_id)
return "post"
except Exception:
pass
body = {"featured_media": media_id}
try:
await client.post(f"pages/{target_id}", json_data=body)
return "page"
except Exception:
pass
try:
types_data = await client.get("types")
except Exception:
return None
if not isinstance(types_data, dict):
return None
for slug, data in types_data.items():
if not isinstance(data, dict):
continue
rest_base = data.get("rest_base") or slug
if rest_base in ("posts", "pages", "media", "attachment"):
continue
supports = data.get("supports") or {}
# `supports` is sometimes a list (older WP / some plugins) and
# sometimes a dict keyed by feature. Treat both as truthy when
# ``thumbnail`` is present.
if isinstance(supports, dict):
if not supports.get("thumbnail"):
continue
elif isinstance(supports, list):
if "thumbnail" not in supports:
continue
else:
continue
try:
await client.post(f"{rest_base}/{target_id}", json_data=body)
return rest_base
except Exception:
continue
return None
def _format_upload_result(media: dict[str, Any], *, source: str) -> dict[str, Any]:
title = media.get("title")
rendered_title = title.get("rendered") if isinstance(title, dict) else title

View File

@@ -147,6 +147,109 @@ def get_tool_specifications() -> list[dict[str, Any]]:
},
"scope": "write",
},
{
"name": "list_navigations",
"method_name": "list_navigations",
"description": (
"List wp_navigation posts (block-theme navigation menus). "
"These power the <!-- wp:navigation --> block in Site Editor. "
"Distinct from classic menus (use list_menus for those)."
),
"schema": {
"type": "object",
"properties": {
"per_page": {
"type": "integer",
"description": "Results per page (1-100)",
"default": 20,
"minimum": 1,
"maximum": 100,
},
"page": {
"type": "integer",
"description": "Page number",
"default": 1,
"minimum": 1,
},
"status": {
"type": "string",
"description": "Status filter",
"enum": ["publish", "draft", "pending", "private", "any"],
"default": "any",
},
},
},
"scope": "read",
},
{
"name": "get_navigation",
"method_name": "get_navigation",
"description": (
"Get a single wp_navigation post by ID, including its raw "
"block-markup content. Use the returned content to plan an "
"update_navigation call (e.g. fix href values inside <!-- "
"wp:navigation-link --> blocks)."
),
"schema": {
"type": "object",
"properties": {
"navigation_id": {
"type": "integer",
"description": "wp_navigation post ID",
"minimum": 1,
},
},
"required": ["navigation_id"],
},
"scope": "read",
},
{
"name": "update_navigation",
"method_name": "update_navigation",
"description": (
"Update a wp_navigation post (block-theme menu). Writes "
"post_content (block markup) and/or title/status/slug/meta. "
"Use this to fix navigation-link hrefs that the agent set "
"up in Site Editor without leaving the MCP loop."
),
"schema": {
"type": "object",
"properties": {
"navigation_id": {
"type": "integer",
"description": "wp_navigation post ID",
"minimum": 1,
},
"title": {
"anyOf": [{"type": "string"}, {"type": "null"}],
"description": "Navigation title",
},
"content": {
"anyOf": [{"type": "string"}, {"type": "null"}],
"description": (
"Block markup (e.g. <!-- wp:navigation-link "
'{"label":"About","url":"/about/"} /-->). '
"Replaces the entire post_content."
),
},
"status": {
"anyOf": [{"type": "string"}, {"type": "null"}],
"description": "Publication status",
"enum": ["publish", "draft", "pending", "private"],
},
"slug": {
"anyOf": [{"type": "string"}, {"type": "null"}],
"description": "Navigation slug",
},
"meta": {
"anyOf": [{"type": "object"}, {"type": "null"}],
"description": "Post meta key/value map (registered keys only).",
},
},
"required": ["navigation_id"],
},
"scope": "write",
},
]
@@ -401,3 +504,123 @@ class MenusHandler:
{"error": str(e), "message": f"Failed to update menu item {item_id}: {str(e)}"},
indent=2,
)
async def list_navigations(self, per_page: int = 20, page: int = 1, status: str = "any") -> str:
"""List wp_navigation posts (block-theme menus)."""
try:
params = {"per_page": per_page, "page": page, "status": status}
items = await self.client.get("navigation", params=params)
if isinstance(items, list):
summary = [
{
"id": n.get("id"),
"title": (n.get("title") or {}).get("rendered", ""),
"slug": n.get("slug"),
"status": n.get("status"),
"modified": n.get("modified"),
}
for n in items
]
else:
summary = items
return json.dumps(
{"total": len(summary) if isinstance(summary, list) else 0, "navigations": summary},
indent=2,
)
except Exception as e:
return json.dumps(
{"error": str(e), "message": f"Failed to list navigations: {str(e)}"}, indent=2
)
async def get_navigation(self, navigation_id: int) -> str:
"""Get a single wp_navigation post (with raw block content)."""
try:
# context=edit returns content.raw so the agent can round-trip it
params = {"context": "edit"}
nav = await self.client.get(f"navigation/{navigation_id}", params=params)
content = nav.get("content") or {}
raw_content = content.get("raw") if isinstance(content, dict) else None
rendered = content.get("rendered") if isinstance(content, dict) else None
return json.dumps(
{
"id": nav.get("id"),
"title": (nav.get("title") or {}).get("rendered", ""),
"slug": nav.get("slug"),
"status": nav.get("status"),
"content_raw": raw_content,
"content_rendered": rendered,
"modified": nav.get("modified"),
"link": nav.get("link"),
},
indent=2,
)
except Exception as e:
return json.dumps(
{
"error": str(e),
"message": f"Failed to get navigation {navigation_id}: {str(e)}",
},
indent=2,
)
async def update_navigation(
self,
navigation_id: int,
title: str | None = None,
content: str | None = None,
status: str | None = None,
slug: str | None = None,
meta: dict | None = None,
) -> str:
"""Update a wp_navigation post (block-theme menu)."""
try:
data: dict[str, Any] = {}
if title is not None:
data["title"] = title
if content is not None:
data["content"] = content
if status is not None:
data["status"] = status
if slug is not None:
data["slug"] = slug
if meta is not None:
data["meta"] = meta
if not data:
return json.dumps(
{
"error": "no_fields",
"message": (
"update_navigation requires at least one of "
"title, content, status, slug, meta."
),
},
indent=2,
)
nav = await self.client.post(f"navigation/{navigation_id}", json_data=data)
return json.dumps(
{
"id": nav.get("id"),
"title": (nav.get("title") or {}).get("rendered", ""),
"slug": nav.get("slug"),
"status": nav.get("status"),
"modified": nav.get("modified"),
"link": nav.get("link"),
"message": f"Navigation {navigation_id} updated successfully",
},
indent=2,
)
except Exception as e:
return json.dumps(
{
"error": str(e),
"message": f"Failed to update navigation {navigation_id}: {str(e)}",
},
indent=2,
)

View File

@@ -292,7 +292,7 @@ def get_tool_specifications() -> list[dict[str, Any]]:
{
"name": "update_page",
"method_name": "update_page",
"description": "Update an existing WordPress page. Can update title, content, status, slug, and parent page.",
"description": "Update an existing WordPress page. Can update title, content, status, slug, parent page, featured image, and post meta (e.g. Yoast fields).",
"schema": {
"type": "object",
"properties": {
@@ -322,6 +322,14 @@ def get_tool_specifications() -> list[dict[str, Any]]:
"anyOf": [{"type": "integer"}, {"type": "null"}],
"description": "Parent page ID",
},
"featured_media": {
"anyOf": [{"type": "integer"}, {"type": "null"}],
"description": "Featured image attachment ID. Pass 0 to clear.",
},
"meta": {
"anyOf": [{"type": "object"}, {"type": "null"}],
"description": "Post meta key/value map forwarded as REST `meta` (e.g. Yoast fields like `_yoast_wpseo_metadesc`). Only registered/whitelisted keys are persisted by WordPress.",
},
},
"required": ["page_id"],
},
@@ -892,6 +900,8 @@ class PostsHandler:
status: str | None = None,
slug: str | None = None,
parent: int | None = None,
featured_media: int | None = None,
meta: dict | None = None,
) -> str:
"""
Update an existing WordPress page.
@@ -903,6 +913,8 @@ class PostsHandler:
status: Publication status
slug: Page URL slug
parent: Parent page ID
featured_media: Featured image attachment ID (0 clears it)
meta: Post meta key/value map (e.g. Yoast fields)
Returns:
JSON string with updated page data
@@ -920,6 +932,10 @@ class PostsHandler:
data["slug"] = slug
if parent is not None:
data["parent"] = parent
if featured_media is not None:
data["featured_media"] = featured_media
if meta is not None:
data["meta"] = meta
page = await self.client.post(f"pages/{page_id}", json_data=data)

View File

@@ -119,7 +119,9 @@ class WordPressPlugin(BasePlugin):
else:
self.wp_cli = None
# Note: Database, Bulk, and System operations moved to wordpress_advanced plugin
# Database, bulk, and system operations live on
# ``wordpress_specialist`` (companion-backed; no Docker socket).
# The legacy ``wordpress_advanced`` plugin was sunset 2026-05-04.
@staticmethod
def get_tool_specifications() -> list[dict[str, Any]]:
@@ -156,11 +158,12 @@ class WordPressPlugin(BasePlugin):
# Advanced content handlers
specs.extend(handlers.get_seo_specs()) # 4 tools
specs.extend(handlers.get_menus_specs()) # 5 tools
specs.extend(handlers.get_menus_specs()) # 9 tools (incl. wp_navigation CRUD)
specs.extend(handlers.get_wp_cli_specs()) # 15 tools
# Note: WooCommerce specs moved to woocommerce plugin (Phase D.1)
# Note: Database, Bulk, and System specs in wordpress_advanced plugin
# Database / bulk / system specs live on wordpress_specialist
# (companion-backed; legacy wordpress_advanced was sunset 2026-05-04).
return specs
@@ -479,6 +482,15 @@ class WordPressPlugin(BasePlugin):
async def update_menu_item(self, **kwargs):
return await self.menus.update_menu_item(**kwargs)
async def list_navigations(self, **kwargs):
return await self.menus.list_navigations(**kwargs)
async def get_navigation(self, **kwargs):
return await self.menus.get_navigation(**kwargs)
async def update_navigation(self, **kwargs):
return await self.menus.update_navigation(**kwargs)
# === WP-CLI Operations ===
async def wp_cache_flush(self, **kwargs):
if self.wp_cli:
@@ -555,7 +567,8 @@ class WordPressPlugin(BasePlugin):
return await self.wp_cli.wp_core_update(**kwargs)
return '{"error": "WP-CLI not available. Container not configured."}'
# Note: Database, Bulk, and System operations moved to wordpress_advanced plugin
# Database, bulk, and system operations live on wordpress_specialist
# (companion-backed; legacy wordpress_advanced was sunset 2026-05-04).
# === Legacy compatibility methods ===
# These methods are kept for potential backward compatibility

View File

@@ -30,7 +30,8 @@ from plugins.wordpress.schemas.product import (
)
from plugins.wordpress.schemas.seo import SEOData, SEOUpdate
# Note: Database, Bulk, and System schemas moved to wordpress_advanced plugin
# Database / bulk / system schemas live on wordpress_specialist
# (companion-backed; legacy wordpress_advanced was sunset 2026-05-04).
__all__ = [
# Common
@@ -64,5 +65,4 @@ __all__ = [
# SEO
"SEOData",
"SEOUpdate",
# Note: Database, Bulk, and System schemas moved to wordpress_advanced plugin
]

View File

@@ -1,375 +0,0 @@
# WordPress Advanced Plugin
> **Advanced WordPress management features requiring elevated permissions**
## Overview
The WordPress Advanced plugin provides 22 powerful tools for advanced WordPress management, separated from the core WordPress plugin for better security and tool visibility.
### Why Separated?
**Phase D (WordPress Advanced Split)** separates advanced management features into their own plugin for:
1. **Better Security** 🔒
- Separate API keys for basic vs advanced operations
- Advanced operations require explicit permission
- Reduces risk of accidental database modifications
2. **Better Tool Visibility** 👁️
- Basic users see only 95 WordPress tools (not 117)
- Advanced users explicitly enable advanced features
- Cleaner tool list for most users
3. **Granular Access Control** 🎯
- Per-project API keys can grant access to:
- WordPress Core only (95 tools)
- WordPress Advanced only (22 tools)
- Both (117 tools total)
## Features
### 22 Advanced Tools
#### Database Operations (7 tools)
- `wp_db_export` - Export WordPress database to SQL file
- `wp_db_import` - Import SQL file to WordPress database
- `wp_db_size` - Get database size and table information
- `wp_db_tables` - List all database tables with sizes
- `wp_db_search` - Search database for specific content
- `wp_db_query` - Execute read-only SQL queries
- `wp_db_repair` - Repair and optimize database tables
#### Bulk Operations (8 tools)
- `bulk_update_posts` - Update multiple posts in parallel (max 100)
- `bulk_delete_posts` - Delete multiple posts in parallel (max 100)
- `bulk_update_products` - Update multiple products in parallel (max 100)
- `bulk_delete_products` - Delete multiple products in parallel (max 100)
- `bulk_delete_media` - Delete multiple media files in parallel (max 100)
- `bulk_assign_categories` - Assign categories to multiple posts
- `bulk_assign_tags` - Assign tags to multiple posts
- `bulk_trash_posts` - Move multiple posts to trash
#### System Operations (7 tools)
- `system_info` - Get WordPress system information (PHP, MySQL, server)
- `system_phpinfo` - Get detailed PHP configuration
- `system_disk_usage` - Get disk usage for WordPress installation
- `system_clear_all_caches` - Clear all WordPress caches
- `cron_list` - List all WordPress cron jobs
- `cron_run` - Run specific cron job immediately
- `error_log` - Get WordPress error log
## Requirements
### Core Requirements
- WordPress site with REST API enabled
- WordPress application password
- **Docker container name** (for WP-CLI access) - REQUIRED!
### WP-CLI Access
All WordPress Advanced features require WP-CLI access through Docker:
```bash
# The MCP server must have access to:
/var/run/docker.sock # Docker socket
# And the WordPress container must be accessible:
docker exec <container_name> wp --version
```
## Configuration
### Environment Variables
```bash
# WordPress Advanced Site 1
WORDPRESS_ADVANCED_SITE1_URL=https://example.com
WORDPRESS_ADVANCED_SITE1_USERNAME=admin
WORDPRESS_ADVANCED_SITE1_APP_PASSWORD=xxxx xxxx xxxx xxxx xxxx xxxx
WORDPRESS_ADVANCED_SITE1_ALIAS=myblog # Optional: friendly name
WORDPRESS_ADVANCED_SITE1_CONTAINER=coolify-wp1 # REQUIRED: Docker container name
```
### Finding Container Name
```bash
# List all WordPress containers:
docker ps --filter "name=wordpress" --format "{{.Names}}"
# Test WP-CLI access:
docker exec <container_name> wp --info
```
## Usage Examples
### Database Operations
```python
# Export database
result = await mcp.call_tool("wordpress_advanced_wp_db_export", {
"site": "myblog",
"output_file": "/backup/db-backup.sql"
})
# Get database size
result = await mcp.call_tool("wordpress_advanced_wp_db_size", {
"site": "myblog"
})
# Search database
result = await mcp.call_tool("wordpress_advanced_wp_db_search", {
"site": "myblog",
"search_term": "old-domain.com",
"tables": ["wp_posts", "wp_options"]
})
# Execute read-only query
result = await mcp.call_tool("wordpress_advanced_wp_db_query", {
"site": "myblog",
"query": "SELECT COUNT(*) as total FROM wp_posts WHERE post_status='publish'"
})
```
### Bulk Operations
```python
# Bulk update posts
result = await mcp.call_tool("wordpress_advanced_bulk_update_posts", {
"site": "myblog",
"post_ids": [1, 2, 3, 4, 5],
"updates": {
"status": "draft",
"author": 2
}
})
# Bulk delete products
result = await mcp.call_tool("wordpress_advanced_bulk_delete_products", {
"site": "mystore",
"product_ids": [100, 101, 102],
"force": False # Move to trash instead of permanent delete
})
# Bulk assign categories
result = await mcp.call_tool("wordpress_advanced_bulk_assign_categories", {
"site": "myblog",
"post_ids": [10, 11, 12],
"category_ids": [5, 6]
})
```
### System Operations
```python
# Get system information
result = await mcp.call_tool("wordpress_advanced_system_info", {
"site": "myblog"
})
# Clear all caches
result = await mcp.call_tool("wordpress_advanced_system_clear_all_caches", {
"site": "myblog"
})
# List cron jobs
result = await mcp.call_tool("wordpress_advanced_cron_list", {
"site": "myblog"
})
# Get error log
result = await mcp.call_tool("wordpress_advanced_error_log", {
"site": "myblog",
"lines": 100
})
```
## Tool Count
```
WordPress Core Plugin: 95 tools ✅ (basic features)
WordPress Advanced Plugin: 22 tools 🔒 (advanced features)
─────────────────────────────────────────────────
Total (if both enabled): 117 tools
```
## API Key Configuration
### Option 1: WordPress Core Only (Basic Users)
```bash
# Create API key with wordpress scope only
# User gets: 95 WordPress tools
# User does NOT see: WordPress Advanced tools
```
### Option 2: WordPress Advanced Only (Power Users)
```bash
# Create API key with wordpress_advanced scope only
# User gets: 22 WordPress Advanced tools
# User does NOT see: WordPress Core tools
```
### Option 3: Both Plugins (Admin Users)
```bash
# Create API key with both scopes
# User gets: 117 total tools (95 + 22)
```
## Security Considerations
### Database Operations
- **wp_db_export**: Exports contain sensitive data - secure storage required
- **wp_db_import**: Can overwrite entire database - use with extreme caution
- **wp_db_query**: Read-only enforced - write queries are rejected
- **wp_db_search**: May expose sensitive information in results
### Bulk Operations
- **Parallel Execution**: Max 10 concurrent operations (controlled by semaphore)
- **Batch Limits**: Maximum 100 items per bulk operation
- **Error Handling**: Returns success/failure status for each item
- **Reversibility**: Most operations support trash (soft delete) before permanent deletion
### System Operations
- **system_clear_all_caches**: May cause temporary performance impact
- **cron_run**: Can trigger resource-intensive operations
- **error_log**: May contain sensitive information (paths, credentials)
## Troubleshooting
### "WP-CLI not available" Error
**Cause**: Container not configured or Docker socket not mounted
**Solution**:
```bash
# 1. Check container name
docker ps | grep wordpress
# 2. Test WP-CLI access
docker exec <container_name> wp --info
# 3. Verify Docker socket in docker-compose.yaml
volumes:
- /var/run/docker.sock:/var/run/docker.sock:ro
```
### "Database handler not available" Error
**Cause**: WP-CLI not configured (container name missing)
**Solution**:
```bash
# Ensure CONTAINER is set in environment variables
WORDPRESS_ADVANCED_SITE1_CONTAINER=your-container-name
```
### "Bulk operation failed" Error
**Cause**: Too many items or invalid IDs
**Solution**:
- Reduce batch size (max 100 items)
- Verify all IDs exist
- Check error details in response for specific failures
## Architecture
```
plugins/wordpress_advanced/
├── __init__.py # Plugin exports
├── plugin.py # WordPressAdvancedPlugin class
├── README.md # This file
├── schemas/ # Pydantic validation models
│ ├── __init__.py
│ ├── database.py # Database operation schemas
│ ├── bulk.py # Bulk operation schemas
│ └── system.py # System operation schemas
└── handlers/ # Tool implementations
├── __init__.py
├── database.py # Database operations (7 tools)
├── bulk.py # Bulk operations (8 tools)
└── system.py # System operations (7 tools)
```
## Migration from WordPress Core
If you previously used WordPress Phase 5 features (database, bulk, system operations):
### Before (Phase 5 - Single Plugin)
```bash
# All features in one plugin
WORDPRESS_SITE1_CONTAINER=coolify-wp1
# All 117 tools visible to everyone
```
### After (Phase D - Split Plugins)
```bash
# Basic WordPress (95 tools)
WORDPRESS_SITE1_URL=...
WORDPRESS_SITE1_USERNAME=...
WORDPRESS_SITE1_APP_PASSWORD=...
# Advanced WordPress (22 tools) - separate configuration
WORDPRESS_ADVANCED_SITE1_URL=...
WORDPRESS_ADVANCED_SITE1_USERNAME=...
WORDPRESS_ADVANCED_SITE1_APP_PASSWORD=...
WORDPRESS_ADVANCED_SITE1_CONTAINER=coolify-wp1 # REQUIRED
# API Keys can now control access separately
```
### Tool Name Changes
Tool names now include `wordpress_advanced_` prefix:
| Before (Phase 5) | After (Phase D) |
|-----------------------|---------------------------------------|
| `wp_db_export` | `wordpress_advanced_wp_db_export` |
| `bulk_update_posts` | `wordpress_advanced_bulk_update_posts`|
| `system_info` | `wordpress_advanced_system_info` |
## Performance
### Bulk Operations
- **Parallel Execution**: Up to 10 concurrent operations
- **Semaphore Control**: Prevents server overload
- **Progress Tracking**: Per-item success/failure status
- **Recommended Batch Size**: 10-50 items for optimal performance
### Database Operations
- **Export**: Time depends on database size (1GB ≈ 30-60 seconds)
- **Import**: Slightly slower than export due to indexing
- **Search**: Full-text search across specified tables
- **Query**: Fast read-only queries with result limits
### System Operations
- **Cache Clear**: 1-5 seconds depending on cache size
- **Cron Jobs**: Immediate execution, duration depends on job
- **System Info**: Near-instant (<1 second)
## Best Practices
1. **Use Separate API Keys**: Create different keys for basic and advanced operations
2. **Batch Size**: Keep bulk operations under 50 items for optimal performance
3. **Database Backups**: Always backup before using wp_db_import
4. **Cron Jobs**: Test cron jobs in staging environment first
5. **Error Logs**: Regularly check error logs for security issues
6. **Disk Usage**: Monitor disk usage before large export operations
## Support
For issues, feature requests, or contributions:
- GitHub Issues: [mcphub/issues](https://github.com/airano-ir/mcphub/issues)
- Documentation: [docs/](../../docs/)
- Main README: [../../README.md](../../README.md)
## License
Same as main project license.
---
**Part of MCP Hub** - Phase D (WordPress Advanced Split)
**Version**: 1.0.0
**Last Updated**: 2025-11-18

View File

@@ -1,12 +0,0 @@
"""
WordPress Advanced Plugin
Advanced WordPress management features including database operations,
bulk operations, and system management.
This plugin provides 22 advanced tools for WordPress power users and administrators.
"""
from .plugin import WordPressAdvancedPlugin
__all__ = ["WordPressAdvancedPlugin"]

View File

@@ -1,26 +0,0 @@
"""
WordPress Advanced Handlers
Modular handlers for WordPress advanced functionality.
Each handler is responsible for a specific domain of WordPress advanced operations.
"""
from plugins.wordpress_advanced.handlers.bulk import BulkHandler
from plugins.wordpress_advanced.handlers.bulk import get_tool_specifications as get_bulk_specs
from plugins.wordpress_advanced.handlers.database import DatabaseHandler
from plugins.wordpress_advanced.handlers.database import (
get_tool_specifications as get_database_specs,
)
from plugins.wordpress_advanced.handlers.system import SystemHandler
from plugins.wordpress_advanced.handlers.system import get_tool_specifications as get_system_specs
__all__ = [
# Handlers
"DatabaseHandler",
"BulkHandler",
"SystemHandler",
# Tool specifications
"get_database_specs",
"get_bulk_specs",
"get_system_specs",
]

View File

@@ -1,689 +0,0 @@
"""
Bulk Operations Handler
Manages WordPress bulk operations including:
- Bulk updates for posts/products/media
- Bulk deletions
- Bulk category/tag assignments
All operations use WordPress REST API batch requests for efficiency.
Operations are limited to 100 items per request for performance.
"""
import asyncio
from typing import Any
from plugins.wordpress.client import WordPressClient
from plugins.wordpress_advanced.schemas.bulk import (
BulkOperationResult,
)
def get_tool_specifications() -> list[dict[str, Any]]:
"""Return tool specifications for ToolGenerator"""
return [
# Bulk Update Posts
{
"name": "bulk_update_posts",
"method_name": "bulk_update_posts",
"description": "Update multiple posts at once. Supports status, author, categories, tags, and more. Max 100 posts per request.",
"schema": {
"type": "object",
"properties": {
"post_ids": {
"type": "array",
"items": {"type": "integer"},
"minItems": 1,
"maxItems": 100,
"description": "List of post IDs to update",
},
"updates": {
"type": "object",
"description": "Fields to update (status, title, content, author, categories, tags, etc.)",
"properties": {
"status": {
"type": "string",
"enum": ["publish", "draft", "pending", "private"],
},
"title": {"type": "string"},
"content": {"type": "string"},
"excerpt": {"type": "string"},
"author": {"type": "integer"},
"categories": {"type": "array", "items": {"type": "integer"}},
"tags": {"type": "array", "items": {"type": "integer"}},
"featured_media": {"type": "integer"},
"comment_status": {"type": "string", "enum": ["open", "closed"]},
"ping_status": {"type": "string", "enum": ["open", "closed"]},
"sticky": {"type": "boolean"},
},
},
},
"required": ["post_ids", "updates"],
},
"scope": "write",
},
# Bulk Delete Posts
{
"name": "bulk_delete_posts",
"method_name": "bulk_delete_posts",
"description": "Delete multiple posts at once. Can move to trash or permanently delete. Max 100 posts per request.",
"schema": {
"type": "object",
"properties": {
"post_ids": {
"type": "array",
"items": {"type": "integer"},
"minItems": 1,
"maxItems": 100,
"description": "List of post IDs to delete",
},
"force": {
"type": "boolean",
"default": False,
"description": "Force permanent deletion (bypass trash)",
},
},
"required": ["post_ids"],
},
"scope": "admin",
},
# Bulk Update Products
{
"name": "bulk_update_products",
"method_name": "bulk_update_products",
"description": "Update multiple WooCommerce products at once. Supports price, stock, status, and more. Max 100 products per request.",
"schema": {
"type": "object",
"properties": {
"product_ids": {
"type": "array",
"items": {"type": "integer"},
"minItems": 1,
"maxItems": 100,
"description": "List of product IDs to update",
},
"updates": {
"type": "object",
"description": "Fields to update (price, stock, status, etc.)",
"properties": {
"name": {"type": "string"},
"status": {
"type": "string",
"enum": ["draft", "pending", "private", "publish"],
},
"featured": {"type": "boolean"},
"catalog_visibility": {
"type": "string",
"enum": ["visible", "catalog", "search", "hidden"],
},
"description": {"type": "string"},
"short_description": {"type": "string"},
"sku": {"type": "string"},
"price": {"type": "string"},
"regular_price": {"type": "string"},
"sale_price": {"type": "string"},
"stock_quantity": {"type": "integer"},
"stock_status": {
"type": "string",
"enum": ["instock", "outofstock", "onbackorder"],
},
"manage_stock": {"type": "boolean"},
"categories": {"type": "array", "items": {"type": "object"}},
"tags": {"type": "array", "items": {"type": "object"}},
},
},
},
"required": ["product_ids", "updates"],
},
"scope": "write",
},
# Bulk Delete Products
{
"name": "bulk_delete_products",
"method_name": "bulk_delete_products",
"description": "Delete multiple WooCommerce products at once. Permanently deletes products. Max 100 products per request.",
"schema": {
"type": "object",
"properties": {
"product_ids": {
"type": "array",
"items": {"type": "integer"},
"minItems": 1,
"maxItems": 100,
"description": "List of product IDs to delete",
},
"force": {
"type": "boolean",
"default": False,
"description": "Force permanent deletion",
},
},
"required": ["product_ids"],
},
"scope": "admin",
},
# Bulk Assign Categories
{
"name": "bulk_assign_categories",
"method_name": "bulk_assign_categories",
"description": "Assign categories to multiple posts/products at once. Can replace or append categories. Max 100 items per request. IMPORTANT: For posts use 'category' taxonomy IDs, for products use 'product_cat' taxonomy IDs.",
"schema": {
"type": "object",
"properties": {
"item_ids": {
"type": "array",
"items": {"type": "integer"},
"minItems": 1,
"maxItems": 100,
"description": "List of post/product IDs",
},
"category_ids": {
"type": "array",
"items": {"type": "integer"},
"minItems": 1,
"description": "List of category IDs to assign. For posts: use 'category' taxonomy IDs. For products: use 'product_cat' taxonomy IDs.",
},
"replace": {
"type": "boolean",
"default": False,
"description": "Replace existing categories (true) or append (false)",
},
"item_type": {
"type": "string",
"enum": ["post", "product"],
"default": "post",
"description": "Type of items",
},
},
"required": ["item_ids", "category_ids"],
},
"scope": "write",
},
# Bulk Assign Tags
{
"name": "bulk_assign_tags",
"method_name": "bulk_assign_tags",
"description": "Assign tags to multiple posts/products at once. Can replace or append tags. Max 100 items per request. IMPORTANT: For posts use 'post_tag' taxonomy IDs, for products use 'product_tag' taxonomy IDs.",
"schema": {
"type": "object",
"properties": {
"item_ids": {
"type": "array",
"items": {"type": "integer"},
"minItems": 1,
"maxItems": 100,
"description": "List of post/product IDs",
},
"tag_ids": {
"type": "array",
"items": {"type": "integer"},
"minItems": 1,
"description": "List of tag IDs to assign. For posts: use 'post_tag' taxonomy IDs. For products: use 'product_tag' taxonomy IDs.",
},
"replace": {
"type": "boolean",
"default": False,
"description": "Replace existing tags (true) or append (false)",
},
"item_type": {
"type": "string",
"enum": ["post", "product"],
"default": "post",
"description": "Type of items",
},
},
"required": ["item_ids", "tag_ids"],
},
"scope": "write",
},
# Bulk Update Media
{
"name": "bulk_update_media",
"method_name": "bulk_update_media",
"description": "Update multiple media items at once. Supports alt_text, title, caption, description. Max 100 items per request.",
"schema": {
"type": "object",
"properties": {
"media_ids": {
"type": "array",
"items": {"type": "integer"},
"minItems": 1,
"maxItems": 100,
"description": "List of media IDs to update",
},
"updates": {
"type": "object",
"description": "Fields to update",
"properties": {
"title": {"type": "string"},
"alt_text": {"type": "string"},
"caption": {"type": "string"},
"description": {"type": "string"},
},
},
},
"required": ["media_ids", "updates"],
},
"scope": "write",
},
# Bulk Delete Media
{
"name": "bulk_delete_media",
"method_name": "bulk_delete_media",
"description": "Delete multiple media items at once. Permanently deletes files from server. Max 100 items per request.",
"schema": {
"type": "object",
"properties": {
"media_ids": {
"type": "array",
"items": {"type": "integer"},
"minItems": 1,
"maxItems": 100,
"description": "List of media IDs to delete",
},
"force": {
"type": "boolean",
"default": True,
"description": "Force permanent deletion (media can't be trashed)",
},
},
"required": ["media_ids"],
},
"scope": "admin",
},
]
class BulkHandler:
"""Handles WordPress bulk operations"""
def __init__(self, client: WordPressClient):
"""
Initialize Bulk Handler
Args:
client: WordPress REST API client
"""
self.client = client
self.logger = client.logger
async def _bulk_operation(
self, endpoint: str, item_ids: list[int], operation: str, data: dict[str, Any] | None = None
) -> BulkOperationResult:
"""
Generic bulk operation executor
Args:
endpoint: REST API endpoint (e.g., 'posts', 'products')
item_ids: List of item IDs to process
operation: 'update' or 'delete'
data: Data for update operations
Returns:
BulkOperationResult with success/failure counts
"""
success_count = 0
failed_count = 0
failed_ids = []
errors = []
# Determine if using WooCommerce API
use_woocommerce = endpoint == "products"
# Determine HTTP method for updates
# WordPress REST API uses POST for media/posts updates, PUT for WooCommerce products
update_method = "PUT" if use_woocommerce else "POST"
# Process items in parallel (with limit to avoid overwhelming server)
semaphore = asyncio.Semaphore(10) # Max 10 concurrent requests
async def process_item(item_id: int):
nonlocal success_count, failed_count
async with semaphore:
try:
# Check if this is a WooCommerce endpoint
use_custom_namespace = endpoint.startswith("wc/")
if operation == "update":
await self.client.request(
update_method,
f"{endpoint}/{item_id}",
json_data=data,
use_custom_namespace=use_custom_namespace,
)
elif operation == "delete":
params = data or {}
await self.client.request(
"DELETE",
f"{endpoint}/{item_id}",
params=params,
use_custom_namespace=use_custom_namespace,
)
success_count += 1
return True
except Exception as e:
failed_count += 1
failed_ids.append(item_id)
errors.append({"id": item_id, "error": str(e)})
self.logger.error(f"Bulk {operation} failed for {endpoint}/{item_id}: {str(e)}")
return False
# Execute all operations in parallel
await asyncio.gather(*[process_item(item_id) for item_id in item_ids])
return {
"success_count": success_count,
"failed_count": failed_count,
"total": len(item_ids),
"failed_ids": failed_ids,
"errors": errors,
}
async def bulk_update_posts(
self, post_ids: list[int], updates: dict[str, Any]
) -> dict[str, Any]:
"""Bulk update posts"""
try:
result = await self._bulk_operation(
endpoint="posts", item_ids=post_ids, operation="update", data=updates
)
return {
"success": True,
"message": f"Updated {result['success_count']}/{result['total']} posts",
**result,
}
except Exception as e:
self.logger.error(f"Bulk update posts failed: {str(e)}")
return {"success": False, "error": str(e)}
async def bulk_delete_posts(self, post_ids: list[int], force: bool = False) -> dict[str, Any]:
"""Bulk delete posts"""
try:
result = await self._bulk_operation(
endpoint="posts", item_ids=post_ids, operation="delete", data={"force": force}
)
return {
"success": True,
"message": f"Deleted {result['success_count']}/{result['total']} posts",
"permanent": force,
**result,
}
except Exception as e:
self.logger.error(f"Bulk delete posts failed: {str(e)}")
return {"success": False, "error": str(e)}
async def bulk_update_products(
self, product_ids: list[int], updates: dict[str, Any]
) -> dict[str, Any]:
"""Bulk update WooCommerce products"""
try:
result = await self._bulk_operation(
endpoint="wc/v3/products", item_ids=product_ids, operation="update", data=updates
)
return {
"success": True,
"message": f"Updated {result['success_count']}/{result['total']} products",
**result,
}
except Exception as e:
self.logger.error(f"Bulk update products failed: {str(e)}")
return {"success": False, "error": str(e)}
async def bulk_delete_products(
self, product_ids: list[int], force: bool = False
) -> dict[str, Any]:
"""Bulk delete WooCommerce products"""
try:
result = await self._bulk_operation(
endpoint="wc/v3/products",
item_ids=product_ids,
operation="delete",
data={"force": force},
)
return {
"success": True,
"message": f"Deleted {result['success_count']}/{result['total']} products",
"permanent": force,
**result,
}
except Exception as e:
self.logger.error(f"Bulk delete products failed: {str(e)}")
return {"success": False, "error": str(e)}
async def bulk_assign_categories(
self,
item_ids: list[int],
category_ids: list[int],
replace: bool = False,
item_type: str = "post",
) -> dict[str, Any]:
"""
Bulk assign categories to posts/products.
IMPORTANT:
- For posts: use category IDs from 'category' taxonomy
- For products: use category IDs from 'product_cat' taxonomy
"""
try:
# Use correct endpoint for posts vs WooCommerce products
if item_type == "post":
endpoint = "posts"
elif item_type == "product":
endpoint = "wc/v3/products" # WooCommerce endpoint
else:
endpoint = "posts" # Default to posts
# Process each item individually to handle append mode
success_count = 0
failed_count = 0
failed_ids = []
errors = []
# Determine if using WooCommerce endpoint
use_custom_namespace = endpoint.startswith("wc/")
for item_id in item_ids:
try:
# If append mode, get current categories first
if not replace:
if use_custom_namespace:
current_item = await self.client.get(
f"{endpoint}/{item_id}", use_custom_namespace=True
)
current_categories = current_item.get("categories", [])
# Extract IDs from current categories
current_cat_ids = [
cat["id"] for cat in current_categories if "id" in cat
]
# Merge with new categories (avoid duplicates)
all_cat_ids = list(set(current_cat_ids + category_ids))
else:
current_item = await self.client.get(f"{endpoint}/{item_id}")
current_cat_ids = current_item.get("categories", [])
# Merge with new categories (avoid duplicates)
all_cat_ids = list(set(current_cat_ids + category_ids))
else:
# Replace mode: use only new categories
all_cat_ids = category_ids
# Format categories based on item type
if item_type == "product":
# WooCommerce requires categories as objects with id
updates = {"categories": [{"id": cat_id} for cat_id in all_cat_ids]}
else:
# WordPress posts use simple category ID array
updates = {"categories": all_cat_ids}
# Update the item
await self.client.request(
"PUT" if use_custom_namespace else "POST",
f"{endpoint}/{item_id}",
json_data=updates,
use_custom_namespace=use_custom_namespace,
)
success_count += 1
except Exception as e:
failed_count += 1
failed_ids.append(item_id)
errors.append({"id": item_id, "error": str(e)})
self.logger.error(
f"Failed to assign categories to {item_type} {item_id}: {str(e)}"
)
return {
"success": True,
"message": f"Assigned categories to {success_count}/{len(item_ids)} {item_type}s",
"mode": "replace" if replace else "append",
"success_count": success_count,
"failed_count": failed_count,
"total": len(item_ids),
"failed_ids": failed_ids,
"errors": errors,
}
except Exception as e:
self.logger.error(f"Bulk assign categories failed: {str(e)}")
return {"success": False, "error": str(e)}
async def bulk_assign_tags(
self,
item_ids: list[int],
tag_ids: list[int],
replace: bool = False,
item_type: str = "post",
) -> dict[str, Any]:
"""
Bulk assign tags to posts/products.
IMPORTANT:
- For posts: use tag IDs from 'post_tag' taxonomy
- For products: use tag IDs from 'product_tag' taxonomy
"""
try:
# Use correct endpoint for posts vs WooCommerce products
if item_type == "post":
endpoint = "posts"
elif item_type == "product":
endpoint = "wc/v3/products" # WooCommerce endpoint
else:
endpoint = "posts" # Default to posts
# Process each item individually to handle append mode
success_count = 0
failed_count = 0
failed_ids = []
errors = []
# Determine if using WooCommerce endpoint
use_custom_namespace = endpoint.startswith("wc/")
for item_id in item_ids:
try:
# If append mode, get current tags first
if not replace:
if use_custom_namespace:
current_item = await self.client.get(
f"{endpoint}/{item_id}", use_custom_namespace=True
)
current_tags = current_item.get("tags", [])
# Extract IDs from current tags
current_tag_ids = [tag["id"] for tag in current_tags if "id" in tag]
# Merge with new tags (avoid duplicates)
all_tag_ids = list(set(current_tag_ids + tag_ids))
else:
current_item = await self.client.get(f"{endpoint}/{item_id}")
current_tag_ids = current_item.get("tags", [])
# Merge with new tags (avoid duplicates)
all_tag_ids = list(set(current_tag_ids + tag_ids))
else:
# Replace mode: use only new tags
all_tag_ids = tag_ids
# Format tags based on item type
if item_type == "product":
# WooCommerce requires tags as objects with id
updates = {"tags": [{"id": tag_id} for tag_id in all_tag_ids]}
else:
# WordPress posts use simple tag ID array
updates = {"tags": all_tag_ids}
# Update the item
await self.client.request(
"PUT" if use_custom_namespace else "POST",
f"{endpoint}/{item_id}",
json_data=updates,
use_custom_namespace=use_custom_namespace,
)
success_count += 1
except Exception as e:
failed_count += 1
failed_ids.append(item_id)
errors.append({"id": item_id, "error": str(e)})
self.logger.error(f"Failed to assign tags to {item_type} {item_id}: {str(e)}")
return {
"success": True,
"message": f"Assigned tags to {success_count}/{len(item_ids)} {item_type}s",
"mode": "replace" if replace else "append",
"success_count": success_count,
"failed_count": failed_count,
"total": len(item_ids),
"failed_ids": failed_ids,
"errors": errors,
}
except Exception as e:
self.logger.error(f"Bulk assign tags failed: {str(e)}")
return {"success": False, "error": str(e)}
async def bulk_update_media(
self, media_ids: list[int], updates: dict[str, Any]
) -> dict[str, Any]:
"""Bulk update media items"""
try:
result = await self._bulk_operation(
endpoint="media", item_ids=media_ids, operation="update", data=updates
)
return {
"success": True,
"message": f"Updated {result['success_count']}/{result['total']} media items",
**result,
}
except Exception as e:
self.logger.error(f"Bulk update media failed: {str(e)}")
return {"success": False, "error": str(e)}
async def bulk_delete_media(self, media_ids: list[int], force: bool = True) -> dict[str, Any]:
"""Bulk delete media items"""
try:
result = await self._bulk_operation(
endpoint="media", item_ids=media_ids, operation="delete", data={"force": force}
)
return {
"success": True,
"message": f"Deleted {result['success_count']}/{result['total']} media items",
"permanent": force,
**result,
}
except Exception as e:
self.logger.error(f"Bulk delete media failed: {str(e)}")
return {"success": False, "error": str(e)}

View File

@@ -1,591 +0,0 @@
"""
Database Operations Handler
Manages WordPress database operations including:
- Export/Import
- Backup/Restore
- Optimization and Repair
- Search and Query operations
All operations require 'write' or 'admin' scope for security.
"""
import json
from typing import Any
from plugins.wordpress.client import WordPressClient
from plugins.wordpress.wp_cli import WPCLIManager
def get_tool_specifications() -> list[dict[str, Any]]:
"""Return tool specifications for ToolGenerator"""
return [
# DB Export (already exists in wp_cli.py, documented here for completeness)
{
"name": "wp_db_export",
"method_name": "wp_db_export",
"description": "Export WordPress database to SQL file. Creates a timestamped backup file in /tmp directory.",
"schema": {
"type": "object",
"properties": {
"tables": {
"type": "array",
"items": {"type": "string"},
"description": "Specific tables to export (default: all tables)",
},
"exclude_tables": {
"type": "array",
"items": {"type": "string"},
"description": "Tables to exclude from export",
},
"add_drop_table": {
"type": "boolean",
"default": True,
"description": "Include DROP TABLE statements",
},
},
},
"scope": "write",
},
# DB Import
{
"name": "wp_db_import",
"method_name": "wp_db_import",
"description": "Import database from SQL file. DESTRUCTIVE: replaces current database. Requires admin scope.",
"schema": {
"type": "object",
"properties": {
"file_path": {"type": "string", "description": "Path to SQL file on server"},
"url": {"type": "string", "description": "URL to download SQL file from"},
"skip_optimization": {
"type": "boolean",
"default": False,
"description": "Skip database optimization after import",
},
},
},
"scope": "admin",
},
# DB Size Info
{
"name": "wp_db_size",
"method_name": "wp_db_size",
"description": "Get database size statistics including total size, table sizes, and row counts.",
"schema": {"type": "object", "properties": {}},
"scope": "read",
},
# DB Tables List
{
"name": "wp_db_tables",
"method_name": "wp_db_tables",
"description": "List all database tables with detailed information (size, engine, rows, collation).",
"schema": {
"type": "object",
"properties": {
"prefix_only": {
"type": "boolean",
"default": True,
"description": "Show only WordPress tables (with wp_ prefix)",
}
},
},
"scope": "read",
},
# DB Search
{
"name": "wp_db_search",
"method_name": "wp_db_search",
"description": "Search database for specific strings. Useful for finding content, debugging, or data migration.",
"schema": {
"type": "object",
"properties": {
"search_string": {"type": "string", "description": "String to search for"},
"tables": {
"type": "array",
"items": {"type": "string"},
"description": "Specific tables to search",
},
"regex": {
"type": "boolean",
"default": False,
"description": "Use regex pattern matching",
},
"case_sensitive": {
"type": "boolean",
"default": False,
"description": "Case-sensitive search",
},
"max_results": {
"type": "integer",
"default": 100,
"minimum": 1,
"maximum": 1000,
"description": "Maximum results to return",
},
},
"required": ["search_string"],
},
"scope": "read",
},
# DB Query (read-only SELECT)
{
"name": "wp_db_query",
"method_name": "wp_db_query",
"description": "Execute read-only SQL query (SELECT, SHOW, DESCRIBE only). For advanced users and debugging.",
"schema": {
"type": "object",
"properties": {
"query": {
"type": "string",
"description": "SQL query to execute (SELECT only)",
},
"max_rows": {
"type": "integer",
"default": 1000,
"minimum": 1,
"maximum": 10000,
"description": "Maximum rows to return",
},
},
"required": ["query"],
},
"scope": "write",
},
# DB Repair
{
"name": "wp_db_repair",
"method_name": "wp_db_repair",
"description": "Repair corrupted database tables. Runs REPAIR TABLE on all WordPress tables.",
"schema": {
"type": "object",
"properties": {
"tables": {
"type": "array",
"items": {"type": "string"},
"description": "Specific tables to repair (default: all tables)",
}
},
},
"scope": "write",
},
]
class DatabaseHandler:
"""Handles WordPress database operations"""
def __init__(self, client: WordPressClient, wp_cli: WPCLIManager | None = None):
"""
Initialize Database Handler
Args:
client: WordPress REST API client
wp_cli: WP-CLI manager (optional, for advanced operations)
"""
self.client = client
self.wp_cli = wp_cli
self.logger = client.logger
async def wp_db_export(
self,
tables: list[str] | None = None,
exclude_tables: list[str] | None = None,
add_drop_table: bool = True,
) -> dict[str, Any]:
"""
Export WordPress database to SQL file
Uses WP-CLI: wp db export
"""
if not self.wp_cli:
return {
"success": False,
"error": "WP-CLI not available. Container name not configured.",
}
try:
# Build WP-CLI command
cmd = "db export /tmp/backup-$(date +%Y%m%d-%H%M%S).sql"
if tables:
cmd += f" --tables={','.join(tables)}"
if exclude_tables:
cmd += f" --exclude_tables={','.join(exclude_tables)}"
if not add_drop_table:
cmd += " --no-add-drop-table"
# Execute export
result = await self.wp_cli.execute_command(cmd)
return {
"success": True,
"message": "Database exported successfully",
"file": result.get("output", ""),
"command": cmd,
}
except Exception as e:
self.logger.error(f"Database export failed: {str(e)}")
return {"success": False, "error": str(e)}
async def wp_db_import(
self, file_path: str | None = None, url: str | None = None, skip_optimization: bool = False
) -> dict[str, Any]:
"""
Import database from SQL file
⚠️ DESTRUCTIVE OPERATION - Requires admin scope
"""
if not self.wp_cli:
return {"success": False, "error": "WP-CLI not available"}
if not file_path and not url:
return {"success": False, "error": "Either file_path or url is required"}
try:
# Download file if URL provided
if url:
# Use wget or curl to download
download_cmd = f"wget -O /tmp/import.sql '{url}'"
await self.wp_cli.execute_command(f"eval '{download_cmd}'")
file_path = "/tmp/import.sql"
# Import database
cmd = f"db import {file_path}"
await self.wp_cli.execute_command(cmd)
# Optimize unless skipped
if not skip_optimization:
await self.wp_cli.execute_command("db optimize")
return {
"success": True,
"message": "Database imported successfully",
"file": file_path,
"optimized": not skip_optimization,
}
except Exception as e:
self.logger.error(f"Database import failed: {str(e)}")
return {"success": False, "error": str(e)}
async def wp_db_size(self) -> dict[str, Any]:
"""Get database size statistics"""
if not self.wp_cli:
return {"success": False, "error": "WP-CLI not available"}
try:
# Get total database size first
total_query = """
SELECT ROUND(SUM(data_length + index_length) / 1024 / 1024, 2) AS total_mb,
SUM(table_rows) AS total_rows,
COUNT(*) AS table_count
FROM information_schema.TABLES
WHERE table_schema = DATABASE()
"""
total_cmd = f'db query "{total_query}" --skip-column-names'
total_result = await self.wp_cli.execute_command(total_cmd)
# Parse tab-separated output
total_output = total_result.get("output", "0\t0\t0").strip()
total_parts = total_output.split("\t")
total_size_mb = (
float(total_parts[0]) if len(total_parts) > 0 and total_parts[0] else 0.0
)
total_rows = int(total_parts[1]) if len(total_parts) > 1 and total_parts[1] else 0
table_count = int(total_parts[2]) if len(total_parts) > 2 and total_parts[2] else 0
# Get individual table sizes (top 50 largest tables)
tables_query = """
SELECT table_name,
ROUND((data_length + index_length) / 1024 / 1024, 2) AS size_mb,
table_rows
FROM information_schema.TABLES
WHERE table_schema = DATABASE()
ORDER BY (data_length + index_length) DESC
LIMIT 50
"""
tables_cmd = f'db query "{tables_query}" --skip-column-names'
tables_result = await self.wp_cli.execute_command(tables_cmd)
# Parse table results (tab-separated)
tables_output = tables_result.get("output", "").strip()
tables = []
if tables_output:
for line in tables_output.split("\n"):
parts = line.split("\t")
if len(parts) >= 3:
tables.append(
{
"table_name": parts[0],
"size_mb": float(parts[1]) if parts[1] else 0.0,
"table_rows": int(parts[2]) if parts[2] else 0,
}
)
return {
"success": True,
"total_size_mb": total_size_mb,
"total_rows": total_rows,
"table_count": table_count,
"tables": tables,
"note": "Showing top 50 largest tables",
}
except Exception as e:
self.logger.error(f"Database size check failed: {str(e)}")
return {"success": False, "error": str(e)}
async def wp_db_tables(self, prefix_only: bool = True) -> dict[str, Any]:
"""List all database tables with details"""
if not self.wp_cli:
return {"success": False, "error": "WP-CLI not available"}
try:
# Query for table information
query = """
SELECT
table_name,
engine,
table_rows,
ROUND((data_length / 1024 / 1024), 2),
ROUND((index_length / 1024 / 1024), 2),
ROUND(((data_length + index_length) / 1024 / 1024), 2),
table_collation
FROM information_schema.TABLES
WHERE table_schema = DATABASE()
"""
if prefix_only:
# Get WordPress table prefix
prefix_result = await self.wp_cli.execute_command("config get table_prefix")
prefix = prefix_result.get("output", "wp_").strip()
query += f" AND table_name LIKE '{prefix}%'"
query += " ORDER BY (data_length + index_length) DESC"
# Use --skip-column-names for MariaDB compatibility (no --format=json)
cmd = f'db query "{query}" --skip-column-names'
result = await self.wp_cli.execute_command(cmd)
# Parse tab-separated output
tables_output = result.get("output", "").strip()
tables = []
if tables_output:
for line in tables_output.split("\n"):
parts = line.split("\t")
if len(parts) >= 7:
tables.append(
{
"name": parts[0],
"engine": parts[1],
"rows": int(parts[2]) if parts[2] and parts[2] != "NULL" else 0,
"data_size_mb": (
float(parts[3]) if parts[3] and parts[3] != "NULL" else 0.0
),
"index_size_mb": (
float(parts[4]) if parts[4] and parts[4] != "NULL" else 0.0
),
"total_size_mb": (
float(parts[5]) if parts[5] and parts[5] != "NULL" else 0.0
),
"collation": parts[6] if parts[6] != "NULL" else None,
}
)
return {"success": True, "tables": tables, "total": len(tables)}
except Exception as e:
self.logger.error(f"Database tables list failed: {str(e)}")
return {"success": False, "error": str(e)}
async def wp_db_search(
self,
search_string: str,
tables: list[str] | None = None,
regex: bool = False,
case_sensitive: bool = False,
max_results: int = 100,
) -> dict[str, Any]:
"""Search database for specific strings"""
if not self.wp_cli:
return {"success": False, "error": "WP-CLI not available"}
try:
# Build search-replace command in dry-run mode
cmd = f'search-replace "{search_string}" "{search_string}" --dry-run --format=count'
if tables:
cmd += f" {' '.join(tables)}"
if regex:
cmd += " --regex"
if not case_sensitive:
cmd += " --skip-columns=guid" # Common practice
result = await self.wp_cli.execute_command(cmd)
return {
"success": True,
"search_string": search_string,
"matches_found": result.get("output", "0"),
"dry_run": True,
}
except Exception as e:
self.logger.error(f"Database search failed: {str(e)}")
return {"success": False, "error": str(e)}
async def wp_db_query(self, query: str, max_rows: int = 1000) -> dict[str, Any]:
"""
Execute read-only SQL query
Security: Only SELECT, SHOW, DESCRIBE queries allowed
"""
if not self.wp_cli:
return {"success": False, "error": "WP-CLI not available"}
# Validate query (additional server-side validation)
query_upper = query.strip().upper()
allowed_starts = ("SELECT", "SHOW", "DESCRIBE", "DESC", "EXPLAIN")
if not any(query_upper.startswith(cmd) for cmd in allowed_starts):
return {
"success": False,
"error": "Only SELECT, SHOW, DESCRIBE, and EXPLAIN queries allowed",
}
forbidden = [
"INSERT",
"UPDATE",
"DELETE",
"DROP",
"ALTER",
"CREATE",
"TRUNCATE",
"REPLACE",
"GRANT",
"REVOKE",
]
for keyword in forbidden:
if keyword in query_upper:
return {"success": False, "error": f"Forbidden keyword in query: {keyword}"}
try:
# Add LIMIT if not present
if "LIMIT" not in query_upper:
query = f"{query.rstrip(';')} LIMIT {max_rows}"
# Execute query with --skip-column-names for MariaDB compatibility
# First, get column names separately if it's a SELECT
results = []
if query_upper.startswith("SELECT"):
# For SELECT queries, we need to parse the tab-separated output
cmd = f'db query "{query}" --skip-column-names'
result = await self.wp_cli.execute_command(cmd)
# Get output
output = result.get("output", "").strip()
if output:
# Parse tab-separated values
lines = output.split("\n")
# For simple queries, try to detect column structure
# We'll return as a list of dictionaries with column indices
for idx, line in enumerate(lines):
values = line.split("\t")
# Create a row dict with column indices
row = {f"col_{i}": val for i, val in enumerate(values)}
results.append(row)
# Limit results
if idx >= max_rows - 1:
break
else:
# For SHOW, DESCRIBE, etc., just return raw output
cmd = f'db query "{query}"'
result = await self.wp_cli.execute_command(cmd)
output = result.get("output", "").strip()
# Return as formatted message
return {
"success": True,
"output": output,
"query": query,
"note": "Results displayed as plain text",
}
return {
"success": True,
"results": results,
"row_count": len(results),
"query": query,
"note": "Columns are numbered as col_0, col_1, etc. due to MariaDB compatibility mode.",
}
except Exception as e:
self.logger.error(f"Database query failed: {str(e)}")
return {"success": False, "error": str(e)}
async def wp_db_repair(self, tables: list[str] | None = None) -> dict[str, Any]:
"""Repair corrupted database tables"""
if not self.wp_cli:
return {"success": False, "error": "WP-CLI not available"}
try:
# Get list of tables to repair
if not tables:
# Get all WordPress tables
table_list = await self.wp_db_tables(prefix_only=True)
if not table_list.get("success"):
return table_list
tables = [t["name"] for t in table_list.get("tables", [])]
# Repair each table
results = []
for table in tables:
try:
query = f"REPAIR TABLE {table}"
cmd = f'db query "{query}" --format=json'
result = await self.wp_cli.execute_command(cmd)
repair_result = json.loads(result.get("output", "[]"))
results.append(
{
"table": table,
"status": "Repaired" if repair_result else "OK",
"message": str(repair_result),
}
)
except Exception as e:
results.append({"table": table, "status": "Failed", "message": str(e)})
# Count successes/failures
success_count = sum(1 for r in results if r["status"] != "Failed")
failed_count = len(results) - success_count
return {
"success": True,
"total_tables": len(results),
"success_count": success_count,
"failed_count": failed_count,
"results": results,
}
except Exception as e:
self.logger.error(f"Database repair failed: {str(e)}")
return {"success": False, "error": str(e)}

View File

@@ -1,582 +0,0 @@
"""
System Operations Handler
Manages WordPress system operations including:
- System information (PHP, MySQL, WordPress versions)
- Disk usage statistics
- Cron job management
- Cache operations
- Error log retrieval
Most operations require WP-CLI for advanced functionality.
"""
import json
import re
from typing import Any
from plugins.wordpress.client import WordPressClient
from plugins.wordpress.wp_cli import WPCLIManager
def get_tool_specifications() -> list[dict[str, Any]]:
"""Return tool specifications for ToolGenerator"""
return [
# System Info
{
"name": "system_info",
"method_name": "system_info",
"description": "Get comprehensive system information including PHP version, MySQL version, WordPress version, server info, memory limits, and more.",
"schema": {"type": "object", "properties": {}},
"scope": "read",
},
# PHP Info
{
"name": "system_phpinfo",
"method_name": "system_phpinfo",
"description": "Get detailed PHP configuration including loaded extensions, ini settings, and disabled functions.",
"schema": {"type": "object", "properties": {}},
"scope": "read",
},
# Disk Usage
{
"name": "system_disk_usage",
"method_name": "system_disk_usage",
"description": "Get disk usage statistics including uploads size, plugins size, themes size, and database size.",
"schema": {"type": "object", "properties": {}},
"scope": "read",
},
# Clear All Caches
{
"name": "system_clear_all_caches",
"method_name": "system_clear_all_caches",
"description": "Clear all caches including object cache, transients, and opcache (if available). Safe to run anytime.",
"schema": {"type": "object", "properties": {}},
"scope": "write",
},
# Cron List
{
"name": "cron_list",
"method_name": "cron_list",
"description": "List all scheduled WordPress cron jobs with schedule, next run time, and arguments.",
"schema": {"type": "object", "properties": {}},
"scope": "read",
},
# Cron Run
{
"name": "cron_run",
"method_name": "cron_run",
"description": "Manually trigger a specific cron job by hook name. Useful for testing or forcing scheduled tasks.",
"schema": {
"type": "object",
"properties": {
"hook": {"type": "string", "description": "Cron hook name to execute"},
"args": {
"type": "array",
"items": {},
"default": [],
"description": "Optional arguments to pass to the hook",
},
},
"required": ["hook"],
},
"scope": "write",
},
# Error Log
{
"name": "error_log",
"method_name": "error_log",
"description": "Get recent PHP error log entries. Useful for debugging issues. Admin scope recommended for security.",
"schema": {
"type": "object",
"properties": {
"lines": {
"type": "integer",
"default": 100,
"minimum": 1,
"maximum": 1000,
"description": "Number of log lines to retrieve",
},
"filter": {
"type": "string",
"description": "Filter logs by keyword (case-insensitive)",
},
"level": {
"type": "string",
"enum": ["error", "warning", "notice", "fatal"],
"description": "Filter by error level",
},
},
},
"scope": "read",
},
]
class SystemHandler:
"""Handles WordPress system operations"""
def __init__(self, client: WordPressClient, wp_cli: WPCLIManager | None = None):
"""
Initialize System Handler
Args:
client: WordPress REST API client
wp_cli: WP-CLI manager (optional, for advanced operations)
"""
self.client = client
self.wp_cli = wp_cli
self.logger = client.logger
async def wp_cli_version(self) -> dict[str, Any]:
"""Get WP-CLI version information"""
if not self.wp_cli:
return {"success": False, "version": None, "error": "WP-CLI not available"}
try:
result = await self.wp_cli.execute_command("cli version")
version_output = result.get("output", "").strip()
# Parse version (e.g., "WP-CLI 2.10.0")
version = (
version_output.replace("WP-CLI ", "").strip()
if "WP-CLI" in version_output
else version_output
)
return {"success": True, "version": version, "full_output": version_output}
except Exception as e:
self.logger.error(f"WP-CLI version check failed: {str(e)}")
return {"success": False, "version": None, "error": str(e)}
async def system_info(self) -> dict[str, Any]:
"""Get comprehensive system information"""
if not self.wp_cli:
return {
"success": False,
"error": "WP-CLI not available. Container name not configured.",
}
try:
# Get various system info using WP-CLI
info_commands = {
"php_version": "eval 'echo PHP_VERSION;'",
"wordpress_version": "core version",
"site_url": "option get siteurl",
"active_theme": "theme list --status=active --field=name",
"plugin_count": "plugin list --status=active --format=count",
"wp_debug": "config get WP_DEBUG",
"wp_debug_log": "config get WP_DEBUG_LOG",
"multisite": "config get MULTISITE",
}
results = {}
for key, cmd in info_commands.items():
try:
result = await self.wp_cli.execute_command(cmd)
results[key] = result.get("output", "").strip()
except Exception as e:
self.logger.warning(f"Failed to get {key}: {str(e)}")
results[key] = "N/A"
# Get PHP settings
php_settings_cmd = """eval 'echo json_encode([
"memory_limit" => ini_get("memory_limit"),
"max_execution_time" => ini_get("max_execution_time"),
"upload_max_filesize" => ini_get("upload_max_filesize"),
"post_max_size" => ini_get("post_max_size"),
"max_input_vars" => ini_get("max_input_vars")
]);'"""
php_result = await self.wp_cli.execute_command(php_settings_cmd)
php_settings = json.loads(php_result.get("output", "{}"))
# Get MySQL version
mysql_cmd = 'db query "SELECT VERSION()" --skip-column-names'
mysql_result = await self.wp_cli.execute_command(mysql_cmd)
mysql_version = mysql_result.get("output", "N/A").strip()
# Get server software from environment
server_cmd = 'eval \'echo $_SERVER["SERVER_SOFTWARE"] ?? "Unknown";\''
server_result = await self.wp_cli.execute_command(server_cmd)
server_software = server_result.get("output", "Unknown").strip()
# Get loaded PHP extensions
ext_cmd = "eval 'echo json_encode(get_loaded_extensions());'"
ext_result = await self.wp_cli.execute_command(ext_cmd)
php_extensions = json.loads(ext_result.get("output", "[]"))
return {
"success": True,
"php_version": results.get("php_version", "N/A"),
"mysql_version": mysql_version,
"wordpress_version": results.get("wordpress_version", "N/A"),
"server_software": server_software,
"memory_limit": php_settings.get("memory_limit", "N/A"),
"max_execution_time": int(php_settings.get("max_execution_time", 0)),
"upload_max_filesize": php_settings.get("upload_max_filesize", "N/A"),
"post_max_size": php_settings.get("post_max_size", "N/A"),
"max_input_vars": int(php_settings.get("max_input_vars", 0)),
"php_extensions": php_extensions,
"wp_debug": results.get("wp_debug", "false") == "true",
"wp_debug_log": results.get("wp_debug_log", "false") == "true",
"multisite": results.get("multisite", "false") == "true",
"active_plugins": int(results.get("plugin_count", 0)),
"active_theme": results.get("active_theme", "N/A"),
"site_url": results.get("site_url", "N/A"),
}
except Exception as e:
self.logger.error(f"System info failed: {str(e)}")
return {"success": False, "error": str(e)}
async def system_phpinfo(self) -> dict[str, Any]:
"""Get detailed PHP configuration"""
if not self.wp_cli:
return {"success": False, "error": "WP-CLI not available"}
try:
# Get PHP version and SAPI
version_cmd = "eval 'echo PHP_VERSION;'"
version_result = await self.wp_cli.execute_command(version_cmd)
php_version = version_result.get("output", "").strip()
sapi_cmd = "eval 'echo PHP_SAPI;'"
sapi_result = await self.wp_cli.execute_command(sapi_cmd)
php_sapi = sapi_result.get("output", "").strip()
# Get loaded extensions
ext_cmd = "eval 'echo json_encode(get_loaded_extensions());'"
ext_result = await self.wp_cli.execute_command(ext_cmd)
extensions = json.loads(ext_result.get("output", "[]"))
# Get important ini settings
ini_settings_cmd = """eval 'echo json_encode([
"display_errors" => ini_get("display_errors"),
"error_reporting" => ini_get("error_reporting"),
"log_errors" => ini_get("log_errors"),
"error_log" => ini_get("error_log"),
"memory_limit" => ini_get("memory_limit"),
"max_execution_time" => ini_get("max_execution_time"),
"upload_max_filesize" => ini_get("upload_max_filesize"),
"post_max_size" => ini_get("post_max_size"),
"max_input_vars" => ini_get("max_input_vars"),
"max_input_time" => ini_get("max_input_time"),
"default_socket_timeout" => ini_get("default_socket_timeout"),
"allow_url_fopen" => ini_get("allow_url_fopen"),
"session.save_handler" => ini_get("session.save_handler")
]);'"""
ini_result = await self.wp_cli.execute_command(ini_settings_cmd)
ini_settings = json.loads(ini_result.get("output", "{}"))
# Get disabled functions
disabled_cmd = "eval 'echo ini_get(\"disable_functions\");'"
disabled_result = await self.wp_cli.execute_command(disabled_cmd)
disabled_raw = disabled_result.get("output", "").strip()
disabled_functions = [f.strip() for f in disabled_raw.split(",") if f.strip()]
return {
"success": True,
"version": php_version,
"sapi": php_sapi,
"extensions": extensions,
"ini_settings": ini_settings,
"disabled_functions": disabled_functions,
}
except Exception as e:
self.logger.error(f"PHP info failed: {str(e)}")
return {"success": False, "error": str(e)}
async def system_disk_usage(self) -> dict[str, Any]:
"""Get disk usage statistics"""
if not self.wp_cli:
return {"success": False, "error": "WP-CLI not available"}
try:
# Get WordPress root path
path_cmd = "eval 'echo ABSPATH;'"
path_result = await self.wp_cli.execute_command(path_cmd)
wp_path = path_result.get("output", "").strip()
# Get directory sizes using du command
sizes = {}
# Uploads directory
uploads_cmd = f"eval 'echo shell_exec(\"du -sm {wp_path}wp-content/uploads 2>/dev/null | cut -f1\");'"
try:
uploads_result = await self.wp_cli.execute_command(uploads_cmd)
upload_size = uploads_result.get("output", "0").strip()
sizes["uploads_size_mb"] = (
float(upload_size) if upload_size and upload_size != "" else 0.0
)
except Exception as e:
self.logger.warning(f"Failed to get uploads size: {e}")
sizes["uploads_size_mb"] = 0.0
# Plugins directory
plugins_cmd = f"eval 'echo shell_exec(\"du -sm {wp_path}wp-content/plugins 2>/dev/null | cut -f1\");'"
try:
plugins_result = await self.wp_cli.execute_command(plugins_cmd)
plugins_size = plugins_result.get("output", "0").strip()
sizes["plugins_size_mb"] = (
float(plugins_size) if plugins_size and plugins_size != "" else 0.0
)
except Exception as e:
self.logger.warning(f"Failed to get plugins size: {e}")
sizes["plugins_size_mb"] = 0.0
# Themes directory
themes_cmd = f"eval 'echo shell_exec(\"du -sm {wp_path}wp-content/themes 2>/dev/null | cut -f1\");'"
try:
themes_result = await self.wp_cli.execute_command(themes_cmd)
themes_size = themes_result.get("output", "0").strip()
sizes["themes_size_mb"] = (
float(themes_size) if themes_size and themes_size != "" else 0.0
)
except Exception as e:
self.logger.warning(f"Failed to get themes size: {e}")
sizes["themes_size_mb"] = 0.0
# Total WordPress directory
total_cmd = f"eval 'echo shell_exec(\"du -sm {wp_path} 2>/dev/null | cut -f1\");'"
try:
total_result = await self.wp_cli.execute_command(total_cmd)
total_size = total_result.get("output", "0").strip()
sizes["wordpress_size_mb"] = (
float(total_size) if total_size and total_size != "" else 0.0
)
except Exception as e:
self.logger.warning(f"Failed to get wordpress total size: {e}")
sizes["wordpress_size_mb"] = 0.0
# Database size (from our database handler logic)
db_query = """
SELECT ROUND(SUM(data_length + index_length) / 1024 / 1024, 2) AS size_mb
FROM information_schema.TABLES
WHERE table_schema = DATABASE()
"""
db_cmd = f'db query "{db_query}" --skip-column-names'
try:
db_result = await self.wp_cli.execute_command(db_cmd)
sizes["database_size_mb"] = float(db_result.get("output", "0").strip())
except (ValueError, KeyError, Exception):
sizes["database_size_mb"] = 0.0
# Calculate total
total_size = sum([sizes["wordpress_size_mb"], sizes["database_size_mb"]])
return {
"success": True,
"total_size_mb": round(total_size, 2),
**sizes,
"breakdown": {
"uploads": sizes["uploads_size_mb"],
"plugins": sizes["plugins_size_mb"],
"themes": sizes["themes_size_mb"],
"database": sizes["database_size_mb"],
},
}
except Exception as e:
self.logger.error(f"Disk usage check failed: {str(e)}")
return {"success": False, "error": str(e)}
async def system_clear_all_caches(self) -> dict[str, Any]:
"""Clear all caches"""
if not self.wp_cli:
return {"success": False, "error": "WP-CLI not available"}
try:
cleared = []
# Clear object cache
try:
await self.wp_cli.execute_command("cache flush")
cleared.append("object_cache")
except Exception as e:
self.logger.warning(f"Object cache flush failed: {str(e)}")
# Clear transients
try:
await self.wp_cli.execute_command("transient delete --all")
cleared.append("transients")
except Exception as e:
self.logger.warning(f"Transient delete failed: {str(e)}")
# Clear OPcache if available
try:
opcache_cmd = 'eval \'if (function_exists("opcache_reset")) { opcache_reset(); echo "cleared"; }\''
opcache_result = await self.wp_cli.execute_command(opcache_cmd)
if "cleared" in opcache_result.get("output", ""):
cleared.append("opcache")
except Exception as e:
self.logger.warning(f"OPcache clear failed: {str(e)}")
return {
"success": True,
"message": f"Cleared {len(cleared)} cache types",
"cleared": cleared,
}
except Exception as e:
self.logger.error(f"Clear all caches failed: {str(e)}")
return {"success": False, "error": str(e)}
async def cron_list(self) -> dict[str, Any]:
"""List all WordPress cron jobs"""
if not self.wp_cli:
return {"success": False, "error": "WP-CLI not available"}
try:
# Get cron events
cmd = "cron event list --format=json"
result = await self.wp_cli.execute_command(cmd)
events_data = json.loads(result.get("output", "[]"))
# Parse events
events = []
for event in events_data:
events.append(
{
"hook": event.get("hook", ""),
"timestamp": int(event.get("time", 0)),
"schedule": event.get("recurrence", "single"),
"interval": event.get("interval"),
"args": event.get("args", []),
}
)
# Get available schedules
schedules_cmd = "cron schedule list --format=json"
try:
schedules_result = await self.wp_cli.execute_command(schedules_cmd)
schedules_data = json.loads(schedules_result.get("output", "[]"))
schedules = {s.get("name"): s for s in schedules_data}
except (json.JSONDecodeError, KeyError, Exception):
schedules = {}
return {"success": True, "events": events, "total": len(events), "schedules": schedules}
except Exception as e:
self.logger.error(f"Cron list failed: {str(e)}")
return {"success": False, "error": str(e)}
async def cron_run(self, hook: str, args: list[Any] | None = None) -> dict[str, Any]:
"""Manually run a cron job"""
if not self.wp_cli:
return {"success": False, "error": "WP-CLI not available"}
try:
# Build command
cmd = f"cron event run {hook}"
# Execute cron job
result = await self.wp_cli.execute_command(cmd)
return {
"success": True,
"message": f"Cron job '{hook}' executed",
"hook": hook,
"output": result.get("output", ""),
}
except Exception as e:
self.logger.error(f"Cron run failed: {str(e)}")
return {"success": False, "error": str(e)}
async def error_log(
self, lines: int = 100, filter: str | None = None, level: str | None = None
) -> dict[str, Any]:
"""Get PHP error log entries"""
if not self.wp_cli:
return {"success": False, "error": "WP-CLI not available"}
try:
# Get error log path
log_path_cmd = "eval 'echo ini_get(\"error_log\");'"
log_path_result = await self.wp_cli.execute_command(log_path_cmd)
log_path = log_path_result.get("output", "").strip()
if not log_path or log_path == "":
# Try WordPress debug log
wp_path_cmd = "eval 'echo WP_CONTENT_DIR;'"
wp_path_result = await self.wp_cli.execute_command(wp_path_cmd)
wp_content = wp_path_result.get("output", "").strip()
log_path = f"{wp_content}/debug.log"
# Get log file size
size_cmd = f"eval 'echo filesize(\"{log_path}\") ?? 0;'"
try:
size_result = await self.wp_cli.execute_command(size_cmd)
log_size_bytes = int(size_result.get("output", "0").strip())
log_size_mb = round(log_size_bytes / 1024 / 1024, 2)
except (ValueError, KeyError, Exception):
log_size_mb = 0.0
# Read log file (last N lines)
tail_cmd = f"eval 'exec(\"tail -n {lines} {log_path} 2>&1\");'"
log_result = await self.wp_cli.execute_command(tail_cmd)
log_lines = log_result.get("output", "").strip().split("\n")
# Parse log entries
entries = []
for line in log_lines:
if not line.strip():
continue
# Basic parsing (PHP error log format varies)
# Format: [timestamp] PHP Error_Type: message in file on line X
match = re.match(r"\[([^\]]+)\]\s+PHP\s+(\w+):\s+(.+)", line)
if match:
timestamp, error_type, message = match.groups()
# Extract file and line if present
file_match = re.search(r"in\s+(.+)\s+on\s+line\s+(\d+)", message)
file_path = file_match.group(1) if file_match else None
line_num = int(file_match.group(2)) if file_match else None
entry = {
"timestamp": timestamp,
"level": error_type.lower(),
"message": message,
"file": file_path,
"line": line_num,
}
# Apply filters
if level and entry["level"] != level.lower():
continue
if filter and filter.lower() not in message.lower():
continue
entries.append(entry)
else:
# Unparsed line - include as-is
entries.append(
{
"timestamp": "N/A",
"level": "unknown",
"message": line,
"file": None,
"line": None,
}
)
return {
"success": True,
"entries": entries,
"total_lines": len(log_lines),
"filtered_lines": len(entries),
"log_size_mb": log_size_mb,
"log_path": log_path,
}
except Exception as e:
self.logger.error(f"Error log retrieval failed: {str(e)}")
return {"success": False, "error": str(e)}

View File

@@ -1,291 +0,0 @@
"""
WordPress Advanced Plugin - Clean Architecture
Advanced WordPress management features requiring elevated permissions.
Provides database operations, bulk operations, and system management.
This plugin is separated from core WordPress plugin for:
- Better security (separate API keys for advanced features)
- Better tool visibility (users see only features they need)
- Granular access control
"""
import json
from typing import Any
from plugins.base import BasePlugin
from plugins.wordpress.client import WordPressClient
from plugins.wordpress_advanced import handlers
class WordPressAdvancedPlugin(BasePlugin):
"""
WordPress Advanced plugin - separated for security and visibility.
Provides advanced WordPress management capabilities:
- Database operations (export, import, search, query, repair)
- Bulk operations (batch updates/deletes for posts, products, media)
- System operations (system info, cache, cron, error logs)
Requires:
- WordPress site with REST API
- WP-CLI access (for database and system operations)
- Docker container name (for WP-CLI execution)
"""
@staticmethod
def get_plugin_name() -> str:
"""Return plugin type identifier"""
return "wordpress_advanced"
@staticmethod
def get_required_config_keys() -> list[str]:
"""Return required configuration keys"""
return ["url", "username", "app_password", "container"]
def __init__(self, config: dict[str, Any], project_id: str | None = None):
"""
Initialize WordPress Advanced plugin with handlers.
Args:
config: Configuration dictionary containing:
- url: WordPress site URL
- username: WordPress username
- app_password: WordPress application password
- container: Docker container name for WP-CLI (REQUIRED)
project_id: Optional project ID (auto-generated if not provided)
"""
super().__init__(config, project_id=project_id)
# Create WordPress API client
self.client = WordPressClient(
site_url=config["url"], username=config["username"], app_password=config["app_password"]
)
# WP-CLI is REQUIRED for wordpress_advanced
container_name = config.get("container")
if not container_name:
raise ValueError(
"WordPress Advanced plugin requires 'container' configuration. "
"Please set the 'container' field when adding the site in the dashboard."
)
# Import WP-CLI manager
from plugins.wordpress.wp_cli import WPCLIManager
wp_cli_manager = WPCLIManager(container_name)
# Initialize handlers (all require WP-CLI or advanced REST API)
self.database = handlers.DatabaseHandler(self.client, wp_cli_manager)
self.bulk = handlers.BulkHandler(self.client)
self.system = handlers.SystemHandler(self.client, wp_cli_manager)
@staticmethod
def get_tool_specifications() -> list[dict[str, Any]]:
"""
Return all tool specifications for ToolGenerator.
This method is called by ToolGenerator to create unified tools
with site parameter routing.
Returns:
List of tool specification dictionaries (22 tools total)
"""
specs = []
# Database operations (7 tools)
specs.extend(handlers.get_database_specs())
# Bulk operations (8 tools)
specs.extend(handlers.get_bulk_specs())
# System operations (7 tools)
specs.extend(handlers.get_system_specs())
return specs
async def health_check(self) -> dict[str, Any]:
"""
Check WordPress Advanced features availability.
Returns:
Dict with health status and WP-CLI availability
"""
try:
# Test REST API access by hitting /wp-json/ directly
# NOTE: self.client.get("/") hits /wp-json/wp/v2/ which doesn't
# return a "name" field. We need /wp-json/ for the site index.
rest_api_available = False
try:
import aiohttp
site_url = self.client.site_url
async with aiohttp.ClientSession() as session:
async with session.get(
f"{site_url}/wp-json/",
timeout=aiohttp.ClientTimeout(total=10),
ssl=False,
) as resp:
if resp.status == 200:
data = await resp.json()
rest_api_available = bool(data.get("name"))
except Exception as e:
self.logger.warning(f"REST API check failed: {e}")
# Test authentication with an authenticated request
auth_valid = False
if rest_api_available:
try:
await self.client.get("users/me")
auth_valid = True
except Exception:
pass
# Test WP-CLI access (optional — only needed for database/system tools)
wp_cli_available = False
try:
wp_cli_version = await self.system.wp_cli_version()
wp_cli_available = bool(wp_cli_version.get("version"))
except Exception:
pass
result = {
"healthy": rest_api_available,
"wp_cli_available": wp_cli_available,
"rest_api_available": rest_api_available,
"auth_valid": auth_valid,
"features": {
"database_operations": wp_cli_available,
"bulk_operations": rest_api_available,
"system_operations": wp_cli_available,
},
}
if not auth_valid and rest_api_available:
result["auth_warning"] = "Site accessible but credentials may be invalid"
return result
except Exception as e:
return {
"healthy": False,
"error": str(e),
"wp_cli_available": False,
"rest_api_available": False,
}
# ========================================
# Method Delegation to Handlers
# ========================================
# All methods delegate to appropriate handlers
# This maintains backward compatibility with existing code
# === Database Operations (7 tools) ===
async def wp_db_export(self, **kwargs):
"""Export WordPress database"""
result = await self.database.wp_db_export(**kwargs)
return json.dumps(result, indent=2) if isinstance(result, dict) else result
async def wp_db_import(self, **kwargs):
"""Import WordPress database"""
result = await self.database.wp_db_import(**kwargs)
return json.dumps(result, indent=2) if isinstance(result, dict) else result
async def wp_db_size(self, **kwargs):
"""Get database size"""
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):
"""List database tables"""
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):
"""Search database"""
result = await self.database.wp_db_search(**kwargs)
return json.dumps(result, indent=2) if isinstance(result, dict) else result
async def wp_db_query(self, **kwargs):
"""Execute read-only database query"""
result = await self.database.wp_db_query(**kwargs)
return json.dumps(result, indent=2) if isinstance(result, dict) else result
async def wp_db_repair(self, **kwargs):
"""Repair and optimize database"""
result = await self.database.wp_db_repair(**kwargs)
return json.dumps(result, indent=2) if isinstance(result, dict) else result
# === Bulk Operations (8 tools) ===
async def bulk_update_posts(self, **kwargs):
"""Bulk update posts"""
result = await self.bulk.bulk_update_posts(**kwargs)
return json.dumps(result, indent=2) if isinstance(result, dict) else result
async def bulk_delete_posts(self, **kwargs):
"""Bulk delete posts"""
result = await self.bulk.bulk_delete_posts(**kwargs)
return json.dumps(result, indent=2) if isinstance(result, dict) else result
async def bulk_update_products(self, **kwargs):
"""Bulk update products"""
result = await self.bulk.bulk_update_products(**kwargs)
return json.dumps(result, indent=2) if isinstance(result, dict) else result
async def bulk_delete_products(self, **kwargs):
"""Bulk delete products"""
result = await self.bulk.bulk_delete_products(**kwargs)
return json.dumps(result, indent=2) if isinstance(result, dict) else result
async def bulk_delete_media(self, **kwargs):
"""Bulk delete media"""
result = await self.bulk.bulk_delete_media(**kwargs)
return json.dumps(result, indent=2) if isinstance(result, dict) else result
async def bulk_assign_categories(self, **kwargs):
"""Bulk assign categories to posts"""
result = await self.bulk.bulk_assign_categories(**kwargs)
return json.dumps(result, indent=2) if isinstance(result, dict) else result
async def bulk_assign_tags(self, **kwargs):
"""Bulk assign tags to posts"""
result = await self.bulk.bulk_assign_tags(**kwargs)
return json.dumps(result, indent=2) if isinstance(result, dict) else result
async def bulk_trash_posts(self, **kwargs):
"""Bulk move posts to trash"""
result = await self.bulk.bulk_trash_posts(**kwargs)
return json.dumps(result, indent=2) if isinstance(result, dict) else result
# === System Operations (7 tools) ===
async def system_info(self, **kwargs):
"""Get system information"""
result = await self.system.system_info(**kwargs)
return json.dumps(result, indent=2) if isinstance(result, dict) else result
async def system_phpinfo(self, **kwargs):
"""Get PHP information"""
result = await self.system.system_phpinfo(**kwargs)
return json.dumps(result, indent=2) if isinstance(result, dict) else result
async def system_disk_usage(self, **kwargs):
"""Get disk usage"""
result = await self.system.system_disk_usage(**kwargs)
return json.dumps(result, indent=2) if isinstance(result, dict) else result
async def system_clear_all_caches(self, **kwargs):
"""Clear all caches"""
result = await self.system.system_clear_all_caches(**kwargs)
return json.dumps(result, indent=2) if isinstance(result, dict) else result
async def cron_list(self, **kwargs):
"""List cron jobs"""
result = await self.system.cron_list(**kwargs)
return json.dumps(result, indent=2) if isinstance(result, dict) else result
async def cron_run(self, **kwargs):
"""Run cron job"""
result = await self.system.cron_run(**kwargs)
return json.dumps(result, indent=2) if isinstance(result, dict) else result
async def error_log(self, **kwargs):
"""Get error log"""
result = await self.system.error_log(**kwargs)
return json.dumps(result, indent=2) if isinstance(result, dict) else result

View File

@@ -1,34 +0,0 @@
"""
WordPress Advanced Pydantic Schemas
"""
from .bulk import *
from .database import *
from .system import *
__all__ = [
# Database schemas
"DatabaseExportRequest",
"DatabaseImportRequest",
"DatabaseSizeResponse",
"DatabaseTablesResponse",
"DatabaseSearchRequest",
"DatabaseQueryRequest",
"DatabaseRepairResponse",
# Bulk schemas
"BulkUpdatePostsRequest",
"BulkDeletePostsRequest",
"BulkUpdateProductsRequest",
"BulkDeleteProductsRequest",
"BulkDeleteMediaRequest",
"BulkAssignCategoriesRequest",
"BulkAssignTagsRequest",
"BulkOperationResponse",
# System schemas
"SystemInfoResponse",
"SystemPHPInfoResponse",
"SystemDiskUsageResponse",
"CronListResponse",
"CronRunRequest",
"ErrorLogResponse",
]

View File

@@ -1,259 +0,0 @@
"""
Bulk Operations Schemas
Pydantic models for WordPress bulk operations including:
- Bulk updates for posts/products
- Bulk deletions
- Bulk category/tag assignments
"""
from typing import Any
from pydantic import BaseModel, Field, field_validator
class BulkUpdatePostsParams(BaseModel):
"""Parameters for bulk updating posts"""
post_ids: list[int] = Field(
..., min_length=1, max_length=100, description="List of post IDs to update (max 100)"
)
updates: dict[str, Any] = Field(
..., description="Fields to update (status, author_id, categories, tags, etc.)"
)
@classmethod
@field_validator("post_ids")
def validate_post_ids(cls, v):
"""Ensure all IDs are positive"""
if any(id <= 0 for id in v):
raise ValueError("All post IDs must be positive integers")
return v
@classmethod
@field_validator("updates")
def validate_updates(cls, v):
"""Validate update fields are allowed"""
allowed_fields = {
"status",
"title",
"content",
"excerpt",
"author",
"categories",
"tags",
"featured_media",
"comment_status",
"ping_status",
"sticky",
"format",
"meta",
}
invalid_fields = set(v.keys()) - allowed_fields
if invalid_fields:
raise ValueError(f"Invalid update fields: {', '.join(invalid_fields)}")
return v
class BulkDeletePostsParams(BaseModel):
"""Parameters for bulk deleting posts"""
post_ids: list[int] = Field(
..., min_length=1, max_length=100, description="List of post IDs to delete (max 100)"
)
force: bool = Field(default=False, description="Force permanent deletion (bypass trash)")
@classmethod
@field_validator("post_ids")
def validate_post_ids(cls, v):
"""Ensure all IDs are positive"""
if any(id <= 0 for id in v):
raise ValueError("All post IDs must be positive integers")
return v
class BulkUpdateProductsParams(BaseModel):
"""Parameters for bulk updating WooCommerce products"""
product_ids: list[int] = Field(
..., min_length=1, max_length=100, description="List of product IDs to update (max 100)"
)
updates: dict[str, Any] = Field(
..., description="Fields to update (price, stock_quantity, status, etc.)"
)
@classmethod
@field_validator("product_ids")
def validate_product_ids(cls, v):
"""Ensure all IDs are positive"""
if any(id <= 0 for id in v):
raise ValueError("All product IDs must be positive integers")
return v
@classmethod
@field_validator("updates")
def validate_updates(cls, v):
"""Validate update fields are allowed"""
allowed_fields = {
"name",
"status",
"featured",
"catalog_visibility",
"description",
"short_description",
"sku",
"price",
"regular_price",
"sale_price",
"stock_quantity",
"stock_status",
"manage_stock",
"categories",
"tags",
"images",
"attributes",
"meta_data",
}
invalid_fields = set(v.keys()) - allowed_fields
if invalid_fields:
raise ValueError(f"Invalid update fields: {', '.join(invalid_fields)}")
return v
class BulkDeleteProductsParams(BaseModel):
"""Parameters for bulk deleting products"""
product_ids: list[int] = Field(
..., min_length=1, max_length=100, description="List of product IDs to delete (max 100)"
)
force: bool = Field(default=False, description="Force permanent deletion")
@classmethod
@field_validator("product_ids")
def validate_product_ids(cls, v):
"""Ensure all IDs are positive"""
if any(id <= 0 for id in v):
raise ValueError("All product IDs must be positive integers")
return v
class BulkAssignCategoriesParams(BaseModel):
"""Parameters for bulk assigning categories"""
item_ids: list[int] = Field(
..., min_length=1, max_length=100, description="List of post/product IDs (max 100)"
)
category_ids: list[int] = Field(..., min_length=1, description="List of category IDs to assign")
replace: bool = Field(
default=False, description="Replace existing categories (true) or append (false)"
)
item_type: str = Field(default="post", description="Type of items: 'post' or 'product'")
@classmethod
@field_validator("item_ids", "category_ids")
def validate_ids(cls, v):
"""Ensure all IDs are positive"""
if any(id <= 0 for id in v):
raise ValueError("All IDs must be positive integers")
return v
@classmethod
@field_validator("item_type")
def validate_item_type(cls, v):
"""Validate item type"""
if v not in ["post", "product"]:
raise ValueError("item_type must be 'post' or 'product'")
return v
class BulkAssignTagsParams(BaseModel):
"""Parameters for bulk assigning tags"""
item_ids: list[int] = Field(
..., min_length=1, max_length=100, description="List of post/product IDs (max 100)"
)
tag_ids: list[int] = Field(..., min_length=1, description="List of tag IDs to assign")
replace: bool = Field(
default=False, description="Replace existing tags (true) or append (false)"
)
item_type: str = Field(default="post", description="Type of items: 'post' or 'product'")
@classmethod
@field_validator("item_ids", "tag_ids")
def validate_ids(cls, v):
"""Ensure all IDs are positive"""
if any(id <= 0 for id in v):
raise ValueError("All IDs must be positive integers")
return v
@classmethod
@field_validator("item_type")
def validate_item_type(cls, v):
"""Validate item type"""
if v not in ["post", "product"]:
raise ValueError("item_type must be 'post' or 'product'")
return v
class BulkUpdateMediaParams(BaseModel):
"""Parameters for bulk updating media items"""
media_ids: list[int] = Field(
..., min_length=1, max_length=100, description="List of media IDs to update (max 100)"
)
updates: dict[str, Any] = Field(
..., description="Fields to update (alt_text, title, caption, description)"
)
@classmethod
@field_validator("media_ids")
def validate_media_ids(cls, v):
"""Ensure all IDs are positive"""
if any(id <= 0 for id in v):
raise ValueError("All media IDs must be positive integers")
return v
@classmethod
@field_validator("updates")
def validate_updates(cls, v):
"""Validate update fields are allowed"""
allowed_fields = {"title", "alt_text", "caption", "description", "meta"}
invalid_fields = set(v.keys()) - allowed_fields
if invalid_fields:
raise ValueError(f"Invalid update fields: {', '.join(invalid_fields)}")
return v
class BulkDeleteMediaParams(BaseModel):
"""Parameters for bulk deleting media items"""
media_ids: list[int] = Field(
..., min_length=1, max_length=100, description="List of media IDs to delete (max 100)"
)
force: bool = Field(
default=True, description="Force permanent deletion (media can't be trashed)"
)
@classmethod
@field_validator("media_ids")
def validate_media_ids(cls, v):
"""Ensure all IDs are positive"""
if any(id <= 0 for id in v):
raise ValueError("All media IDs must be positive integers")
return v
class BulkOperationResult(BaseModel):
"""Result of a bulk operation"""
success_count: int = Field(description="Number of successful operations")
failed_count: int = Field(description="Number of failed operations")
total: int = Field(description="Total items processed")
failed_ids: list[int] = Field(default=[], description="IDs that failed to process")
errors: list[dict[str, Any]] = Field(default=[], description="Detailed error information")

View File

@@ -1,183 +0,0 @@
"""
Database Operations Schemas
Pydantic models for WordPress database operations including:
- Database export/import
- Backup/restore
- Optimization and repair
- Search and query operations
"""
from typing import Any
from pydantic import BaseModel, Field, field_validator
class DatabaseExportParams(BaseModel):
"""Parameters for database export"""
tables: list[str] | None = Field(
default=None, description="Specific tables to export (default: all tables)"
)
exclude_tables: list[str] | None = Field(
default=None, description="Tables to exclude from export"
)
add_drop_table: bool = Field(default=True, description="Include DROP TABLE statements")
@classmethod
@field_validator("tables", "exclude_tables")
def validate_table_names(cls, v):
"""Validate table names - no special characters"""
if v:
for table in v:
if not table.replace("_", "").isalnum():
raise ValueError(f"Invalid table name: {table}")
return v
class DatabaseImportParams(BaseModel):
"""Parameters for database import"""
file_path: str | None = Field(default=None, description="Path to SQL file on server")
url: str | None = Field(default=None, description="URL to download SQL file from")
skip_optimization: bool = Field(
default=False, description="Skip database optimization after import"
)
@classmethod
@field_validator("url")
def validate_url(cls, v):
"""Validate URL format"""
if v and not v.startswith(("http://", "https://")):
raise ValueError("URL must start with http:// or https://")
return v
class DatabaseBackupParams(BaseModel):
"""Parameters for creating database backup"""
description: str | None = Field(
default=None, max_length=255, description="Optional description for this backup"
)
compress: bool = Field(default=True, description="Compress backup with gzip")
include_uploads: bool = Field(
default=False, description="Also backup wp-content/uploads directory"
)
class DatabaseRestoreParams(BaseModel):
"""Parameters for restoring database"""
backup_id: str | None = Field(default=None, description="Backup ID to restore")
timestamp: str | None = Field(
default=None, description="Backup timestamp (alternative to backup_id)"
)
confirm: bool = Field(default=False, description="Confirmation required (safety check)")
@classmethod
@field_validator("confirm")
def validate_confirmation(cls, v):
"""Require explicit confirmation for restore"""
if not v:
raise ValueError("Confirmation required: set confirm=true")
return v
class DatabaseSearchParams(BaseModel):
"""Parameters for searching database"""
search_string: str = Field(
..., min_length=1, max_length=255, description="String to search for in database"
)
tables: list[str] | None = Field(
default=None, description="Specific tables to search (default: all tables)"
)
regex: bool = Field(default=False, description="Use regex pattern matching")
case_sensitive: bool = Field(default=False, description="Case-sensitive search")
max_results: int = Field(default=100, ge=1, le=1000, description="Maximum results to return")
class DatabaseQueryParams(BaseModel):
"""Parameters for executing SQL query"""
query: str = Field(
..., min_length=1, max_length=10000, description="SQL query to execute (SELECT only)"
)
max_rows: int = Field(default=1000, ge=1, le=10000, description="Maximum rows to return")
@classmethod
@field_validator("query")
def validate_query(cls, v):
"""
Validate query is safe (read-only SELECT)
Security: Only allow SELECT, SHOW, DESCRIBE statements
Prevent: INSERT, UPDATE, DELETE, DROP, ALTER, CREATE, etc.
"""
query_upper = v.strip().upper()
# Allowed statement types
allowed_starts = ("SELECT", "SHOW", "DESCRIBE", "DESC", "EXPLAIN")
if not any(query_upper.startswith(cmd) for cmd in allowed_starts):
raise ValueError("Only SELECT, SHOW, DESCRIBE, and EXPLAIN queries allowed")
# Forbidden keywords (prevent nested destructive queries)
forbidden = [
"INSERT",
"UPDATE",
"DELETE",
"DROP",
"ALTER",
"CREATE",
"TRUNCATE",
"REPLACE",
"GRANT",
"REVOKE",
]
for keyword in forbidden:
if keyword in query_upper:
raise ValueError(f"Forbidden keyword in query: {keyword}")
return v
class DatabaseSizeResponse(BaseModel):
"""Response model for database size information"""
total_size_mb: float = Field(description="Total database size in MB")
tables: list[dict[str, Any]] = Field(description="List of tables with size information")
row_count: int = Field(description="Total row count across all tables")
class DatabaseTableInfo(BaseModel):
"""Information about a database table"""
name: str
engine: str
rows: int
data_size_mb: float
index_size_mb: float
total_size_mb: float
collation: str
class DatabaseRepairResult(BaseModel):
"""Result of database repair operation"""
table: str
status: str # 'OK', 'Repaired', 'Failed'
message: str | None = None
class DatabaseBackupInfo(BaseModel):
"""Information about a database backup"""
backup_id: str
timestamp: str
size_mb: float
compressed: bool
description: str | None = None
location: str
tables_count: int

View File

@@ -1,150 +0,0 @@
"""
System Operations Schemas
Pydantic models for WordPress system operations including:
- System information
- Disk usage
- Cron management
- Cache operations
- Error logs
"""
from typing import Any
from pydantic import BaseModel, Field, field_validator
class SystemInfoResponse(BaseModel):
"""Comprehensive system information"""
php_version: str = Field(description="PHP version")
mysql_version: str = Field(description="MySQL/MariaDB version")
wordpress_version: str = Field(description="WordPress core version")
server_software: str = Field(description="Web server software")
memory_limit: str = Field(description="PHP memory limit")
max_execution_time: int = Field(description="Max execution time in seconds")
upload_max_filesize: str = Field(description="Maximum upload file size")
post_max_size: str = Field(description="Maximum POST size")
max_input_vars: int = Field(description="Maximum input variables")
php_extensions: list[str] = Field(default=[], description="Loaded PHP extensions")
wp_debug: bool = Field(description="WP_DEBUG constant status")
wp_debug_log: bool = Field(description="WP_DEBUG_LOG constant status")
multisite: bool = Field(description="WordPress Multisite enabled")
active_plugins: int = Field(description="Number of active plugins")
active_theme: str = Field(description="Active theme name")
class PHPInfoResponse(BaseModel):
"""PHP configuration details"""
version: str
sapi: str = Field(description="Server API (e.g., fpm-fcgi, apache2handler)")
extensions: list[str] = Field(description="Loaded PHP extensions")
ini_settings: dict[str, str] = Field(description="Important php.ini settings")
disabled_functions: list[str] = Field(default=[], description="Disabled PHP functions")
class DiskUsageResponse(BaseModel):
"""Disk usage statistics"""
total_size_mb: float = Field(description="Total disk usage in MB")
wordpress_size_mb: float = Field(description="WordPress installation size")
uploads_size_mb: float = Field(description="wp-content/uploads size")
plugins_size_mb: float = Field(description="wp-content/plugins size")
themes_size_mb: float = Field(description="wp-content/themes size")
database_size_mb: float = Field(description="Database size")
available_space_mb: float | None = Field(
default=None, description="Available disk space (if accessible)"
)
breakdown: dict[str, float] = Field(default={}, description="Detailed breakdown by directory")
class CronEvent(BaseModel):
"""WordPress cron event information"""
hook: str = Field(description="Cron hook name")
timestamp: int = Field(description="Unix timestamp of next run")
schedule: str = Field(description="Schedule type (hourly, daily, etc.)")
interval: int | None = Field(
default=None, description="Interval in seconds for recurring events"
)
args: list[Any] = Field(default=[], description="Arguments passed to the hook")
class CronListResponse(BaseModel):
"""List of all cron events"""
events: list[CronEvent] = Field(description="List of cron events")
total: int = Field(description="Total number of events")
schedules: dict[str, dict[str, Any]] = Field(default={}, description="Available cron schedules")
class CronRunParams(BaseModel):
"""Parameters for manually running a cron job"""
hook: str = Field(..., min_length=1, max_length=255, description="Cron hook name to execute")
args: list[Any] = Field(default=[], description="Optional arguments to pass to the hook")
@classmethod
@field_validator("hook")
def validate_hook(cls, v):
"""Validate hook name format"""
# Hook names should be alphanumeric with underscores, hyphens
if not v.replace("_", "").replace("-", "").replace(".", "").isalnum():
raise ValueError(
"Hook name can only contain letters, numbers, underscores, " "hyphens, and dots"
)
return v
class ErrorLogParams(BaseModel):
"""Parameters for retrieving error log"""
lines: int = Field(
default=100, ge=1, le=1000, description="Number of log lines to retrieve (max 1000)"
)
filter: str | None = Field(
default=None, description="Filter logs by keyword (case-insensitive)"
)
level: str | None = Field(
default=None, description="Filter by error level (error, warning, notice)"
)
@classmethod
@field_validator("level")
def validate_level(cls, v):
"""Validate error level"""
if v and v.lower() not in ["error", "warning", "notice", "fatal"]:
raise ValueError("level must be: error, warning, notice, or fatal")
return v.lower() if v else v
class ErrorLogEntry(BaseModel):
"""Single error log entry"""
timestamp: str = Field(description="Error timestamp")
level: str = Field(description="Error level (error, warning, etc.)")
message: str = Field(description="Error message")
file: str | None = Field(default=None, description="File where error occurred")
line: int | None = Field(default=None, description="Line number")
class ErrorLogResponse(BaseModel):
"""Error log retrieval response"""
entries: list[ErrorLogEntry] = Field(description="Log entries")
total_lines: int = Field(description="Total lines in log file")
filtered_lines: int = Field(description="Number of entries returned")
log_size_mb: float = Field(description="Log file size in MB")
class CacheStats(BaseModel):
"""Cache statistics"""
cache_type: str = Field(description="Type of object cache (Redis, Memcached, etc.)")
cache_enabled: bool = Field(description="Is persistent object cache enabled")
transients_count: int = Field(description="Number of transients in database")
opcache_enabled: bool = Field(description="Is OPcache enabled")
opcache_memory_usage: dict[str, Any] | None = Field(
default=None, description="OPcache memory usage statistics"
)

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