4 Commits

Author SHA1 Message Date
ec6c006235 fix(hotfix): v3.13.1 — fix ImportError in api_list_sites (My Sites broken)
Some checks failed
CI / Test (Python 3.11) (push) Has been cancelled
CI / Test (Python 3.12) (push) Has been cancelled
CI / Lint & Format (push) Has been cancelled
CI / Docker Build (push) Has been cancelled
Release / Test before release (push) Has been cancelled
Release / Publish to PyPI (push) Has been cancelled
Release / Publish to Docker Hub (push) Has been cancelled
Release / Create GitHub Release (push) Has been cancelled
routes.py still imported MAX_SITES_PER_USER from core.site_api which was
removed in v3.13.0. This caused 500 errors on /api/sites, making My Sites
appear empty for all users. Fixed to use get_cached_max_sites().

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-21 01:09:51 +02:00
331d756851 fix(ci): add .gitkeep to SPA dist dir so test_spa_dist_dir_exists passes
Some checks failed
Release / Test before release (push) Has been cancelled
Release / Publish to PyPI (push) Has been cancelled
Release / Publish to Docker Hub (push) Has been cancelled
Release / Create GitHub Release (push) Has been cancelled
Fixes test_dashboard_v2.py::test_spa_dist_dir_exists_as_placeholder.
Directory must exist for package_data to include it; Docker build
populates it via npm run build.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-21 00:29:52 +02:00
349913ca2d fix(ci): fix 3 pre-existing CI failures + doc updates
- i18n: add 115 missing EN translation keys (parity with FA)
- csrf: test /dashboard-legacy/keys (SPA owns /dashboard/*)
- oauth: add tier scopes to test fixture allowed_scopes
- DOCKER_README: v3.13.0 — remove Appwrite/Directus, clarify 8 plugins
- getting-started: remove Appwrite/Directus, add Coolify endpoint row
- Overview.tsx: fix Lang type for AdminStatsPanel

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-21 00:05:33 +02:00
43fd2201a0 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>
2026-05-20 23:33:20 +02:00
226 changed files with 36393 additions and 4182 deletions

View File

@@ -18,7 +18,7 @@
# - /system/mcp → System (17 tools, Master Key) # - /system/mcp → System (17 tools, Master Key)
# - /wordpress/mcp → WordPress Core (65 tools) # - /wordpress/mcp → WordPress Core (65 tools)
# - /woocommerce/mcp → WooCommerce (28 tools) # - /woocommerce/mcp → WooCommerce (28 tools)
# - /wordpress-advanced/mcp → WordPress Advanced (22 tools) # - /wordpress-specialist/mcp → WordPress Specialist (51 tools)
# - /gitea/mcp → Gitea (56 tools) # - /gitea/mcp → Gitea (56 tools)
# - /n8n/mcp → n8n Automation (56 tools) # - /n8n/mcp → n8n Automation (56 tools)
# - /supabase/mcp → Supabase Self-Hosted (70 tools) # - /supabase/mcp → Supabase Self-Hosted (70 tools)
@@ -218,39 +218,31 @@ WORDPRESS_SITE1_CONTAINER=coolify-wp-site1 # For WP-CLI access (optional)
# ============================================================================== # ==============================================================================
# ============================================================================== # ==============================================================================
# WORDPRESS ADVANCED PLUGIN # WORDPRESS SPECIALIST PLUGIN
# ============================================================================== # ==============================================================================
# #
# WordPress Advanced provides 22 advanced management tools: # WordPress Specialist provides 51 management tools across read / editor /
# - Database operations (7 tools): export, import, search, query, repair # settings / install / admin tiers. Companion-backed (Airano MCP Bridge
# - Bulk operations (8 tools): batch updates/deletes for posts, products, media # v2.18.0+ — install wordpress-plugin/airano-mcp-bridge.zip on the WP
# - System operations (7 tools): system info, cache, cron, error logs # site). No Docker socket needed; replaced the deprecated wordpress_advanced
# plugin (sunset 2026-05-04 in F.19.3.2-.3).
# #
# REQUIRED: Docker container name for WP-CLI access # Tool surface:
# NOTE: WordPress Advanced has the SAME configuration pattern as WordPress # - Read management (9 tools): plugins / themes / users / options / cron
# but requires 'container' configuration for WP-CLI access. # / maintenance status / system_info / phpinfo / disk_usage
# - Page editing (11 tools): Gutenberg blocks + Elementor + Classic
# - Theme dev (7 tools): install / activate / delete / file CRUD
# - Plugin write (6 tools): install / activate / deactivate / update / delete
# - Site config (6 tools): identity / reading / permalinks
# - Site layout (7 tools): menus / widgets / customizer
# - Database inspection (3 tools): db_size / db_tables / db_search
# (no SQL exposure; db_search wraps WP_Query)
# - Bulk fan-out (2 tools): bulk_post_update / bulk_term_update (50-item
# cap, concurrency=10)
# #
# Security Benefits: # Sites are managed via the web dashboard (DB-based), not env vars.
# - Separate API keys for advanced vs basic operations # This section is documentation only — there are no env-var stanzas to
# - Better tool visibility (basic users don't see advanced tools) # fill in for wordpress_specialist sites.
# - Granular access control
#
# Site 1 Advanced Configuration
WORDPRESS_ADVANCED_SITE1_URL=https://example1.com
WORDPRESS_ADVANCED_SITE1_USERNAME=admin
WORDPRESS_ADVANCED_SITE1_APP_PASSWORD=your_app_password_here
WORDPRESS_ADVANCED_SITE1_ALIAS=myblog
WORDPRESS_ADVANCED_SITE1_CONTAINER=coolify-wp-site1 # REQUIRED for advanced features
# Site 2 Advanced Configuration
#WORDPRESS_ADVANCED_SITE2_URL=https://example2.com
#WORDPRESS_ADVANCED_SITE2_USERNAME=admin
#WORDPRESS_ADVANCED_SITE2_APP_PASSWORD=your_app_password_here
#WORDPRESS_ADVANCED_SITE2_ALIAS=mystore
#WORDPRESS_ADVANCED_SITE2_CONTAINER=coolify-wp-site2
# Add more sites as needed (wordpress_advanced_site3, wordpress_advanced_site4, etc.)
# ============================================================================== # ==============================================================================
# LOGGING & MONITORING # LOGGING & MONITORING

22
.gitignore vendored
View File

@@ -14,6 +14,14 @@ build/
*.egg *.egg
MANIFEST MANIFEST
# Track G — preserve the SPA dist directory placeholder (so package_data can
# pick it up) but ignore the actual build output, which is regenerated by
# `cd web && npm run build` or the Dockerfile's frontend stage.
!core/templates/static/dist/
!core/templates/static/dist/.gitkeep
core/templates/static/dist/*
!core/templates/static/dist/.gitkeep
# Virtual environments # Virtual environments
venv/ venv/
env/ env/
@@ -170,3 +178,17 @@ uv.lock
task_plan.md task_plan.md
findings.md findings.md
progress.md progress.md
# wp.org SVN working copies (separate VCS, not for git):
# - airano-mcp-bridge-wporg-svn/ → new slug `airano-mcp-bridge` (current)
# - airano-mcp-seo-bridge-svn/ → old slug `airano-mcp-seo-bridge` (deprecated, migration release lives here)
# - airano-mcp-bridge-svn/ → legacy stub from earlier exploration
wordpress-plugin/airano-mcp-bridge-svn/
wordpress-plugin/airano-mcp-bridge-wporg-svn/
wordpress-plugin/airano-mcp-seo-bridge-svn/
# Screenshot harness auth state — never commit (contains session cookie)
screenshots/.auth-state.json
screenshots/_*.json
# wp.org SVN working copy for the airano-mcp-bridge plugin (separate VCS, not for git)
wordpress-plugin/airano-mcp-bridge-svn/

View File

@@ -4,7 +4,7 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
## Project Overview ## Project Overview
**MCP Hub** — a Python MCP (Model Context Protocol) server that manages multiple self-hosted services through a unified plugin architecture. Supports 10 plugin types (WordPress, WooCommerce, WordPress Advanced, Gitea, n8n, Supabase, OpenPanel, Appwrite, Directus, Coolify). Total tool count varies with which plugins are enabled and grows as new plugins are added; per-plugin counts are surfaced in the dashboard. The exposed count for a given user/endpoint stays constant regardless of how many sites of the same type are configured. **MCP Hub** — a Python MCP (Model Context Protocol) server that manages multiple self-hosted services through a unified plugin architecture. Supports 8 active plugin types (WordPress, WooCommerce, WordPress Specialist, Gitea, n8n, Supabase, OpenPanel, Coolify). Total tool count varies with which plugins are enabled and grows as new plugins are added; per-plugin counts are surfaced in the dashboard. The exposed count for a given user/endpoint stays constant regardless of how many sites of the same type are configured.
## Quick Setup ## Quick Setup
@@ -55,13 +55,42 @@ docker build -t mcphub .
docker-compose up -d docker-compose up -d
``` ```
## Frontend (web/) — Track G
The redesigned dashboard SPA lives in `web/` (Vite + React 18 + TypeScript).
Build artifacts land in `core/templates/static/dist/` and are served by
Starlette under `/static/dist/` and routed at `/dashboard-v2/*`. Legacy Jinja
dashboard at `/dashboard/*` is unchanged during the coexistence window.
```bash
# Install JS deps (one-time)
cd web && npm install
# Dev server (proxies /api, /auth, /dashboard, /static to localhost:8000)
cd web && npm run dev
# Type-check and run unit tests
cd web && npm run typecheck
cd web && npm test
# Production build (writes hashed bundles into core/templates/static/dist)
cd web && npm run build
# SPA-support backend tests
pytest -m frontend
```
Logo: `core/templates/static/logo.svg` is the single source of truth and is
copied verbatim into `web/public/` during build. The React `Logo` component
inlines the same path data and themes its fills via CSS vars.
## Code Quality Configuration ## Code Quality Configuration
All configured in `pyproject.toml`: All configured in `pyproject.toml`:
- **Black**: line-length=100, target py311 - **Black**: line-length=100, target py311
- **Ruff**: strict rules (E, W, F, I, N, D, UP, B, C4, SIM, TCH, PTH), Google-style docstrings - **Ruff**: strict rules (E, W, F, I, N, D, UP, B, C4, SIM, TCH, PTH), Google-style docstrings
- **mypy**: Python 3.11, strict equality, check_untyped_defs=true - **mypy**: Python 3.11, strict equality, check_untyped_defs=true
- **pytest**: asyncio_mode="auto", testpaths=["tests"], markers: slow, integration, unit, security - **pytest**: asyncio_mode="auto", testpaths=["tests"], markers: slow, integration, unit, security, frontend
## Architecture ## Architecture
@@ -116,7 +145,7 @@ plugins/{name}/
└── schemas/ # Pydantic models for validation └── schemas/ # Pydantic models for validation
``` ```
**Registered plugins**: wordpress, woocommerce, wordpress_advanced, gitea, n8n, supabase, openpanel, appwrite, directus, coolify **Registered plugins**: wordpress, woocommerce, wordpress_specialist, gitea, n8n, supabase, openpanel, coolify
**Plugin visibility** (Track F.1): Public users only see plugins listed in `ENABLED_PLUGINS` env var (default: `wordpress,woocommerce,supabase`). Admin sees all. Controlled by `core/plugin_visibility.py`. **Plugin visibility** (Track F.1): Public users only see plugins listed in `ENABLED_PLUGINS` env var (default: `wordpress,woocommerce,supabase`). Admin sees all. Controlled by `core/plugin_visibility.py`.
@@ -192,7 +221,7 @@ v4 development cycle with 15 phases. See `docs/ROADMAP.md` (Track F) and `.claud
- Test files exist in both `tests/` (proper) and root directory (legacy `test_*.py`). Run `pytest tests/` for organized tests only. - Test files exist in both `tests/` (proper) and root directory (legacy `test_*.py`). Run `pytest tests/` for organized tests only.
- `wordpress-plugin/` contains companion WP plugins (openpanel, airano-mcp-bridge) — these are PHP, not Python - `wordpress-plugin/` contains companion WP plugins (openpanel, airano-mcp-bridge) — these are PHP, not Python
- `env.example` has "FUTURE" labels for Supabase/Gitea but both are fully implemented - `env.example` has "FUTURE" labels for Supabase/Gitea but both are fully implemented
- 5 plugins are tested for public use: WordPress, WooCommerce, Supabase, OpenPanel, Gitea. Others are admin-only or disabled. - 8 active plugins: WordPress, WooCommerce, WordPress Specialist, Supabase, OpenPanel, Gitea, n8n, Coolify. Appwrite/Directus code is retained in plugins/ but not registered (need review before re-enabling).
- OAuth Clients (Client ID/Secret) are for MCP endpoint auth, NOT the same as GitHub/Google dashboard login - OAuth Clients (Client ID/Secret) are for MCP endpoint auth, NOT the same as GitHub/Google dashboard login
- All sites stored in SQLite (`core/database.py`), managed via web dashboard - All sites stored in SQLite (`core/database.py`), managed via web dashboard
- Dashboard templates live in `core/templates/` (included in pip package as `package_data`) - Dashboard templates live in `core/templates/` (included in pip package as `package_data`)

View File

@@ -2,7 +2,7 @@
**The AI-native management hub for WordPress, WooCommerce, and self-hosted services.** **The AI-native management hub for WordPress, WooCommerce, and self-hosted services.**
633 tools across 10 plugins (incl. Coolify). Connect your sites, stores, repos, and databases — manage them all through Claude, ChatGPT, Cursor, or any MCP client. 8 plugins, hundreds of tools. Connect your sites, stores, repos, and databases — manage them all through Claude, ChatGPT, Cursor, or any MCP client.
> **Don't want to self-host?** Try the hosted instance at **[mcp.palebluedot.live](https://mcp.palebluedot.live)** — log in with GitHub or Google, add your sites, and connect your AI client in minutes. > **Don't want to self-host?** Try the hosted instance at **[mcp.palebluedot.live](https://mcp.palebluedot.live)** — log in with GitHub or Google, add your sites, and connect your AI client in minutes.
@@ -37,6 +37,8 @@ curl http://localhost:8000/health
# http://localhost:8000/dashboard # http://localhost:8000/dashboard
``` ```
Log in with your `MASTER_API_KEY`, then add sites via **My Sites → Add Service**.
### 4. Connect your AI client ### 4. Connect your AI client
**Claude Desktop** (`claude_desktop_config.json`): **Claude Desktop** (`claude_desktop_config.json`):
@@ -55,7 +57,7 @@ curl http://localhost:8000/health
} }
``` ```
**VS Code** (`.vscode/mcp.json`): **VS Code / Claude Code** (`.vscode/mcp.json` or `.mcp.json`):
```json ```json
{ {
@@ -71,7 +73,7 @@ curl http://localhost:8000/health
} }
``` ```
> Use a plugin-specific endpoint (e.g., `/wordpress/mcp`) instead of `/mcp` to reduce tool count and save tokens. See [Endpoints](#endpoints) below. > Use a plugin-specific endpoint (e.g., `/wordpress/mcp`) instead of `/mcp` to keep the tool list focused and save tokens. See [Endpoints](#endpoints) below.
## Authentication ## Authentication
@@ -81,20 +83,18 @@ MCP Hub uses **Bearer token** authentication:
Authorization: Bearer YOUR_API_KEY Authorization: Bearer YOUR_API_KEY
``` ```
> `X-API-Key` header and query parameter auth are **not** supported.
## Endpoints ## Endpoints
Use the most specific endpoint for your use case: Use the most specific endpoint for your use case:
| Endpoint | Tools | `site` param? | Best for | | Endpoint | Use case |
|----------|------:|:-------------:|----------| |----------|----------|
| `/u/{user_id}/{alias}/mcp` | 22-100 | No (pre-scoped) | Hosted/OAuth users | | `/u/{user_id}/{alias}/mcp` | Hosted/OAuth users — single pre-scoped service |
| `/project/{alias}/mcp` | 22-100 | No (pre-scoped) | Single-site workflow | | `/project/{alias}/mcp` | Single-site workflow (recommended) |
| `/{plugin}/mcp` | 23-101 | Yes | Multi-site management | | `/{plugin}/mcp` | Multi-site management for one service type |
| `/mcp` | 633 | Yes | Admin & discovery only | | `/mcp` | Admin & discovery — all enabled tools |
Available plugin endpoints: `/wordpress/mcp`, `/woocommerce/mcp`, `/wordpress-advanced/mcp`, `/gitea/mcp`, `/n8n/mcp`, `/supabase/mcp`, `/openpanel/mcp`, `/appwrite/mcp`, `/directus/mcp`, `/system/mcp` Available plugin endpoints: `/wordpress/mcp`, `/woocommerce/mcp`, `/wordpress-specialist/mcp`, `/gitea/mcp`, `/n8n/mcp`, `/supabase/mcp`, `/openpanel/mcp`, `/coolify/mcp`, `/system/mcp`
## Using Docker Compose ## Using Docker Compose
@@ -118,61 +118,71 @@ volumes:
mcphub-logs: mcphub-logs:
``` ```
> **WP-CLI tools** (cache flush, database export, plugin updates via CLI) require Docker socket access.
> Add `WORDPRESS_SITE1_CONTAINER=your-wp-container-name` to your `.env` and uncomment the Docker socket volume above.
```bash ```bash
docker compose up -d docker compose up -d
``` ```
> **WP-CLI tools** (cache flush, database export, plugin management via CLI) require Docker socket access. Uncomment the socket volume and set the `container` field when adding the WordPress site in the dashboard.
## After Starting ## After Starting
| URL | Description | | URL | Description |
|-----|-------------| |-----|-------------|
| `http://localhost:8000/health` | Health check & status | | `http://localhost:8000/health` | Health check & status |
| `http://localhost:8000/dashboard` | Web dashboard (manage sites, API keys, OAuth clients, health monitoring). Login via MASTER_API_KEY or GitHub/Google OAuth | | `http://localhost:8000/dashboard` | Web dashboard manage sites, API keys, OAuth clients, health |
| `http://localhost:8000/mcp` | MCP endpoint (connect AI clients here) | | `http://localhost:8000/mcp` | MCP endpoint (connect your AI client here) |
## Environment Variables ## Environment Variables
| Variable | Required | Description | ### Required
|----------|----------|-------------|
| `MASTER_API_KEY` | Recommended | API key for authentication. If omitted, a temporary key is auto-generated and printed to logs |
| `WORDPRESS_SITE1_URL` | For WP | WordPress site URL |
| `WORDPRESS_SITE1_USERNAME` | For WP | WordPress admin username |
| `WORDPRESS_SITE1_APP_PASSWORD` | For WP | WordPress Application Password |
| `WORDPRESS_SITE1_ALIAS` | Recommended | Friendly name (e.g., `myblog`) |
| `WORDPRESS_SITE1_CONTAINER` | For WP-CLI | Docker container name of your WordPress site (enables cache/db/system tools) |
| `OAUTH_JWT_SECRET_KEY` | For OAuth | JWT secret for ChatGPT auto-registration (not needed for Claude/Cursor) |
| `OAUTH_BASE_URL` | For OAuth | Public URL of your server (not needed for Claude/Cursor) |
| `GITHUB_CLIENT_ID` | For Social Login | GitHub OAuth App client ID |
| `GITHUB_CLIENT_SECRET` | For Social Login | GitHub OAuth App client secret |
| `GOOGLE_CLIENT_ID` | For Social Login | Google OAuth client ID |
| `GOOGLE_CLIENT_SECRET` | For Social Login | Google OAuth client secret |
| `PUBLIC_URL` | For Social Login | Public URL for OAuth callbacks (e.g., `https://mcp.example.com`) |
| `ENCRYPTION_KEY` | For Multi-user | AES-256 key for encrypting user site credentials. Generate: `python -c "import os, base64; print(base64.b64encode(os.urandom(32)).decode())"` |
> **CONTAINER**: Required for WP-CLI tools (cache flush, database export, system info) and **all** WordPress Advanced tools. Find your container name: `docker ps --filter name=wordpress`. Also requires Docker socket mount. | Variable | Description |
|----------|-------------|
| `MASTER_API_KEY` | API key for admin access. If omitted, a temporary key is auto-generated and printed to logs. |
> **OAuth**: Only needed for ChatGPT Remote MCP auto-registration. For Claude Desktop, Claude Code, Cursor, and VS Code — just use `MASTER_API_KEY` with Bearer token auth. ### Optional — OAuth & Social Login
> **Social Login**: Enable GitHub/Google login for multi-user mode. Users can add sites via the dashboard and get personal MCP endpoints (`/u/{user_id}/{alias}/mcp`). | Variable | Description |
|----------|-------------|
| `OAUTH_JWT_SECRET_KEY` | JWT secret for ChatGPT Remote MCP auto-registration. Not needed for Claude/Cursor/VS Code. |
| `OAUTH_BASE_URL` | Public URL of your server. Required for OAuth flows. |
| `GITHUB_CLIENT_ID` / `GITHUB_CLIENT_SECRET` | Enable GitHub social login for multi-user mode. |
| `GOOGLE_CLIENT_ID` / `GOOGLE_CLIENT_SECRET` | Enable Google social login for multi-user mode. |
| `PUBLIC_URL` | Public URL for OAuth callbacks (e.g., `https://mcp.example.com`). |
Add more sites with `SITE2`, `SITE3`, etc. See [full configuration guide](https://github.com/airano-ir/mcphub/blob/main/docs/getting-started.md). ### Optional — Multi-user & Encryption
## Supported Plugins | Variable | Default | Description |
|----------|---------|-------------|
| `ENCRYPTION_KEY` | — | AES-256-GCM key for encrypting user site credentials. Generate: `python -c "import os, base64; print(base64.b64encode(os.urandom(32)).decode())"` |
| `MAX_SITES_PER_USER` | `10` | Maximum sites per user account (also configurable via the dashboard Settings page). |
| `USER_RATE_LIMIT_PER_MIN` | `30` | Per-user rate limit (requests/minute). Configurable via dashboard. |
| `USER_RATE_LIMIT_PER_HR` | `500` | Per-user rate limit (requests/hour). Configurable via dashboard. |
| `ENABLED_PLUGINS` | `wordpress,woocommerce,supabase,openpanel,gitea` | Comma-separated list of plugins visible to public/OAuth users. Admin always sees all plugins. |
| Plugin | Tools | Env Prefix | ### Optional — Logging
|--------|-------|------------|
| WordPress | 67 | `WORDPRESS_` | | Variable | Default | Description |
| WooCommerce | 28 | `WOOCOMMERCE_` | |----------|---------|-------------|
| WordPress Advanced | 22 | `WORDPRESS_ADVANCED_` | | `LOG_LEVEL` | `INFO` | Logging verbosity: `DEBUG`, `INFO`, `WARNING`, `ERROR` |
| Gitea | 56 | `GITEA_` |
| n8n | 56 | `N8N_` | > **Site configuration** (URLs, credentials) is managed via the web dashboard — not environment variables. Log in and go to **My Sites → Add Service**.
| Supabase | 70 | `SUPABASE_` |
| OpenPanel | 73 | `OPENPANEL_` | ## 8 Plugins, Hundreds of Tools
| Appwrite | 100 | `APPWRITE_` |
| Directus | 100 | `DIRECTUS_` | | Plugin | Approx. Tools | What you can do |
|--------|------:|-----------------|
| WordPress | ~70 | Posts, pages, media (incl. AI image gen), users, menus, taxonomies, SEO |
| WooCommerce | ~30 | Products, orders, customers, coupons, reports, shipping |
| WordPress Specialist | ~50 | Plugins, themes, options, cron, page editing, DB inspection, bulk fan-out |
| Gitea | ~65 | Repos, issues, PRs, releases, webhooks, orgs, batch file ops |
| n8n | ~55 | Workflows, executions, credentials, variables, audit |
| Supabase | ~70 | Database, auth, storage, edge functions, realtime |
| OpenPanel | ~40 | Events, export, insights, profiles, projects |
| Coolify | ~65 | Apps, deployments, servers, projects, databases, services |
| System | ~25 | Health monitoring, API keys, OAuth management, audit logs |
> **WordPress Specialist** requires [Airano MCP Bridge](https://wordpress.org/plugins/airano-mcp-bridge/) (v2.18.0+) installed on your WordPress site.
## Links ## Links

View File

@@ -5,6 +5,16 @@
# Production-ready with security best practices # Production-ready with security best practices
# =================================== # ===================================
# Stage 0: Frontend build (Track G — Vite + React SPA → core/templates/static/dist)
FROM node:20-alpine AS frontend
WORKDIR /web
COPY web/package.json web/package-lock.json* ./
RUN npm ci --no-audit --no-fund || npm install --no-audit --no-fund
COPY web/ ./
COPY core/templates/static/logo.svg ./public/logo.svg
RUN npm run build
# Stage 1: Build stage # Stage 1: Build stage
FROM python:3.12-alpine AS builder FROM python:3.12-alpine AS builder
@@ -46,6 +56,11 @@ COPY --from=builder /root/.local /home/appuser/.local
# Copy application code # Copy application code
COPY --chown=appuser:appgroup . . COPY --chown=appuser:appgroup . .
# Overlay the SPA build output (from stage 0) into the templates static dir.
# Vite already targets core/templates/static/dist when run locally; in Docker
# we build inside the frontend stage and copy the result here.
COPY --from=frontend --chown=appuser:appgroup /core/templates/static/dist /app/core/templates/static/dist
# Create data directories for API keys and logs with correct ownership # Create data directories for API keys and logs with correct ownership
# This must be done before switching to non-root user # This must be done before switching to non-root user
RUN mkdir -p /app/data /app/logs && \ RUN mkdir -p /app/data /app/logs && \

View File

@@ -1,123 +0,0 @@
# ===================================
# MCP Hub — Dockerfile (mirror + PyPI-mirror-fallback variant)
# ===================================
# Identical to Dockerfile except:
# 1. Base images come from mirror.gcr.io (Docker Hub mirror) — the
# build host doesn't need to reach registry-1.docker.io.
# Alternative Docker registry mirrors reachable from this host
# (swap in by s/mirror.gcr.io/... if gcr also fails):
# - dockerhub.timeweb.cloud/library
# - docker.m.daocloud.io
# - docker.arvancloud.ir
# 2. Alpine apk repos point to mirror.yandex.ru (more reachable than
# dl-cdn.alpinelinux.org from restrictive networks).
# 3. pip install tries pypi.org first, then falls back through Aliyun
# and Tsinghua PyPI mirrors. If an optional BUILD_PROXY ARG is
# provided, it is tried as a final fallback.
#
# Switch back to vanilla Dockerfile once Docker Hub + Alpine CDN +
# pypi.org are all reachable directly from the build host.
# ===================================
# Stage 1: Build stage
FROM mirror.gcr.io/library/python:3.12-alpine AS builder
# Optional HTTP proxy (empty by default — only used if set via Coolify
# build-arg). ARG values are not baked into the runtime image.
ARG BUILD_PROXY=""
# Use Yandex apk mirror — reachable when dl-cdn.alpinelinux.org is not.
RUN sed -i 's|dl-cdn.alpinelinux.org|mirror.yandex.ru/mirrors|g' /etc/apk/repositories
# Install build dependencies — direct first, proxy fallback if provided.
RUN apk add --no-cache gcc musl-dev libffi-dev openssl-dev \
|| ( [ -n "${BUILD_PROXY}" ] \
&& echo "==> direct apk failed; retrying via BUILD_PROXY" \
&& HTTP_PROXY="${BUILD_PROXY}" HTTPS_PROXY="${BUILD_PROXY}" \
apk add --no-cache gcc musl-dev libffi-dev openssl-dev )
# Create build directory
WORKDIR /build
# Install Python dependencies — four-step fallback chain:
# 1. pypi.org (direct)
# 2. Aliyun mirror (mirrors.aliyun.com)
# 3. Tsinghua mirror (pypi.tuna.tsinghua.edu.cn)
# 4. Optional BUILD_PROXY (only tried if the ARG is non-empty)
COPY requirements.txt .
RUN pip install --no-cache-dir --user --retries 1 --timeout 15 -r requirements.txt \
|| ( echo "==> pypi.org failed; trying Aliyun mirror" \
&& pip install --no-cache-dir --user --retries 1 --timeout 15 \
-i https://mirrors.aliyun.com/pypi/simple/ \
--trusted-host mirrors.aliyun.com \
-r requirements.txt ) \
|| ( echo "==> Aliyun failed; trying Tsinghua mirror" \
&& pip install --no-cache-dir --user --retries 1 --timeout 20 \
-i https://pypi.tuna.tsinghua.edu.cn/simple/ \
--trusted-host pypi.tuna.tsinghua.edu.cn \
-r requirements.txt ) \
|| ( [ -n "${BUILD_PROXY}" ] \
&& echo "==> Tsinghua failed; retrying via BUILD_PROXY" \
&& pip install --no-cache-dir --user \
--proxy "${BUILD_PROXY}" -r requirements.txt )
# Stage 2: Production stage
FROM mirror.gcr.io/library/python:3.12-alpine AS production
# Re-declare proxy ARG (ARGs don't cross stage boundaries).
ARG BUILD_PROXY=""
# Same Yandex apk mirror swap as the builder stage.
RUN sed -i 's|dl-cdn.alpinelinux.org|mirror.yandex.ru/mirrors|g' /etc/apk/repositories
# CRITICAL: Install wget for health checks + docker-cli for WP-CLI tools
# libmagic is required by python-magic (F.5a media upload MIME sniffing)
RUN apk add --no-cache wget curl docker-cli libmagic \
|| ( [ -n "${BUILD_PROXY}" ] \
&& echo "==> direct apk failed; retrying via BUILD_PROXY" \
&& HTTP_PROXY="${BUILD_PROXY}" HTTPS_PROXY="${BUILD_PROXY}" \
apk add --no-cache wget curl docker-cli libmagic )
# Create non-root user for security and grant Docker socket access
# Docker group (GID 999) allows access to /var/run/docker.sock
RUN addgroup -g 1001 appgroup && \
adduser -u 1001 -G appgroup -s /bin/sh -D appuser && \
addgroup -g 999 docker 2>/dev/null || true && \
adduser appuser docker 2>/dev/null || true
# Set working directory
WORKDIR /app
# Copy Python packages from builder
COPY --from=builder /root/.local /home/appuser/.local
# Copy application code
COPY --chown=appuser:appgroup . .
# Create data directories for API keys and logs with correct ownership
# This must be done before switching to non-root user
RUN mkdir -p /app/data /app/logs && \
chown -R appuser:appgroup /app/data /app/logs && \
chmod 755 /app/data /app/logs
# Make server.py executable
RUN chmod +x server.py
# Switch to non-root user
USER appuser
# Add local packages to PATH
ENV PATH=/home/appuser/.local/bin:$PATH
ENV PYTHONUNBUFFERED=1
# CRITICAL: EXPOSE port for Coolify
EXPOSE 8000
# CRITICAL: Health check
HEALTHCHECK --interval=30s --timeout=10s --start-period=40s --retries=3 \
CMD wget --no-verbose --tries=1 --spider http://localhost:8000/health || exit 1
# CRITICAL: Listen on 0.0.0.0 (not localhost!)
# Run server with streamable-http transport on port 8000
CMD ["python", "server.py", "--transport", "streamable-http", "--port", "8000", "--host", "0.0.0.0"]

View File

@@ -13,7 +13,7 @@ Connect your sites, stores, repos, and databases — manage them all through Cla
[![Python 3.11+](https://img.shields.io/badge/python-3.11+-3776ab.svg)](https://www.python.org/) [![Python 3.11+](https://img.shields.io/badge/python-3.11+-3776ab.svg)](https://www.python.org/)
[![PyPI](https://img.shields.io/pypi/v/mcphub-server.svg)](https://pypi.org/project/mcphub-server/) [![PyPI](https://img.shields.io/pypi/v/mcphub-server.svg)](https://pypi.org/project/mcphub-server/)
[![Docker](https://img.shields.io/docker/v/airano/mcphub?label=docker)](https://hub.docker.com/r/airano/mcphub) [![Docker](https://img.shields.io/docker/v/airano/mcphub?label=docker)](https://hub.docker.com/r/airano/mcphub)
[![Plugins: 10](https://img.shields.io/badge/plugins-10-orange.svg)]() [![Plugins: 8](https://img.shields.io/badge/plugins-8-orange.svg)]()
[![CI](https://github.com/airano-ir/mcphub/actions/workflows/ci.yml/badge.svg)](https://github.com/airano-ir/mcphub/actions/workflows/ci.yml) [![CI](https://github.com/airano-ir/mcphub/actions/workflows/ci.yml/badge.svg)](https://github.com/airano-ir/mcphub/actions/workflows/ci.yml)
</div> </div>
@@ -48,7 +48,7 @@ MCP Hub is the first MCP server that lets you manage WordPress, WooCommerce, and
--- ---
## 10 Plugins, Hundreds of Tools ## 8 Public Plugins, Hundreds of Tools
The exact tool count grows as new plugins ship and existing ones gain endpoints. The exact tool count grows as new plugins ship and existing ones gain endpoints.
What you actually expose is controlled by your `ENABLED_PLUGINS` setting and per-key What you actually expose is controlled by your `ENABLED_PLUGINS` setting and per-key
@@ -58,13 +58,11 @@ scope — pick a plugin-specific endpoint to keep the surface area small.
|--------|---------------:|-----------------| |--------|---------------:|-----------------|
| **WordPress** | ~70 | Posts, pages, media (incl. AI image generation), users, menus, taxonomies, SEO (Rank Math/Yoast) | | **WordPress** | ~70 | Posts, pages, media (incl. AI image generation), users, menus, taxonomies, SEO (Rank Math/Yoast) |
| **WooCommerce** | ~30 | Products, orders, customers, coupons, reports, shipping | | **WooCommerce** | ~30 | Products, orders, customers, coupons, reports, shipping |
| **WordPress Advanced** | ~20 | Database ops, bulk operations, WP-CLI, system management | | **WordPress Specialist** | ~50 | Plugins, themes, users, options, cron, page editing, site config + layout, db inspection, bulk fan-out (companion-backed; no Docker socket) |
| **Gitea** | ~65 | Repos, issues, pull requests, releases, webhooks, organizations, labels, batch files, tree, search, compare | | **Gitea** | ~65 | Repos, issues, pull requests, releases, webhooks, organizations, labels, batch files, tree, search, compare |
| **n8n** | ~55 | Workflows, executions, credentials, variables, audit | | **n8n** | ~55 | Workflows, executions, credentials, variables, audit |
| **Supabase** | ~70 | Database, auth, storage, edge functions, realtime | | **Supabase** | ~70 | Database, auth, storage, edge functions, realtime |
| **OpenPanel** | ~40 | Events, export, insights, profiles, projects, system | | **OpenPanel** | ~40 | Events, export, insights, profiles, projects, system |
| **Appwrite** | ~100 | Databases, auth, storage, functions, teams, messaging |
| **Directus** | ~100 | Collections, items, users, files, flows, permissions |
| **Coolify** | ~65 | Applications, deployments, servers, projects, databases, services | | **Coolify** | ~65 | Applications, deployments, servers, projects, databases, services |
| **System** | ~25 | Health monitoring, API keys, OAuth management, audit | | **System** | ~25 | Health monitoring, API keys, OAuth management, audit |
@@ -163,13 +161,11 @@ MASTER_API_KEY=your-secure-key-here
|--------|---------------------|-------| |--------|---------------------|-------|
| WordPress | URL, Username, App Password | [How to create App Password](https://wordpress.org/documentation/article/application-passwords/) | | WordPress | URL, Username, App Password | [How to create App Password](https://wordpress.org/documentation/article/application-passwords/) |
| WooCommerce | URL, Consumer Key, Consumer Secret | WooCommerce → Settings → Advanced → REST API | | WooCommerce | URL, Consumer Key, Consumer Secret | WooCommerce → Settings → Advanced → REST API |
| WordPress Advanced | URL, Username, App Password, Container | Container = Docker container name (for WP-CLI) | | WordPress Specialist | URL, Username, App Password | Requires [Airano MCP Bridge v2.18.0+](wordpress-plugin/airano-mcp-bridge.zip) on the WP site; user must have `manage_options` |
| Gitea | URL, Token | Settings → Applications → Personal Access Token | | Gitea | URL, Token | Settings → Applications → Personal Access Token |
| n8n | URL, API Key | Settings → API → Create API Key | | n8n | URL, API Key | Settings → API → Create API Key |
| Supabase | URL, Service Role Key | Supabase Dashboard → Settings → API | | Supabase | URL, Service Role Key | Supabase Dashboard → Settings → API |
| OpenPanel | URL, Client ID, Client Secret | OpenPanel Dashboard → Project Settings | | OpenPanel | URL, Client ID, Client Secret | OpenPanel Dashboard → Project Settings |
| Appwrite | URL, API Key, Project ID | Appwrite Console → Settings → API Keys |
| Directus | URL, Static Token | Directus Admin → Settings |
</details> </details>
@@ -275,13 +271,11 @@ MCP Hub supports **Open Dynamic Client Registration** (RFC 7591). ChatGPT can au
/system/mcp → System tools only /system/mcp → System tools only
/wordpress/mcp → WordPress tools /wordpress/mcp → WordPress tools
/woocommerce/mcp → WooCommerce tools /woocommerce/mcp → WooCommerce tools
/wordpress-advanced/mcp → WordPress Advanced tools /wordpress-specialist/mcp → WordPress Specialist tools (companion-backed)
/gitea/mcp → Gitea tools /gitea/mcp → Gitea tools
/n8n/mcp → n8n tools /n8n/mcp → n8n tools
/supabase/mcp → Supabase tools /supabase/mcp → Supabase tools
/openpanel/mcp → OpenPanel tools /openpanel/mcp → OpenPanel tools
/appwrite/mcp → Appwrite tools
/directus/mcp → Directus tools
/coolify/mcp → Coolify tools /coolify/mcp → Coolify tools
/project/{alias}/mcp → Per-project endpoint (auto-injects site) /project/{alias}/mcp → Per-project endpoint (auto-injects site)
/u/{user_id}/{alias}/mcp → Per-user endpoint (hosted/OAuth users) /u/{user_id}/{alias}/mcp → Per-user endpoint (hosted/OAuth users)
@@ -316,11 +310,11 @@ Some MCP Hub tools require companion WordPress plugins:
|-------|-------------| |-------|-------------|
| SEO + capability/audit tools (`wordpress_get_post_seo`, capability probe, audit hook, etc.) | [Airano MCP Bridge](https://wordpress.org/plugins/airano-mcp-bridge/) ([GitHub](wordpress-plugin/airano-mcp-bridge/)) + Rank Math or Yoast SEO | | SEO + capability/audit tools (`wordpress_get_post_seo`, capability probe, audit hook, etc.) | [Airano MCP Bridge](https://wordpress.org/plugins/airano-mcp-bridge/) ([GitHub](wordpress-plugin/airano-mcp-bridge/)) + Rank Math or Yoast SEO |
| WP-CLI tools (15 tools: `wp_cache_*`, `wp_db_*`, etc.) | Docker socket + `CONTAINER` config | | WP-CLI tools (15 tools: `wp_cache_*`, `wp_db_*`, etc.) | Docker socket + `CONTAINER` config |
| WordPress Advanced database/system tools | Docker socket + `CONTAINER` config | | WordPress Specialist (~50 tools: plugins / themes / users / options / cron / page editing / site config + layout / db inspection / bulk fan-out) | [Airano MCP Bridge v2.18.0+](wordpress-plugin/airano-mcp-bridge.zip) (no Docker socket needed) |
| OpenPanel analytics integration | [OpenPanel Self-Hosted](wordpress-plugin/openpanel-self-hosted/) ([Download ZIP](wordpress-plugin/openpanel-self-hosted.zip)) | | OpenPanel analytics integration | [OpenPanel Self-Hosted](wordpress-plugin/openpanel-self-hosted/) ([Download ZIP](wordpress-plugin/openpanel-self-hosted.zip)) |
| WooCommerce tools | WooCommerce plugin installed on your WordPress site | | WooCommerce tools | WooCommerce plugin installed on your WordPress site |
**Docker socket** is needed for WP-CLI and WordPress Advanced system tools. Add to your docker-compose: **Docker socket** is needed for the legacy WP-CLI tools (15 helpers under `wordpress_wp_*`). Everything in `wordpress_specialist` works without it. Add to your docker-compose:
```yaml ```yaml
volumes: volumes:

View File

@@ -13,6 +13,8 @@ from .routes import (
# K.3: API Keys routes # K.3: API Keys routes
dashboard_api_keys_list, dashboard_api_keys_list,
dashboard_api_keys_revoke, dashboard_api_keys_revoke,
# K.1: Core routes
dashboard_api_login,
dashboard_api_project_detail, dashboard_api_project_detail,
dashboard_api_projects, dashboard_api_projects,
dashboard_api_stats, dashboard_api_stats,
@@ -22,7 +24,6 @@ from .routes import (
dashboard_health_page, dashboard_health_page,
dashboard_health_projects_partial, dashboard_health_projects_partial,
dashboard_home, dashboard_home,
# K.1: Core routes
dashboard_login_page, dashboard_login_page,
dashboard_login_submit, dashboard_login_submit,
dashboard_logout, dashboard_logout,
@@ -44,6 +45,7 @@ __all__ = [
"get_dashboard_auth", "get_dashboard_auth",
"register_dashboard_routes", "register_dashboard_routes",
# K.1 # K.1
"dashboard_api_login",
"dashboard_login_page", "dashboard_login_page",
"dashboard_login_submit", "dashboard_login_submit",
"dashboard_logout", "dashboard_logout",

View File

@@ -316,7 +316,7 @@ class DashboardAuth:
if request.url.query: if request.url.query:
next_url += f"?{request.url.query}" next_url += f"?{request.url.query}"
return RedirectResponse( return RedirectResponse(
url=f"/auth/login?next={next_url}", url=f"/dashboard/login?next={next_url}",
status_code=303, status_code=303,
) )
return None return None

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,345 @@
"""
SPA Routes (Track G) — React SPA support on /dashboard/*.
This module exposes:
- ``GET /api/me`` — JSON description of the current session (no redirect side
effects). Used by the React SPA to decide whether to show login or dashboard.
- ``GET /api/i18n/{lang}`` — JSON dump of the existing
``DASHBOARD_TRANSLATIONS`` so the SPA can reuse the copy without duplication.
- ``GET /dashboard`` and ``GET /dashboard/{path:path}`` — catch-all that
serves the SPA's compiled ``index.html``. Static asset handling is wired up
in ``register_spa_routes`` via a ``StaticFiles`` mount.
Coexistence: the legacy Jinja UI lives at ``/dashboard-legacy/*`` while the
SPA owns ``/dashboard/*``. Old ``/dashboard-v2/*`` links are redirected to the
new prefix by the main Starlette app (see Track G.12 in ROADMAP.md).
"""
from __future__ import annotations
import logging
import os
from typing import Any
from starlette.requests import Request
from starlette.responses import FileResponse, JSONResponse, RedirectResponse, Response
from starlette.staticfiles import StaticFiles
from .auth import get_dashboard_auth, is_admin_session
logger = logging.getLogger(__name__)
# Path to the Vite build output. Templates dir lives one level above this file.
_TEMPLATES_DIR = os.path.join(os.path.dirname(os.path.dirname(__file__)), "templates")
SPA_DIST_DIR = os.path.join(_TEMPLATES_DIR, "static", "dist")
# Marker placed just before </head> so the analytics injector has a stable,
# build-tool-agnostic insertion point.
_HEAD_CLOSE = "</head>"
def _spa_index_path() -> str:
return os.path.join(SPA_DIST_DIR, "index.html")
def _analytics_snippet() -> str:
"""Return analytics <script> tags assembled from env vars.
Each provider is independent: leaving its env vars unset silently skips
that block. Returns an empty string when nothing is configured so the
injection is a no-op.
"""
parts: list[str] = []
umami_id = os.environ.get("UMAMI_WEBSITE_ID", "").strip()
umami_url = os.environ.get("UMAMI_URL", "").strip()
if umami_id and umami_url:
parts.append(f'<script defer src="{umami_url}" data-website-id="{umami_id}"></script>')
op_api = os.environ.get("OPENPANEL_API_URL", "").strip()
op_client = os.environ.get("OPENPANEL_CLIENT_ID", "").strip()
if op_api and op_client:
# OpenPanel ships a small bootstrap that loads their tracker SDK.
parts.append(
"<script>"
"(function(){window.op=window.op||function(){(window.op.q=window.op.q||[]).push(arguments);};"
f'window.op("init",{{apiUrl:"{op_api}",clientId:"{op_client}"}});'
"var s=document.createElement('script');s.async=1;"
f's.src="{op_api.rstrip("/")}/script.js";'
"document.head.appendChild(s);})();"
"</script>"
)
return "".join(parts)
def _render_spa_index(index_path: str) -> bytes:
"""Read the built index.html and inject analytics tags before </head>.
Falls back to the unmodified bytes when no provider is configured or
when the </head> marker is somehow missing (older build).
"""
with open(index_path, "rb") as fh:
html = fh.read()
snippet = _analytics_snippet()
if not snippet:
return html
marker = _HEAD_CLOSE.encode("utf-8")
if marker not in html:
return html
return html.replace(marker, snippet.encode("utf-8") + marker, 1)
def _master_key_login_enabled() -> bool:
"""Return True when admin login by master API key is enabled.
Mirrors the env semantics used in ``core/dashboard/auth.py`` and
``core/dashboard/routes.py``: the env var inverts the meaning, so
``DISABLE_MASTER_KEY_LOGIN=true`` → master-key form is hidden.
"""
return os.environ.get("DISABLE_MASTER_KEY_LOGIN", "false").lower() != "true"
async def _max_sites_per_user() -> int:
from core.settings import get_setting
try:
val = await get_setting("MAX_SITES_PER_USER", "10")
return max(0, int(val or "10"))
except (ValueError, TypeError):
return 10
async def api_me(request: Request) -> Response:
"""Return JSON describing the current session.
Returns ``{"authenticated": false}`` with status 200 when the caller has no
valid session, instead of redirecting — this lets the SPA render its public
pages without round-trips.
Also exposes the per-request ``csrf_token`` (set by ``DashboardCSRFMiddleware``
on every request) and the ``master_key_login_enabled`` flag so the SPA can
submit guarded POSTs and conditionally render the admin-key form. The
underlying ``dashboard_csrf`` cookie stays HttpOnly; the JSON copy is the
only path JS has to read it.
"""
# Lazy import: avoids circular import with .routes (which imports from
# spa_routes is fine, but other dependents of routes.py shouldn't trigger
# heavy imports here).
from .routes import detect_language
auth = get_dashboard_auth()
admin_session = auth.get_session_from_request(request)
user_session = auth.get_user_session_from_request(request)
session = admin_session or user_session
accept_language = request.headers.get("accept-language")
query_lang = request.query_params.get("lang")
lang = detect_language(accept_language, query_lang)
csrf_token = getattr(request.state, "csrf_token", None)
master_key_login_enabled = _master_key_login_enabled()
if not session:
return JSONResponse(
{
"authenticated": False,
"is_admin": False,
"lang": lang,
"csrf_token": csrf_token,
"master_key_login_enabled": master_key_login_enabled,
"max_sites_per_user": await _max_sites_per_user(),
}
)
# Check is_admin against the *resolved* session, not just `admin_session`.
# Master-key login intentionally swaps the admin session token for a user
# session token (so the master admin can access My Sites etc.), which
# left `admin_session` as None and forced `is_admin` to False even though
# the user session dict carries role="admin". The SPA's RequireAuth then
# redirected admin-only routes (Health, OAuth clients, Audit logs) back
# to /sites for the master admin. Use `is_admin_session(session)` so the
# check works regardless of which slot the cookie resolved into.
is_admin = is_admin_session(session)
user_id = None
email = None
name = None
role = "user"
sess_type = "oauth_user"
# Extract fields defensively — DashboardSession and user-session dicts
# have different shapes.
if isinstance(session, dict):
user_id = session.get("user_id") or session.get("uid")
email = session.get("email")
name = session.get("name")
role = session.get("role") or ("admin" if is_admin else "user")
sess_type = session.get("type") or "oauth_user"
else:
# DashboardSession dataclass
user_id = getattr(session, "user_id", None) or getattr(session, "sid", None)
email = getattr(session, "email", None)
name = getattr(session, "name", None)
role = getattr(session, "role", None) or ("admin" if is_admin else "user")
sess_type = getattr(session, "type", None) or "master"
if sess_type in {"master", "api_key"}:
is_admin = True
return JSONResponse(
{
"authenticated": True,
"user_id": user_id,
"email": email,
"name": name,
"role": role,
"type": sess_type,
"is_admin": bool(is_admin),
"lang": lang,
"csrf_token": csrf_token,
"master_key_login_enabled": master_key_login_enabled,
"max_sites_per_user": await _max_sites_per_user(),
}
)
async def api_i18n(request: Request) -> Response:
"""Return the dashboard translation dictionary for the requested language."""
from .routes import DASHBOARD_TRANSLATIONS, get_translations
lang = request.path_params.get("lang", "en")
if lang not in DASHBOARD_TRANSLATIONS:
lang = "en"
return JSONResponse(get_translations(lang))
async def api_plugins(request: Request) -> Response:
"""Return the plugin catalog + per-plugin credential field definitions.
Used by the SPA's Site Add/Edit dialog to render the correct form
for the selected plugin without bouncing to the legacy Jinja page.
Admins see every plugin; user sessions see only the ones flagged
public by ``ENABLED_PLUGINS``. The shape mirrors the same map that
the Jinja form binds to so backend validation stays identical.
"""
auth = get_dashboard_auth()
admin_session = auth.get_session_from_request(request)
user_session = auth.get_user_session_from_request(request)
if admin_session is None and user_session is None:
return JSONResponse({"error": "Unauthorized"}, status_code=401)
from core.site_api import get_user_credential_fields, get_user_plugin_names
is_admin = is_admin_session(admin_session or user_session)
fields = get_user_credential_fields(is_admin=is_admin)
names = get_user_plugin_names(is_admin=is_admin)
plugins = [
{"type": ptype, "name": names.get(ptype, ptype), "fields": flds}
for ptype, flds in fields.items()
]
plugins.sort(key=lambda p: p["name"].lower())
return JSONResponse({"plugins": plugins})
async def serve_spa(_request: Request, status_code: int = 200) -> Response:
"""Serve the SPA's compiled ``index.html`` for any /dashboard/* path."""
index = _spa_index_path()
if not os.path.exists(index):
# Friendly fallback when running before the first frontend build.
return Response(
(
"<!doctype html><html><body style='font-family:sans-serif;padding:40px'>"
"<h1>Dashboard SPA not built</h1>"
"<p>Run <code>cd web && npm install && npm run build</code> to "
"generate <code>core/templates/static/dist/index.html</code>, "
"or use the legacy dashboard at "
"<a href='/dashboard-legacy'>/dashboard-legacy</a>.</p>"
"</body></html>"
),
status_code=status_code,
media_type="text/html",
)
return Response(
_render_spa_index(index),
status_code=status_code,
media_type="text/html",
)
async def redirect_dashboard_v2(request: Request) -> Response:
"""308 redirect old /dashboard-v2/* URLs to the new /dashboard/* prefix."""
suffix = request.path_params.get("path", "")
target = "/dashboard"
if suffix:
target += f"/{suffix}"
elif request.url.path.endswith("/"):
target += "/"
if request.url.query:
target += f"?{request.url.query}"
return RedirectResponse(url=target, status_code=308)
def register_spa_routes(mcp: Any) -> None:
"""Register the SPA support routes on a FastMCP instance.
Mounts:
- ``/static/dist`` (StaticFiles) for hashed JS/CSS/asset bundles.
- ``/api/me`` and ``/api/i18n/{lang}`` JSON endpoints.
- ``/dashboard`` and ``/dashboard/{path:path}`` SPA index serving.
"""
logger.info("Registering SPA (/dashboard) routes...")
# JSON helpers
mcp.custom_route("/api/me", methods=["GET"])(api_me)
mcp.custom_route("/api/i18n/{lang}", methods=["GET"])(api_i18n)
mcp.custom_route("/api/plugins", methods=["GET"])(api_plugins)
# SPA catch-alls. Both the bare path and any sub-path resolve to index.html
# so react-router-dom can take over.
mcp.custom_route("/dashboard", methods=["GET"])(serve_spa)
mcp.custom_route("/dashboard/", methods=["GET"])(serve_spa)
mcp.custom_route("/dashboard/{path:path}", methods=["GET"])(serve_spa)
mcp.custom_route("/dashboard-v2", methods=["GET"])(redirect_dashboard_v2)
mcp.custom_route("/dashboard-v2/", methods=["GET"])(redirect_dashboard_v2)
mcp.custom_route("/dashboard-v2/{path:path}", methods=["GET"])(redirect_dashboard_v2)
# Static assets (JS/CSS bundles) — only mount if dist/ exists, otherwise
# FastMCP's underlying Starlette app refuses the mount on a missing dir.
if os.path.isdir(SPA_DIST_DIR):
try:
# FastMCP exposes its inner Starlette app via .http_app() in v3+.
# Fall back to .app if available.
inner_app = None
for attr in ("http_app", "app", "_app"):
candidate = getattr(mcp, attr, None)
inner_app = candidate() if callable(candidate) else candidate
if inner_app is not None:
break
if inner_app is not None and hasattr(inner_app, "mount"):
inner_app.mount(
"/static/dist",
StaticFiles(directory=SPA_DIST_DIR, check_dir=False),
name="spa-dist",
)
logger.info("Mounted SPA static at /static/dist -> %s", SPA_DIST_DIR)
else:
# If we can't mount, fall back to a per-file route handler so
# bundles still load. This is slower but functional.
async def _serve_dist_file(request: Request) -> Response:
rel = request.path_params.get("filename", "")
full = os.path.normpath(os.path.join(SPA_DIST_DIR, rel))
if not full.startswith(SPA_DIST_DIR) or not os.path.isfile(full):
return Response(status_code=404)
return FileResponse(full)
mcp.custom_route("/static/dist/{filename:path}", methods=["GET"])(_serve_dist_file)
logger.info("Registered fallback /static/dist/{path} route")
except Exception as exc: # pragma: no cover — defensive
logger.warning("Could not mount SPA static dir: %s", exc)
else:
logger.info(
"SPA dist not present yet (%s); build with `cd web && npm run build`", SPA_DIST_DIR
)
logger.info("SPA routes registered successfully")

View File

@@ -37,7 +37,7 @@ logger = logging.getLogger(__name__)
_DEFAULT_DATA_DIR = "/app/data" if Path("/app").exists() else "./data" _DEFAULT_DATA_DIR = "/app/data" if Path("/app").exists() else "./data"
# Schema version — increment when adding migrations # Schema version — increment when adding migrations
SCHEMA_VERSION = 13 SCHEMA_VERSION = 14
# Initial schema DDL # Initial schema DDL
_SCHEMA_SQL = """\ _SCHEMA_SQL = """\
@@ -281,6 +281,23 @@ _MIGRATIONS: dict[int, str] = {
# callers don't have to pass `model=...` every time. # callers don't have to pass `model=...` every time.
"ALTER TABLE site_provider_keys ADD COLUMN default_model TEXT;\n" "ALTER TABLE site_provider_keys ADD COLUMN default_model TEXT;\n"
), ),
14: (
# F.19.2.2: every user API key gets full-tier scope by design.
# Tool visibility is gated per-site via ``sites.tool_scope`` and
# per-tool toggles, not per-key. Older keys were issued before
# the F.19.2.0 tier system added ``editor`` / ``settings`` /
# ``install`` between ``read`` and ``write``, so their stored
# scope string ("read write admin" or narrower) doesn't list the
# new tiers explicitly. The universal-tier closure already maps
# ``admin`` to every tier, so functionally these keys see every
# tool — but enumerating the scope names explicitly keeps the
# DB consistent with the dashboard preset dropdown and removes
# any risk if a future change checks scope names directly
# without going through UNIVERSAL_SCOPE_TIERS.
"UPDATE user_api_keys "
"SET scopes = 'read editor settings install write admin' "
"WHERE scopes IN ('read write admin', 'read write', 'admin');\n"
),
} }
@@ -759,6 +776,27 @@ class Database:
) )
return row["cnt"] if row else 0 return row["cnt"] if row else 0
async def count_all_users(self) -> int:
"""Return total number of registered users."""
row = await self.fetchone("SELECT COUNT(*) AS cnt FROM users")
return row["cnt"] if row else 0
async def count_all_sites(self) -> int:
"""Return total number of user-owned sites across all users."""
row = await self.fetchone("SELECT COUNT(*) AS cnt FROM sites")
return row["cnt"] if row else 0
async def count_recent_users(self, days: int = 7) -> int:
"""Return number of users registered in the last N days."""
import time
cutoff = time.time() - days * 86400
row = await self.fetchone(
"SELECT COUNT(*) AS cnt FROM users WHERE created_at > ?",
(cutoff,),
)
return row["cnt"] if row else 0
# ------------------------------------------------------------------ # ------------------------------------------------------------------
# Settings CRUD (Phase 4C.3) # Settings CRUD (Phase 4C.3)
# ------------------------------------------------------------------ # ------------------------------------------------------------------

View File

@@ -7,7 +7,7 @@ Each endpoint exposes only the tools relevant to its purpose.
Endpoints: Endpoints:
/mcp - Admin endpoint (all tools, requires Master API Key) /mcp - Admin endpoint (all tools, requires Master API Key)
/mcp/wordpress - WordPress tools only (92 tools) /mcp/wordpress - WordPress tools only (92 tools)
/mcp/wordpress-advanced - WordPress Advanced tools (22 tools) /mcp/wordpress-specialist - WordPress Specialist tools (companion-backed)
/mcp/gitea - Gitea tools only (55 tools) /mcp/gitea - Gitea tools only (55 tools)
/mcp/project/{id} - Project-specific tools /mcp/project/{id} - Project-specific tools

View File

@@ -16,7 +16,7 @@ class EndpointType(Enum):
SYSTEM = "system" # Phase X.3 - System tools only SYSTEM = "system" # Phase X.3 - System tools only
WORDPRESS = "wordpress" WORDPRESS = "wordpress"
WOOCOMMERCE = "woocommerce" WOOCOMMERCE = "woocommerce"
WORDPRESS_ADVANCED = "wordpress_advanced" WORDPRESS_SPECIALIST = "wordpress_specialist"
GITEA = "gitea" GITEA = "gitea"
N8N = "n8n" N8N = "n8n"
SUPABASE = "supabase" # Phase G SUPABASE = "supabase" # Phase G
@@ -181,15 +181,28 @@ ENDPOINT_CONFIGS = {
}, },
max_tools=35, max_tools=35,
), ),
# WordPress Advanced endpoint - advanced operations # F.19.1 WordPress Specialist endpoint - companion-backed advanced
EndpointType.WORDPRESS_ADVANCED: EndpointConfig( # management (plugins/themes/users/options/cron/maintenance). No
path="/wordpress-advanced", # Docker socket; only needs Airano MCP Bridge v2.11.0+ on the WP
name="WordPress Advanced", # side. Currently 6 read-only tools; F.19.2 will expand the surface.
description="WordPress advanced operations (database, bulk, system)", EndpointType.WORDPRESS_SPECIALIST: EndpointConfig(
endpoint_type=EndpointType.WORDPRESS_ADVANCED, path="/wordpress-specialist",
plugin_types=["wordpress_advanced"], name="WordPress Specialist",
description="Specialist WordPress management (plugins, themes, users, options, cron) — companion-backed",
endpoint_type=EndpointType.WORDPRESS_SPECIALIST,
plugin_types=["wordpress_specialist"],
require_master_key=False, require_master_key=False,
allowed_scopes={"admin"}, # Admin scope required allowed_scopes={"read", "write", "admin"},
# Same blacklist as the basic wordpress endpoint — keep system
# tools off the per-plugin endpoint regardless of how the tool
# registry expands.
tool_blacklist={
"manage_api_keys_create",
"manage_api_keys_delete",
"manage_api_keys_rotate",
"oauth_register_client",
"oauth_revoke_client",
},
max_tools=30, max_tools=30,
), ),
# Gitea endpoint - Git repository management # Gitea endpoint - Git repository management

View File

@@ -162,10 +162,13 @@ class MCPEndpointFactory:
Returns: Returns:
Plugin type or None for system tools Plugin type or None for system tools
""" """
# Check for wordpress_advanced first (before wordpress_) # Check the multi-word WordPress variant BEFORE the bare
# Tools are named: wordpress_advanced_wp_db_*, wordpress_advanced_bulk_*, wordpress_advanced_system_* # ``wordpress_`` prefix — tool names are
if tool_name.startswith("wordpress_advanced_"): # ``wordpress_specialist_wp_plugin_list``,
return "wordpress_advanced" # ``wordpress_create_post`` etc. (``wordpress_advanced`` was
# sunset in F.19.3.2-.3 / 2026-05-04.)
if tool_name.startswith("wordpress_specialist_"):
return "wordpress_specialist"
if tool_name.startswith("wordpress_"): if tool_name.startswith("wordpress_"):
return "wordpress" return "wordpress"

View File

@@ -58,7 +58,7 @@ class EndpointRegistry:
""" """
Initialize the default set of endpoints. Initialize the default set of endpoints.
Creates admin, wordpress, wordpress-advanced, and gitea endpoints. Creates admin, wordpress, wordpress-specialist, and gitea endpoints.
""" """
if self._initialized: if self._initialized:
logger.warning("Endpoints already initialized") logger.warning("Endpoints already initialized")

View File

@@ -520,7 +520,7 @@ class HealthMonitor:
if not self.site_manager: if not self.site_manager:
return {"healthy": False, "message": "SiteManager not available"} return {"healthy": False, "message": "SiteManager not available"}
# Look up site info by full_id (handles multi-word plugin types like wordpress_advanced) # Look up site info by full_id (handles multi-word plugin types like wordpress_specialist)
site_info = self._find_site_info(project_id) site_info = self._find_site_info(project_id)
if not site_info: if not site_info:
return {"healthy": False, "message": f"Site not found: {project_id}"} return {"healthy": False, "message": f"Site not found: {project_id}"}
@@ -547,7 +547,11 @@ class HealthMonitor:
) )
# Fallback: authenticated health check for WordPress-based plugins # Fallback: authenticated health check for WordPress-based plugins
if plugin_type in ("wordpress", "wordpress_advanced", "woocommerce"): if plugin_type in (
"wordpress",
"wordpress_specialist",
"woocommerce",
):
try: try:
import aiohttp import aiohttp

View File

@@ -114,8 +114,13 @@ class OAuthServer:
error_description="Only S256 code_challenge_method is supported (OAuth 2.1)", error_description="Only S256 code_challenge_method is supported (OAuth 2.1)",
) )
# Validate scope # Validate scope. F.19.2.2: default to every tier explicitly so
requested_scopes = scope.split() if scope else ["read", "write", "admin"] # an OAuth client without a scope hint gets the same surface as a
# dashboard-issued user key. The binding narrowing is per-site
# (intersection with the site's tool_scope preset).
requested_scopes = (
scope.split() if scope else ["read", "editor", "settings", "install", "write", "admin"]
)
for s in requested_scopes: for s in requested_scopes:
if s not in client.allowed_scopes: if s not in client.allowed_scopes:
raise OAuthError( raise OAuthError(

View File

@@ -7,15 +7,24 @@ plugins listed in the ENABLED_PLUGINS setting (DB > ENV > default).
Usage: Usage:
from core.plugin_visibility import get_public_plugin_types, is_plugin_public from core.plugin_visibility import get_public_plugin_types, is_plugin_public
public_types = get_public_plugin_types() # {"wordpress", "woocommerce", "supabase"} public_types = get_public_plugin_types()
if is_plugin_public("gitea"): # True if is_plugin_public("gitea"): # True
... ...
""" """
import os import os
# Default plugins available to public (OAuth) users # Default plugins available to public (OAuth) users.
DEFAULT_PUBLIC_PLUGINS = {"wordpress", "woocommerce", "supabase", "openpanel", "gitea"} DEFAULT_PUBLIC_PLUGINS = {
"wordpress",
"woocommerce",
"wordpress_specialist",
"supabase",
"openpanel",
"gitea",
"n8n",
"coolify",
}
def _parse_plugins(val: str) -> set[str]: def _parse_plugins(val: str) -> set[str]:

View File

@@ -3,7 +3,10 @@
Usage: Usage:
from core.settings import get_setting from core.settings import get_setting
enabled = await get_setting("ENABLED_PLUGINS", "wordpress,woocommerce,supabase,openpanel,gitea") enabled = await get_setting(
"ENABLED_PLUGINS",
"wordpress,woocommerce,wordpress_specialist,supabase,openpanel,gitea,n8n,coolify",
)
max_sites = int(await get_setting("MAX_SITES_PER_USER", "10")) max_sites = int(await get_setting("MAX_SITES_PER_USER", "10"))
""" """
@@ -15,9 +18,14 @@ logger = logging.getLogger(__name__)
# Cached plugin set for sync access (updated when settings change) # Cached plugin set for sync access (updated when settings change)
_cached_plugins: set[str] | None = None _cached_plugins: set[str] | None = None
# Cached int settings for sync access (updated when settings change)
_cached_max_sites: int | None = None
_cached_rate_per_min: int | None = None
_cached_rate_per_hr: int | None = None
# Default values for all managed settings # Default values for all managed settings
SETTING_DEFAULTS: dict[str, str] = { SETTING_DEFAULTS: dict[str, str] = {
"ENABLED_PLUGINS": "wordpress,woocommerce,supabase,openpanel,gitea", "ENABLED_PLUGINS": "wordpress,woocommerce,wordpress_specialist,supabase,openpanel,gitea,n8n,coolify",
"MAX_SITES_PER_USER": "10", "MAX_SITES_PER_USER": "10",
"USER_RATE_LIMIT_PER_MIN": "30", "USER_RATE_LIMIT_PER_MIN": "30",
"USER_RATE_LIMIT_PER_HR": "500", "USER_RATE_LIMIT_PER_HR": "500",
@@ -84,15 +92,77 @@ async def get_setting(key: str, default: str | None = None) -> str | None:
return SETTING_DEFAULTS.get(key) return SETTING_DEFAULTS.get(key)
def get_cached_max_sites() -> int:
"""Return the cached MAX_SITES_PER_USER value (sync, uses last refresh)."""
if _cached_max_sites is not None:
return _cached_max_sites
env_val = os.environ.get("MAX_SITES_PER_USER")
if env_val:
try:
return max(1, int(env_val))
except ValueError:
pass
return int(SETTING_DEFAULTS["MAX_SITES_PER_USER"])
def get_cached_rate_per_min() -> int:
"""Return the cached USER_RATE_LIMIT_PER_MIN value (sync, uses last refresh)."""
if _cached_rate_per_min is not None:
return _cached_rate_per_min
env_val = os.environ.get("USER_RATE_LIMIT_PER_MIN")
if env_val:
try:
return max(1, int(env_val))
except ValueError:
pass
return int(SETTING_DEFAULTS["USER_RATE_LIMIT_PER_MIN"])
def get_cached_rate_per_hr() -> int:
"""Return the cached USER_RATE_LIMIT_PER_HR value (sync, uses last refresh)."""
if _cached_rate_per_hr is not None:
return _cached_rate_per_hr
env_val = os.environ.get("USER_RATE_LIMIT_PER_HR")
if env_val:
try:
return max(1, int(env_val))
except ValueError:
pass
return int(SETTING_DEFAULTS["USER_RATE_LIMIT_PER_HR"])
async def refresh_plugin_cache() -> None: async def refresh_plugin_cache() -> None:
"""Refresh the cached plugin set from DB/ENV/default.""" """Refresh all sync-readable setting caches from DB/ENV/default."""
global _cached_plugins global _cached_plugins, _cached_max_sites, _cached_rate_per_min, _cached_rate_per_hr
val = await get_setting("ENABLED_PLUGINS") val = await get_setting("ENABLED_PLUGINS")
if val: if val:
_cached_plugins = {p.strip().lower() for p in val.split(",") if p.strip()} _cached_plugins = {p.strip().lower() for p in val.split(",") if p.strip()}
else: else:
_cached_plugins = None _cached_plugins = None
try:
raw = await get_setting("MAX_SITES_PER_USER")
_cached_max_sites = max(1, int(raw)) if raw else int(SETTING_DEFAULTS["MAX_SITES_PER_USER"])
except (ValueError, TypeError):
_cached_max_sites = int(SETTING_DEFAULTS["MAX_SITES_PER_USER"])
try:
raw = await get_setting("USER_RATE_LIMIT_PER_MIN")
_cached_rate_per_min = (
max(1, int(raw)) if raw else int(SETTING_DEFAULTS["USER_RATE_LIMIT_PER_MIN"])
)
except (ValueError, TypeError):
_cached_rate_per_min = int(SETTING_DEFAULTS["USER_RATE_LIMIT_PER_MIN"])
try:
raw = await get_setting("USER_RATE_LIMIT_PER_HR")
_cached_rate_per_hr = (
max(1, int(raw)) if raw else int(SETTING_DEFAULTS["USER_RATE_LIMIT_PER_HR"])
)
except (ValueError, TypeError):
_cached_rate_per_hr = int(SETTING_DEFAULTS["USER_RATE_LIMIT_PER_HR"])
async def save_setting(key: str, value: str) -> None: async def save_setting(key: str, value: str) -> None:
"""Save a setting to database and refresh caches.""" """Save a setting to database and refresh caches."""
@@ -101,7 +171,6 @@ async def save_setting(key: str, value: str) -> None:
db = get_database() db = get_database()
await db.set_setting(key, value) await db.set_setting(key, value)
if key == "ENABLED_PLUGINS":
await refresh_plugin_cache() await refresh_plugin_cache()
@@ -112,7 +181,6 @@ async def delete_setting_value(key: str) -> bool:
db = get_database() db = get_database()
deleted = await db.delete_setting(key) deleted = await db.delete_setting(key)
if key == "ENABLED_PLUGINS":
await refresh_plugin_cache() await refresh_plugin_cache()
return deleted return deleted

View File

@@ -19,8 +19,8 @@ import aiohttp
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
# Maximum sites per user (configurable via env var) # Fallback used when the settings DB is unavailable at import time
MAX_SITES_PER_USER = int(os.getenv("MAX_SITES_PER_USER", "10")) _MAX_SITES_FALLBACK = int(os.getenv("MAX_SITES_PER_USER", "10"))
# Plugin credential field definitions — drives the dynamic "Add Site" form # Plugin credential field definitions — drives the dynamic "Add Site" form
# and server-side validation. Each field has: # and server-side validation. Each field has:
@@ -104,13 +104,18 @@ PLUGIN_CREDENTIAL_FIELDS: dict[str, list[dict[str, Any]]] = {
), ),
}, },
], ],
"wordpress_advanced": [ # F.19.1 WordPress Specialist — companion-backed, no Docker socket.
# Same auth shape as the basic wordpress plugin (Application Password)
# but the WP user behind the password must have ``manage_options``,
# AND Airano MCP Bridge v2.11.0+ must be active on the WP site for
# the admin namespace to exist.
"wordpress_specialist": [
{ {
"name": "username", "name": "username",
"label": "Username", "label": "Username",
"type": "text", "type": "text",
"required": True, "required": True,
"hint": "Your WordPress admin username (HTTP Basic username for every API call).", "hint": "WordPress admin username (the user that owns the Application Password).",
}, },
{ {
"name": "app_password", "name": "app_password",
@@ -119,16 +124,11 @@ PLUGIN_CREDENTIAL_FIELDS: dict[str, list[dict[str, Any]]] = {
"required": True, "required": True,
"hint": ( "hint": (
"WordPress Admin → Users → Profile → Application Passwords. " "WordPress Admin → Users → Profile → Application Passwords. "
"IS the API credential — no separate API key needed." "The user MUST have manage_options. Requires Airano MCP "
"Bridge v2.11.0+ on the WP side — install from "
"wordpress-plugin/airano-mcp-bridge.zip in the repo."
), ),
}, },
{
"name": "container",
"label": "Docker Container Name",
"type": "text",
"required": False,
"hint": "Docker container running WordPress (for WP-CLI access). Leave empty if unused.",
},
], ],
"gitea": [ "gitea": [
{ {
@@ -268,7 +268,7 @@ PLUGIN_CREDENTIAL_FIELDS: dict[str, list[dict[str, Any]]] = {
PLUGIN_DISPLAY_NAMES: dict[str, str] = { PLUGIN_DISPLAY_NAMES: dict[str, str] = {
"wordpress": "WordPress", "wordpress": "WordPress",
"woocommerce": "WooCommerce", "woocommerce": "WooCommerce",
"wordpress_advanced": "WordPress Advanced", "wordpress_specialist": "WordPress Specialist",
"gitea": "Gitea", "gitea": "Gitea",
"n8n": "n8n", "n8n": "n8n",
"supabase": "Supabase", "supabase": "Supabase",
@@ -282,7 +282,12 @@ PLUGIN_DISPLAY_NAMES: dict[str, str] = {
_HEALTH_ENDPOINTS: dict[str, dict[str, Any]] = { _HEALTH_ENDPOINTS: dict[str, dict[str, Any]] = {
"wordpress": {"path": "/wp-json/wp/v2/users/me", "method": "GET"}, "wordpress": {"path": "/wp-json/wp/v2/users/me", "method": "GET"},
"woocommerce": {"path": "/wp-json/wc/v3/system_status", "method": "GET"}, "woocommerce": {"path": "/wp-json/wc/v3/system_status", "method": "GET"},
"wordpress_advanced": {"path": "/wp-json/wp/v2/users/me", "method": "GET"}, # F.19.1: hits the companion's lightest admin route. Returns 404 if
# Airano MCP Bridge v2.11.0+ is missing, 403 if the user lacks
# ``manage_options``, 200 with a small JSON body otherwise. This
# makes "Test Connection" surface the actual end-to-end requirement
# instead of just "auth works" (which /users/me would).
"wordpress_specialist": {"path": "/wp-json/airano-mcp/v1/admin/maintenance", "method": "GET"},
"gitea": {"path": "/api/v1/user", "method": "GET"}, "gitea": {"path": "/api/v1/user", "method": "GET"},
"n8n": {"path": "/healthz", "method": "GET"}, "n8n": {"path": "/healthz", "method": "GET"},
"supabase": {"path": "/rest/v1/", "method": "GET"}, "supabase": {"path": "/rest/v1/", "method": "GET"},
@@ -314,28 +319,53 @@ def get_credential_fields(plugin_type: str) -> list[dict[str, Any]]:
return fields return fields
def get_user_credential_fields() -> dict[str, list[dict[str, Any]]]: def get_user_credential_fields(is_admin: bool = False) -> dict[str, list[dict[str, Any]]]:
"""Get credential fields for public (non-admin) users. """Get credential fields for the dashboard's Add/Edit Site forms.
Only includes plugins enabled via ENABLED_PLUGINS env var. Admin users see every registered plugin so they can use admin-only
plugins (wordpress_specialist) without having to add them to
``ENABLED_PLUGINS`` for everyone. Public users get only the plugins
enabled via ``ENABLED_PLUGINS`` env var (Track F.1).
F.19.1 (2026-05-01) — surfaced when an admin OAuth user added a
wordpress_specialist site and the manage page rendered an empty
credential form on revisit because the unfiltered map was being
filtered for public visibility. The site row exists but the
template can't render its fields.
Args:
is_admin: When True, return every plugin's credential fields.
Defaults to False for backward compatibility.
Returns: Returns:
Filtered dict of plugin_type -> field definitions. Dict of plugin_type -> field definitions.
""" """
if is_admin:
return dict(PLUGIN_CREDENTIAL_FIELDS)
from core.plugin_visibility import get_public_plugin_types from core.plugin_visibility import get_public_plugin_types
public = get_public_plugin_types() public = get_public_plugin_types()
return {k: v for k, v in PLUGIN_CREDENTIAL_FIELDS.items() if k in public} return {k: v for k, v in PLUGIN_CREDENTIAL_FIELDS.items() if k in public}
def get_user_plugin_names() -> dict[str, str]: def get_user_plugin_names(is_admin: bool = False) -> dict[str, str]:
"""Get plugin display names for public (non-admin) users. """Get plugin display names for the dashboard's plugin pickers.
Only includes plugins enabled via ENABLED_PLUGINS env var. Admin users see every registered plugin (so the Add Site dropdown
surfaces ``wordpress_specialist`` etc.); public users get only the
plugins enabled via ``ENABLED_PLUGINS`` env var.
Args:
is_admin: When True, return every plugin's display name.
Defaults to False for backward compatibility.
Returns: Returns:
Filtered dict of plugin_type -> display name. Dict of plugin_type -> display name.
""" """
if is_admin:
return dict(PLUGIN_DISPLAY_NAMES)
from core.plugin_visibility import get_public_plugin_types from core.plugin_visibility import get_public_plugin_types
public = get_public_plugin_types() public = get_public_plugin_types()
@@ -387,7 +417,7 @@ async def validate_site_connection(
# Build auth headers per plugin type # Build auth headers per plugin type
headers: dict[str, str] = {} headers: dict[str, str] = {}
if plugin_type in ("wordpress", "wordpress_advanced"): if plugin_type in ("wordpress", "wordpress_specialist"):
import base64 import base64
username = credentials.get("username", "") username = credentials.get("username", "")
@@ -502,10 +532,13 @@ async def create_user_site(
db = get_database() db = get_database()
# Check site limit # Check site limit (reads DB > ENV > default so dashboard/settings changes take effect)
from core.settings import get_cached_max_sites
max_sites = get_cached_max_sites()
count = await db.count_sites_by_user(user_id) count = await db.count_sites_by_user(user_id)
if count >= MAX_SITES_PER_USER: if count >= max_sites:
raise ValueError(f"Site limit reached ({MAX_SITES_PER_USER} sites per user)") raise ValueError(f"Site limit reached ({max_sites} sites per user)")
# Check alias uniqueness (DB constraint will also catch this) # Check alias uniqueness (DB constraint will also catch this)
existing = await db.get_site_by_alias(user_id, alias) existing = await db.get_site_by_alias(user_id, alias)

View File

@@ -1,18 +1,100 @@
<!DOCTYPE html> <!DOCTYPE html>
<html lang="{{ lang|default('en') }}"> <html lang="{{ lang|default('en') }}" dir="{% if lang|default('en') == 'fa' %}rtl{% else %}ltr{% endif %}" data-theme="dark" data-theme-pref="dark">
<head> <head>
<meta charset="UTF-8"> <meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta name="color-scheme" content="dark light">
<title>{% block title %}MCP Hub{% endblock %}</title> <title>{% block title %}MCP Hub{% endblock %}</title>
<link rel="icon" type="image/svg+xml" href="/static/logo.svg"> <link rel="icon" type="image/svg+xml" href="/static/logo.svg">
<script>
(function () {
try {
var html = document.documentElement;
var raw = localStorage.getItem("mcphub-ui");
var parsed = raw ? JSON.parse(raw) : null;
var state = parsed && parsed.state ? parsed.state : null;
var serverLang = html.getAttribute("lang") === "fa" ? "fa" : "en";
var storedLang = state && state.lang === "fa" ? "fa" : state && state.lang === "en" ? "en" : null;
var lang = storedLang || serverLang;
html.setAttribute("lang", lang);
html.setAttribute("dir", lang === "fa" ? "rtl" : "ltr");
var themePref = state && (state.theme === "light" || state.theme === "system" || state.theme === "dark")
? state.theme
: "dark";
var resolved = themePref;
if (themePref === "system" && window.matchMedia) {
resolved = window.matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light";
}
html.setAttribute("data-theme", resolved);
html.setAttribute("data-theme-pref", themePref);
html.classList.toggle("dark", resolved === "dark");
html.style.colorScheme = resolved;
if (state && state.brandHue) {
html.style.setProperty("--brand-hue", String(state.brandHue));
}
if (storedLang && storedLang !== serverLang && location.pathname === "/oauth/authorize") {
var url = new URL(location.href);
if (!url.searchParams.has("lang")) {
url.searchParams.set("lang", storedLang);
location.replace(url.toString());
}
}
} catch (_) {
document.documentElement.classList.add("dark");
}
})();
</script>
<!-- Tailwind CSS from CDN --> <!-- Tailwind CSS from CDN -->
<script src="https://cdn.tailwindcss.com"></script> <script src="https://cdn.tailwindcss.com"></script>
<script>
tailwind.config = { darkMode: "class" };
</script>
<link rel="preconnect" href="https://fonts.bunny.net" crossorigin>
<link
href="https://fonts.bunny.net/css2?family=Geist:wght@300;400;500;600;700&family=Geist+Mono:wght@400;500;600&family=Vazirmatn:wght@300;400;500;600;700&display=swap"
rel="stylesheet">
<!-- Custom styling --> <!-- Custom styling -->
<style> <style>
body { body {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif; font-family: Geist, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
background: rgb(249 250 251);
}
html[lang="fa"] body,
html[lang="fa"] button,
html[lang="fa"] input,
html[lang="fa"] textarea,
html[lang="fa"] select {
font-family: Vazirmatn, Geist, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Arial, sans-serif;
}
html[data-theme="dark"] body {
background: rgb(17 24 39);
}
html[data-theme="dark"] .dark\:bg-gray-900 {
background-color: rgb(17 24 39);
}
html[data-theme="dark"] .dark\:bg-gray-800 {
background-color: rgb(31 41 55);
}
html[data-theme="dark"] .dark\:bg-gray-700 {
background-color: rgb(55 65 81);
}
html[data-theme="dark"] .dark\:text-white {
color: rgb(255 255 255);
}
html[data-theme="dark"] .dark\:text-gray-200 {
color: rgb(229 231 235);
}
html[data-theme="dark"] .dark\:text-gray-300 {
color: rgb(209 213 219);
}
html[data-theme="dark"] .dark\:text-gray-400 {
color: rgb(156 163 175);
} }
.auth-container { .auth-container {
min-height: 100vh; min-height: 100vh;

View File

@@ -135,9 +135,6 @@
('services', t.get('services', 'Services'), 'M19 11H5m14 0a2 2 0 012 2v6a2 2 0 01-2 2H5a2 2 0 01-2-2v-6a2 2 0 012-2m14 0V9a2 2 0 00-2-2M5 11V9a2 2 0 012-2m0 0V5a2 2 0 012-2h6a2 2 0 012 2v2M7 7h10', '/dashboard/services'), ('services', t.get('services', 'Services'), 'M19 11H5m14 0a2 2 0 012 2v6a2 2 0 01-2 2H5a2 2 0 01-2-2v-6a2 2 0 012-2m14 0V9a2 2 0 00-2-2M5 11V9a2 2 0 012-2m0 0V5a2 2 0 012-2h6a2 2 0 012 2v2M7 7h10', '/dashboard/services'),
('keys', t.get('keys', 'API Keys'), 'M15 7a2 2 0 012 2m4 0a6 6 0 01-7.743 5.743L11 17H9v2H7v2H4a1 1 0 ('keys', t.get('keys', 'API Keys'), 'M15 7a2 2 0 012 2m4 0a6 6 0 01-7.743 5.743L11 17H9v2H7v2H4a1 1 0
01-1-1v-2.586a1 1 0 01.293-.707l5.964-5.964A6 6 0 1121 9z', '/dashboard/keys'), 01-1-1v-2.586a1 1 0 01.293-.707l5.964-5.964A6 6 0 1121 9z', '/dashboard/keys'),
('oauth_clients', t.oauth_clients, 'M9 12l2 2 4-4m5.618-4.016A11.955 11.955 0 0112 2.944a11.955 11.955 0
01-8.618 3.04A12.02 12.02 0 003 9c0 5.591 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622
0-1.042-.133-2.052-.382-3.016z', '/dashboard/oauth-clients'),
] %} ] %}
{# ── Admin-only nav items ── #} {# ── Admin-only nav items ── #}

View File

@@ -159,10 +159,8 @@
<p class="text-sm text-green-700 dark:text-green-400"> <p class="text-sm text-green-700 dark:text-green-400">
{% if lang == 'fa' %} {% if lang == 'fa' %}
<strong>نکته:</strong> فقط آدرس URL کافی است. هنگام اتصال می‌توانید با <strong>API Key</strong> یا <strong>GitHub/Google</strong> احراز هویت کنید. <strong>نکته:</strong> فقط آدرس URL کافی است. هنگام اتصال می‌توانید با <strong>API Key</strong> یا <strong>GitHub/Google</strong> احراز هویت کنید.
ساخت <a href="/dashboard/oauth-clients{% if lang and lang != 'en' %}?lang={{ lang }}{% endif %}" class="underline">OAuth Client</a> اختیاری است.
{% else %} {% else %}
<strong>Tip:</strong> You only need the URL above. When connecting, you can authenticate with an <strong>API Key</strong> or <strong>GitHub/Google</strong>. <strong>Tip:</strong> You only need the URL above. When connecting, you can authenticate with an <strong>API Key</strong> or <strong>GitHub/Google</strong>.
Creating an <a href="/dashboard/oauth-clients{% if lang and lang != 'en' %}?lang={{ lang }}{% endif %}" class="underline">OAuth Client</a> is optional.
{% endif %} {% endif %}
</p> </p>
</div> </div>

View File

@@ -209,7 +209,7 @@
{% set plugin_colors = { {% set plugin_colors = {
'wordpress': 'bg-blue-500', 'wordpress': 'bg-blue-500',
'woocommerce': 'bg-purple-500', 'woocommerce': 'bg-purple-500',
'wordpress_advanced': 'bg-indigo-500', 'wordpress_specialist': 'bg-indigo-500',
'gitea': 'bg-green-500', 'gitea': 'bg-green-500',
'n8n': 'bg-orange-500', 'n8n': 'bg-orange-500',
'supabase': 'bg-emerald-500', 'supabase': 'bg-emerald-500',
@@ -221,7 +221,7 @@
{% set plugin_icons = { {% set plugin_icons = {
'wordpress': 'W', 'wordpress': 'W',
'woocommerce': 'WC', 'woocommerce': 'WC',
'wordpress_advanced': 'WA', 'wordpress_specialist': 'WS',
'gitea': 'G', 'gitea': 'G',
'n8n': 'n8n', 'n8n': 'n8n',
'supabase': 'SB', 'supabase': 'SB',
@@ -377,7 +377,7 @@
{% set plugin_colors = { {% set plugin_colors = {
'wordpress': 'bg-blue-500', 'wordpress': 'bg-blue-500',
'woocommerce': 'bg-purple-500', 'woocommerce': 'bg-purple-500',
'wordpress_advanced': 'bg-indigo-500', 'wordpress_specialist': 'bg-indigo-500',
'gitea': 'bg-green-500', 'gitea': 'bg-green-500',
'n8n': 'bg-orange-500', 'n8n': 'bg-orange-500',
'supabase': 'bg-emerald-500', 'supabase': 'bg-emerald-500',

View File

@@ -275,10 +275,8 @@
<p class="text-sm text-green-700 dark:text-green-400"> <p class="text-sm text-green-700 dark:text-green-400">
{% if lang == 'fa' %} {% if lang == 'fa' %}
<strong>نکته:</strong> فقط آدرس URL کافی است. هنگام اتصال می‌توانید با <strong>API Key</strong> یا <strong>GitHub/Google</strong> احراز هویت کنید. <strong>نکته:</strong> فقط آدرس URL کافی است. هنگام اتصال می‌توانید با <strong>API Key</strong> یا <strong>GitHub/Google</strong> احراز هویت کنید.
ساخت <a href="/dashboard/oauth-clients{% if lang and lang != 'en' %}?lang={{ lang }}{% endif %}" class="underline">OAuth Client</a> اختیاری است.
{% else %} {% else %}
<strong>Tip:</strong> You only need the URL above. When connecting, you can authenticate with an <strong>API Key</strong> or <strong>GitHub/Google</strong>. <strong>Tip:</strong> You only need the URL above. When connecting, you can authenticate with an <strong>API Key</strong> or <strong>GitHub/Google</strong>.
Creating an <a href="/dashboard/oauth-clients{% if lang and lang != 'en' %}?lang={{ lang }}{% endif %}" class="underline">OAuth Client</a> is optional.
{% endif %} {% endif %}
</p> </p>
</div> </div>

View File

@@ -93,7 +93,7 @@
{% endif %} {% endif %}
<!-- Login Form --> <!-- Login Form -->
<form method="POST" action="/dashboard/login" class="space-y-6"> <form method="POST" action="/dashboard-legacy/login" class="space-y-6">
<input type="hidden" name="csrf_token" value="{{ request.state.csrf_token }}"> <input type="hidden" name="csrf_token" value="{{ request.state.csrf_token }}">
<input type="hidden" name="next" value="{{ next_url }}"> <input type="hidden" name="next" value="{{ next_url }}">

View File

@@ -101,7 +101,7 @@
{% set plugin_colors = { {% set plugin_colors = {
'wordpress': 'bg-blue-500', 'wordpress': 'bg-blue-500',
'woocommerce': 'bg-purple-500', 'woocommerce': 'bg-purple-500',
'wordpress_advanced': 'bg-indigo-500', 'wordpress_specialist': 'bg-indigo-500',
'gitea': 'bg-green-500', 'gitea': 'bg-green-500',
'n8n': 'bg-orange-500', 'n8n': 'bg-orange-500',
'supabase': 'bg-emerald-500', 'supabase': 'bg-emerald-500',
@@ -112,7 +112,7 @@
{% set plugin_icons = { {% set plugin_icons = {
'wordpress': 'W', 'wordpress': 'W',
'woocommerce': 'WC', 'woocommerce': 'WC',
'wordpress_advanced': 'WA', 'wordpress_specialist': 'WS',
'gitea': 'G', 'gitea': 'G',
'n8n': 'n8n', 'n8n': 'n8n',
'supabase': 'SB', 'supabase': 'SB',

View File

@@ -21,7 +21,7 @@
'openpanel': 'cyan', 'openpanel': 'cyan',
'appwrite': 'pink', 'appwrite': 'pink',
'directus': 'violet', 'directus': 'violet',
'wordpress_advanced': 'indigo', 'wordpress_specialist': 'indigo',
} %} } %}
{% for svc in services %} {% for svc in services %}

View File

@@ -19,7 +19,7 @@ Required context:
companion_download_url — string, may be empty companion_download_url — string, may be empty
#} #}
{% set fit_status = capability_probe.fit.status %} {% set fit_status = capability_probe.fit.status %}
{% set is_wp = site.plugin_type in ['wordpress', 'wordpress_advanced'] %} {% set is_wp = site.plugin_type == 'wordpress' %}
{% set is_wc = site.plugin_type == 'woocommerce' %} {% set is_wc = site.plugin_type == 'woocommerce' %}
{% set is_wp_like = is_wp or is_wc %} {% set is_wp_like = is_wp or is_wc %}
{% set ai_providers = capability_probe.ai_providers_configured or [] %} {% set ai_providers = capability_probe.ai_providers_configured or [] %}

View File

@@ -7,7 +7,11 @@
<div class="max-w-3xl mx-auto space-y-6"> <div class="max-w-3xl mx-auto space-y-6">
<!-- Header --> <!-- Header -->
<div class="flex items-center gap-4 mb-6"> <div class="flex items-center gap-4 mb-6">
<a href="/dashboard/sites{% if lang and lang != 'en' %}?lang={{ lang }}{% endif %}" {# When the user arrives from /dashboard-v2, link the back arrow
there so they don't dump out into the legacy list. #}
{% set _qs = request.query_params if request else None %}
{% set _from_v2 = _qs and _qs.get('from') == 'dashboard-v2' %}
<a href="{% if _from_v2 %}/dashboard-v2/sites{% else %}/dashboard/sites{% if lang and lang != 'en' %}?lang={{ lang }}{% endif %}{% endif %}"
class="text-gray-500 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white transition-colors"> class="text-gray-500 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white transition-colors">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 19l-7-7 7-7" /> <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 19l-7-7 7-7" />
@@ -244,9 +248,13 @@
}); });
const data = await resp.json(); const data = await resp.json();
if (resp.ok) { if (resp.ok) {
// Redirect to manage page for the new site // If the user came in from the v2 SPA shell, send them
// back there instead of the legacy Jinja list.
const fromV2 = new URLSearchParams(window.location.search).get('from') === 'dashboard-v2';
const siteId = data.site?.id || data.site_id; const siteId = data.site?.id || data.site_id;
if (siteId) { if (fromV2) {
window.location.href = '/dashboard-v2/sites?msg={{ t.site_added }}';
} else if (siteId) {
window.location.href = '/dashboard/sites/' + siteId + '{% if lang and lang != "en" %}?lang={{ lang }}{% endif %}'; window.location.href = '/dashboard/sites/' + siteId + '{% if lang and lang != "en" %}?lang={{ lang }}{% endif %}';
} else { } else {
window.location.href = '/dashboard/sites?msg={{ t.site_added }}{% if lang and lang != "en" %}&lang={{ lang }}{% endif %}'; window.location.href = '/dashboard/sites?msg={{ t.site_added }}{% if lang and lang != "en" %}&lang={{ lang }}{% endif %}';

View File

@@ -5,8 +5,10 @@
{% block content %} {% block content %}
<div class="max-w-2xl mx-auto space-y-6"> <div class="max-w-2xl mx-auto space-y-6">
{% set _qs = request.query_params if request else None %}
{% set _from_v2 = _qs and _qs.get('from') == 'dashboard-v2' %}
<div class="flex items-center gap-4 mb-6"> <div class="flex items-center gap-4 mb-6">
<a href="/dashboard/sites{% if lang and lang != 'en' %}?lang={{ lang }}{% endif %}" <a href="{% if _from_v2 %}/dashboard-v2/sites{% else %}/dashboard/sites{% if lang and lang != 'en' %}?lang={{ lang }}{% endif %}{% endif %}"
class="text-gray-500 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white transition-colors"> class="text-gray-500 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white transition-colors">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 19l-7-7 7-7" /> <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 19l-7-7 7-7" />
@@ -195,7 +197,12 @@
}); });
const data = await resp.json(); const data = await resp.json();
if (resp.ok) { if (resp.ok) {
const fromV2 = new URLSearchParams(window.location.search).get('from') === 'dashboard-v2';
if (fromV2) {
window.location.href = '/dashboard-v2/sites?msg={{ t.site_updated }}';
} else {
window.location.href = '/dashboard/sites?msg={{ t.site_updated }}{% if lang and lang != "en" %}&lang={{ lang }}{% endif %}'; window.location.href = '/dashboard/sites?msg={{ t.site_updated }}{% if lang and lang != "en" %}&lang={{ lang }}{% endif %}';
}
} else { } else {
errorEl.textContent = data.error || 'Failed to update site'; errorEl.textContent = data.error || 'Failed to update site';
errorEl.classList.remove('hidden'); errorEl.classList.remove('hidden');

View File

@@ -6,8 +6,10 @@
{% block content %} {% block content %}
<div class="max-w-3xl mx-auto space-y-6"> <div class="max-w-3xl mx-auto space-y-6">
<!-- Header --> <!-- Header -->
{% set _qs = request.query_params if request else None %}
{% set _from_v2 = _qs and _qs.get('from') == 'dashboard-v2' %}
<div class="flex items-center gap-4 mb-6"> <div class="flex items-center gap-4 mb-6">
<a href="/dashboard/sites{% if lang and lang != 'en' %}?lang={{ lang }}{% endif %}" <a href="{% if _from_v2 %}/dashboard-v2/sites{% else %}/dashboard/sites{% if lang and lang != 'en' %}?lang={{ lang }}{% endif %}{% endif %}"
class="text-gray-500 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white transition-colors"> class="text-gray-500 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white transition-colors">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 19l-7-7 7-7" /> <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 19l-7-7 7-7" />
@@ -625,14 +627,22 @@
fa: 'Application Password ثبت‌شده در Connection Settings باید متعلق به کاربر <strong>Administrator</strong> باشد تا CRUD کامل کار کند. ابزارهای SEO و مدیریت افزونه/قالب علاوه بر آن نیازمند نصب افزونه <code class="font-mono">Airano MCP SEO Bridge</code> روی سایت هستند.' fa: 'Application Password ثبت‌شده در Connection Settings باید متعلق به کاربر <strong>Administrator</strong> باشد تا CRUD کامل کار کند. ابزارهای SEO و مدیریت افزونه/قالب علاوه بر آن نیازمند نصب افزونه <code class="font-mono">Airano MCP SEO Bridge</code> روی سایت هستند.'
} }
}, },
wordpress_advanced: { wordpress_specialist: {
read: { read: {
en: 'The Application Password in Connection Settings must belong to an <strong>Editor</strong>-or-lower user. Read-only WP-CLI operations are enforced by role caps.', en: 'The Application Password in Connection Settings must belong to a WordPress user with <strong>manage_options</strong> (Administrator). Companion plugin <code class="font-mono">Airano MCP Bridge v2.11.0+</code> must be installed and active on the site.',
fa: 'Application Password ثبت‌شده در Connection Settings باید برای کاربری با نقش <strong>Editor</strong> یا پایین‌تر باشد.' fa: 'Application Password ثبت‌شده باید متعلق به کاربری با مجوز <strong>manage_options</strong> (Administrator) باشد. افزونه همراه <code class="font-mono">Airano MCP Bridge v2.11.0+</code> باید روی سایت نصب و فعال باشد.'
},
editor: {
en: 'Same prerequisites as Read, plus companion <code class="font-mono">Airano MCP Bridge v2.13.0+</code> for page editing (Gutenberg blocks, Elementor, Classic) and <code class="font-mono">v2.14.0+</code> for theme file CRUD. Per-tool capability checks: <code>edit_post</code>, <code>edit_themes</code>.',
fa: 'پیش‌نیاز Read به‌علاوهٔ نسخهٔ <code class="font-mono">Airano MCP Bridge v2.13.0+</code> برای ویرایش صفحه (Gutenberg، Elementor، Classic) و <code class="font-mono">v2.14.0+</code> برای ویرایش فایل قالب. Capability موردنیاز: <code>edit_post</code> و <code>edit_themes</code>.'
},
install: {
en: 'Same prerequisites as Editor, plus companion <code class="font-mono">Airano MCP Bridge v2.14.0+</code> for theme install/activate/delete and <code class="font-mono">v2.15.0+</code> for plugin install/activate/deactivate/update. Per-tool capability checks: <code>install_plugins</code>, <code>activate_plugins</code>, <code>install_themes</code>, <code>switch_themes</code>, <code>update_plugins</code>.',
fa: 'پیش‌نیاز Editor به‌علاوهٔ <code class="font-mono">Airano MCP Bridge v2.14.0+</code> برای نصب/فعال‌سازی/حذف قالب و <code class="font-mono">v2.15.0+</code> برای نصب/فعال‌سازی/غیرفعال‌سازی/به‌روزرسانی پلاگین. Capability موردنیاز: <code>install_plugins</code>, <code>activate_plugins</code>, <code>install_themes</code>, <code>switch_themes</code>, <code>update_plugins</code>.'
}, },
admin: { admin: {
en: 'The Application Password in Connection Settings must belong to an <strong>Administrator</strong> and the Docker container name must be correct for shell-level plugin/theme operations. Install <code class="font-mono">Airano MCP SEO Bridge</code> on the site to unlock SEO + plugin/theme tools.', en: 'Same prerequisites as Installer, plus URL/zip install routes and plugin/theme delete. Per-tool capability checks: <code>delete_plugins</code>, <code>delete_themes</code>. PHP file edits additionally require <code class="font-mono">DISALLOW_FILE_EDIT</code> to be unset (or false) in <code class="font-mono">wp-config.php</code>.',
fa: 'Application Password ثبت‌شده باید متعلق به کاربر <strong>Administrator</strong> باشد و نام Docker Container درست وارد شده باشد. برای ابزارهای SEO افزونه <code class="font-mono">Airano MCP SEO Bridge</code> روی سایت نصب باشد.' fa: 'پیش‌نیاز Installer به‌علاوهٔ مسیرهای نصب از URL/zip و حذف پلاگین/قالب. Capability موردنیاز: <code>delete_plugins</code>, <code>delete_themes</code>. ویرایش فایل PHP علاوه بر آن نیازمند تنظیم نشدن (یا false بودن) ثابت <code class="font-mono">DISALLOW_FILE_EDIT</code> در <code class="font-mono">wp-config.php</code> است.'
} }
}, },
woocommerce: { woocommerce: {
@@ -697,11 +707,36 @@
} }
}; };
// F.19.2.2 — destructive-tier warnings. Rendered above the credential
// requirement notice when the user picks ``install`` or ``admin``,
// so they understand the blast radius before the click sticks. Only
// these two tiers warrant a strong warning — everything below is
// recoverable (reads, content edits, theme file writes which
// optimistic-concurrency on sha256, settings which can be reset).
// ``install`` is amber: installs reshape the site but the source
// (wp.org) is curated. ``admin`` is red: arbitrary zip + delete +
// user CRUD have no undo.
const TIER_WARNINGS = {
install: {
severity: 'amber',
en: 'Selecting <strong>Installer</strong> grants the AI agent permission to install + activate plugins and themes from wp.org slugs. Source is curated by wp.org, but installations affect the live site immediately. Recommended: test on a staging site first; review installed plugins regularly via <code>wp_plugin_list</code>.',
fa: 'انتخاب <strong>Installer</strong> به agent اجازه می‌دهد پلاگین و قالب را با slug از wp.org نصب و فعال کند. منبع wp.org curated است اما نصب‌ها بلافاصله روی سایت زنده اعمال می‌شوند. توصیه: ابتدا روی سایت staging تست کنید و افزونه‌های نصب‌شده را با <code>wp_plugin_list</code> به‌طور منظم مرور کنید.'
},
admin: {
severity: 'red',
en: 'Selecting <strong>Admin</strong> grants the AI agent the full destructive surface: install plugins/themes from arbitrary URLs or base64 zips, delete plugins (drops their data with no undo), delete themes, and user CRUD. Use only on sites you fully control where an AI mistake is recoverable from backups. <strong>Not recommended for production sites with real users.</strong>',
fa: 'انتخاب <strong>Admin</strong> به agent سطح کامل تخریبی می‌دهد: نصب پلاگین/قالب از URL یا zip دلخواه، حذف پلاگین (داده‌اش بدون undo پاک می‌شود)، حذف قالب، و CRUD کاربر. فقط روی سایت‌هایی که کامل کنترل دارید و خطای agent از بکاپ قابل بازیابی است استفاده کنید. <strong>برای سایت‌های production با کاربر واقعی توصیه نمی‌شود.</strong>'
}
};
function renderScopeNotice() { function renderScopeNotice() {
const el = document.getElementById('scope-notice'); const el = document.getElementById('scope-notice');
if (!el) return; if (!el) return;
const isFa = {% if lang == 'fa' %}true{% else %}false{% endif %}; const isFa = {% if lang == 'fa' %}true{% else %}false{% endif %};
const scopeLabels = { read: 'Read', 'read:sensitive': 'Read+Secrets', deploy: 'Deploy', write: 'Write', admin: 'Full Access' }; const scopeLabels = {
read: 'Read', editor: 'Editor', settings: 'Settings', install: 'Installer',
'read:sensitive': 'Read+Secrets', deploy: 'Deploy', write: 'Write', admin: 'Admin'
};
if (currentScope === 'custom') { if (currentScope === 'custom') {
el.classList.add('hidden'); el.classList.add('hidden');
@@ -710,22 +745,46 @@
} }
const guide = (CREDENTIAL_GUIDES[pluginType] || {})[currentScope]; const guide = (CREDENTIAL_GUIDES[pluginType] || {})[currentScope];
if (!guide) { const warning = TIER_WARNINGS[currentScope];
if (!guide && !warning) {
el.classList.add('hidden'); el.classList.add('hidden');
el.innerHTML = ''; el.innerHTML = '';
return; return;
} }
const blocks = [];
// Destructive-tier warning first (so eyes hit it before requirements).
if (warning) {
const palette = warning.severity === 'red'
? 'bg-red-50 dark:bg-red-500/10 border-red-300 dark:border-red-500/40 text-red-800 dark:text-red-200'
: 'bg-amber-50 dark:bg-amber-500/10 border-amber-300 dark:border-amber-500/40 text-amber-800 dark:text-amber-200';
const icon = warning.severity === 'red' ? '⚠️' : '⚡';
const heading = isFa ? 'هشدار:' : 'Warning:';
blocks.push(
'<div class="' + palette + ' border rounded-lg p-3 mb-2">' +
'<p class="font-semibold mb-1">' + icon + ' ' + heading + '</p>' +
'<p>' + (isFa ? warning.fa : warning.en) + '</p>' +
'</div>'
);
}
// Credential requirement block.
if (guide) {
const title = isFa const title = isFa
? 'پیش‌نیاز Credential برای سطح <strong>' + (scopeLabels[currentScope] || currentScope) + '</strong>:' ? 'پیش‌نیاز Credential برای سطح <strong>' + (scopeLabels[currentScope] || currentScope) + '</strong>:'
: 'Credential requirement for <strong>' + (scopeLabels[currentScope] || currentScope) + '</strong>:'; : 'Credential requirement for <strong>' + (scopeLabels[currentScope] || currentScope) + '</strong>:';
const body = isFa ? guide.fa : guide.en; const body = isFa ? guide.fa : guide.en;
blocks.push(
el.innerHTML =
'<div class="bg-indigo-50 dark:bg-indigo-500/10 border border-indigo-200 dark:border-indigo-500/30 rounded-lg p-3 text-indigo-700 dark:text-indigo-300">' + '<div class="bg-indigo-50 dark:bg-indigo-500/10 border border-indigo-200 dark:border-indigo-500/30 rounded-lg p-3 text-indigo-700 dark:text-indigo-300">' +
'<p class="mb-1">' + title + '</p>' + '<p class="mb-1">' + title + '</p>' +
'<p>' + body + '</p>' + '<p>' + body + '</p>' +
'</div>'; '</div>'
);
}
el.innerHTML = blocks.join('');
el.classList.remove('hidden'); el.classList.remove('hidden');
} }
@@ -734,8 +793,11 @@
const search = (document.getElementById('tool-search').value || '').toLowerCase(); const search = (document.getElementById('tool-search').value || '').toLowerCase();
container.innerHTML = ''; container.innerHTML = '';
// Group by required_scope // Group by required_scope. F.19.2.0 — six tiers now in play
const groups = { read: [], write: [], admin: [] }; // (read / editor / settings / install / write / admin).
const groups = {
read: [], editor: [], settings: [], install: [], write: [], admin: []
};
for (const tool of allTools) { for (const tool of allTools) {
const s = tool.required_scope || 'read'; const s = tool.required_scope || 'read';
if (!groups[s]) groups[s] = []; if (!groups[s]) groups[s] = [];
@@ -744,12 +806,15 @@
const scopeLabels = { const scopeLabels = {
read: '{% if lang == "fa" %}خواندن{% else %}Read{% endif %}', read: '{% if lang == "fa" %}خواندن{% else %}Read{% endif %}',
editor: '{% if lang == "fa" %}ویرایشگر{% else %}Editor{% endif %}',
settings: '{% if lang == "fa" %}تنظیمات{% else %}Settings{% endif %}',
install: '{% if lang == "fa" %}نصب‌کننده{% else %}Installer{% endif %}',
write: '{% if lang == "fa" %}نوشتن{% else %}Write{% endif %}', write: '{% if lang == "fa" %}نوشتن{% else %}Write{% endif %}',
admin: 'Admin', admin: 'Admin',
}; };
let visibleCount = 0; let visibleCount = 0;
for (const scope of ['read', 'write', 'admin']) { for (const scope of ['read', 'editor', 'settings', 'install', 'write', 'admin']) {
const tools = (groups[scope] || []).filter(t => !search || t.name.toLowerCase().includes(search) || (t.description || '').toLowerCase().includes(search)); const tools = (groups[scope] || []).filter(t => !search || t.name.toLowerCase().includes(search) || (t.description || '').toLowerCase().includes(search));
if (!tools.length) continue; if (!tools.length) continue;
@@ -768,11 +833,15 @@
} }
} }
// Universal scope tiers: maps scope → set of allowed required_scope values // Universal scope tiers: maps scope → set of allowed required_scope values.
// F.19.2.0 — kept in lockstep with core.tool_access.UNIVERSAL_SCOPE_TIERS.
const SCOPE_TIERS = { const SCOPE_TIERS = {
read: new Set(['read']), read: new Set(['read']),
write: new Set(['read', 'write']), editor: new Set(['read', 'editor']),
admin: new Set(['read', 'write', 'admin']), settings: new Set(['read', 'editor', 'settings']),
install: new Set(['read', 'editor', 'settings', 'install']),
write: new Set(['read', 'editor', 'settings', 'install', 'write']),
admin: new Set(['read', 'editor', 'settings', 'install', 'write', 'admin']),
custom: null, // no filter custom: null, // no filter
}; };
@@ -912,7 +981,8 @@
pill.addEventListener('click', (ev) => { pill.addEventListener('click', (ev) => {
ev.stopPropagation(); ev.stopPropagation();
const link = document.createElement('a'); const link = document.createElement('a');
link.href = '/dashboard/sites/' + encodeURIComponent(siteId) + '/edit'; const fromV2 = new URLSearchParams(window.location.search).get('from') === 'dashboard-v2';
link.href = '/dashboard/sites/' + encodeURIComponent(siteId) + '/edit' + (fromV2 ? '?from=dashboard-v2' : '');
link.click(); link.click();
}); });
left.appendChild(pill); left.appendChild(pill);

View File

@@ -2,36 +2,152 @@
{% block title %}{{ t.page_title }}{% endblock %} {% block title %}{{ t.page_title }}{% endblock %}
{% block extra_head %}
<style>
.oauth-consent-shell {
min-height: calc(100vh - 96px);
display: grid;
place-items: center;
padding: 24px;
background:
radial-gradient(circle at 20% 0%, rgba(34, 197, 94, 0.10), transparent 30%),
linear-gradient(180deg, rgba(15, 23, 42, 0.04), transparent 48%);
}
html[data-theme="dark"] .oauth-consent-shell {
background:
radial-gradient(circle at 20% 0%, rgba(34, 197, 94, 0.12), transparent 30%),
linear-gradient(180deg, rgba(148, 163, 184, 0.08), transparent 48%);
}
.oauth-consent-card {
width: min(760px, 100%);
border: 1px solid rgba(148, 163, 184, 0.28);
border-radius: 16px;
background: rgba(255, 255, 255, 0.96);
box-shadow: 0 24px 80px rgba(15, 23, 42, 0.14);
overflow: hidden;
}
.dark .oauth-consent-card {
background: rgba(15, 23, 42, 0.96);
border-color: rgba(71, 85, 105, 0.55);
}
.oauth-consent-header {
display: flex;
gap: 16px;
align-items: flex-start;
padding: 28px;
border-bottom: 1px solid rgba(148, 163, 184, 0.22);
}
html[dir="rtl"] .oauth-consent-header { flex-direction: row-reverse; text-align: right; }
.oauth-consent-icon {
width: 48px;
height: 48px;
border-radius: 12px;
display: grid;
place-items: center;
background: rgba(59, 130, 246, 0.10);
color: rgb(37, 99, 235);
border: 1px solid rgba(59, 130, 246, 0.22);
flex-shrink: 0;
}
.oauth-consent-body {
padding: 24px 28px 28px;
display: grid;
gap: 18px;
}
.oauth-info-grid {
display: grid;
grid-template-columns: minmax(0, 1fr);
gap: 10px;
padding: 14px;
border: 1px solid rgba(148, 163, 184, 0.24);
border-radius: 12px;
background: rgba(248, 250, 252, 0.82);
}
.dark .oauth-info-grid { background: rgba(30, 41, 59, 0.64); }
.oauth-row {
display: grid;
grid-template-columns: 140px minmax(0, 1fr);
gap: 12px;
align-items: center;
font-size: 13px;
}
.oauth-label { color: rgb(100, 116, 139); font-weight: 600; }
.dark .oauth-label { color: rgb(148, 163, 184); }
.oauth-value {
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
font-size: 12px;
word-break: break-all;
color: rgb(15, 23, 42);
}
.dark .oauth-value { color: rgb(226, 232, 240); }
html[dir="rtl"] .oauth-value { direction: ltr; text-align: left; }
.oauth-scope-list { display: flex; flex-wrap: wrap; gap: 8px; }
.oauth-scope {
display: inline-flex;
align-items: center;
gap: 6px;
border-radius: 999px;
border: 1px solid rgba(59, 130, 246, 0.22);
background: rgba(59, 130, 246, 0.09);
padding: 7px 10px;
font-size: 12px;
font-weight: 600;
color: rgb(30, 64, 175);
}
.dark .oauth-scope { color: rgb(147, 197, 253); }
.oauth-panel {
border: 1px solid rgba(148, 163, 184, 0.24);
border-radius: 12px;
padding: 14px;
background: rgba(248, 250, 252, 0.72);
}
.dark .oauth-panel { background: rgba(30, 41, 59, 0.48); }
.oauth-actions { display: flex; gap: 10px; padding-top: 4px; }
html[dir="rtl"] .oauth-actions { flex-direction: row-reverse; }
.oauth-actions button { min-height: 44px; border-radius: 10px; }
@media (max-width: 640px) {
.oauth-consent-shell { padding: 12px; align-items: start; }
.oauth-consent-header { padding: 20px; }
.oauth-consent-body { padding: 18px 20px 22px; }
.oauth-row { grid-template-columns: 1fr; gap: 2px; }
.oauth-actions { flex-direction: column; }
html[dir="rtl"] .oauth-actions { flex-direction: column; }
}
</style>
{% endblock %}
{% block content %} {% block content %}
<div class="auth-container px-4"> <div class="oauth-consent-shell" {% if lang == 'fa' %}dir="rtl"{% endif %}>
<div class="auth-card w-full max-w-md bg-white dark:bg-gray-800 rounded-lg shadow-xl p-8"> <div class="oauth-consent-card">
<!-- Header with Icon --> <!-- Header with Icon -->
<div class="text-center mb-6"> <div class="oauth-consent-header">
<div class="mx-auto flex items-center justify-center h-16 w-16 rounded-full bg-purple-100 dark:bg-purple-900 mb-4"> <div class="oauth-consent-icon">
<svg class="h-10 w-10 text-purple-600 dark:text-purple-300" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <svg class="h-6 w-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z"/> <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z"/>
</svg> </svg>
</div> </div>
<div>
<div class="text-xs uppercase tracking-wide text-gray-500 dark:text-gray-400 mb-1">MCP Hub OAuth</div>
<h1 class="text-2xl font-bold text-gray-900 dark:text-white">{{ t.auth_required }}</h1> <h1 class="text-2xl font-bold text-gray-900 dark:text-white">{{ t.auth_required }}</h1>
<p class="text-gray-600 dark:text-gray-400 mt-2" {% if lang == 'fa' %}dir="rtl"{% endif %}> <p class="text-gray-600 dark:text-gray-400 mt-2">
<span class="font-semibold text-purple-600 dark:text-purple-400">{{ client_name }}</span> <span class="font-semibold text-blue-700 dark:text-blue-300">{{ client_name }}</span>
{{ t.wants_access.replace('{client_name}', '') }} {{ t.wants_access.replace('{client_name}', '') }}
</p> </p>
</div> </div>
</div>
<div class="oauth-consent-body">
<!-- Client Information --> <!-- Client Information -->
<div class="bg-gray-50 dark:bg-gray-700 rounded-lg p-4 mb-6"> <div class="oauth-info-grid">
<div class="space-y-2 text-sm" {% if lang == 'fa' %}dir="rtl"{% endif %}> <div class="space-y-2 text-sm">
<div class="flex justify-between"> <div class="oauth-row">
<span class="text-gray-600 dark:text-gray-400">{{ t.client_id_label }}</span> <span class="oauth-label">{{ t.client_id_label }}</span>
<span class="font-mono text-gray-900 dark:text-white text-xs">{{ client_id }}</span> <span class="oauth-value">{{ client_id }}</span>
</div> </div>
{% if redirect_uri %} {% if redirect_uri %}
<div class="flex justify-between"> <div class="oauth-row">
<span class="text-gray-600 dark:text-gray-400">{{ t.redirect_uri_label }}</span> <span class="oauth-label">{{ t.redirect_uri_label }}</span>
<span class="font-mono text-xs text-gray-900 dark:text-white truncate ml-2" title="{{ redirect_uri }}"> <span class="oauth-value" title="{{ redirect_uri }}">{{ redirect_uri }}</span>
{{ redirect_uri[:40] }}...
</span>
</div> </div>
{% endif %} {% endif %}
</div> </div>
@@ -39,15 +155,15 @@
<!-- Requested Permissions --> <!-- Requested Permissions -->
{% if scopes %} {% if scopes %}
<div class="mb-6" {% if lang == 'fa' %}dir="rtl"{% endif %}> <div>
<h3 class="text-sm font-semibold text-gray-700 dark:text-gray-300 mb-3">{{ t.requested_permissions }}</h3> <h3 class="text-sm font-semibold text-gray-700 dark:text-gray-300 mb-3">{{ t.requested_permissions }}</h3>
<div class="space-y-2"> <div class="oauth-scope-list">
{% for scope in scopes %} {% for scope in scopes %}
<div class="permission-badge flex items-center p-3 bg-purple-50 dark:bg-purple-900/20 rounded-lg" {% if lang == 'fa' %}dir="rtl"{% endif %}> <div class="oauth-scope">
<svg class="h-5 w-5 text-purple-600 dark:text-purple-400 {% if lang == 'fa' %}ml-3{% else %}mr-3{% endif %}" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <svg class="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"/> <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"/>
</svg> </svg>
<span class="text-sm font-medium text-gray-900 dark:text-white">{{ scope }}</span> <span>{{ scope }}</span>
</div> </div>
{% endfor %} {% endfor %}
</div> </div>
@@ -71,14 +187,15 @@
{% if resource %} {% if resource %}
<input type="hidden" name="resource" value="{{ resource }}"> <input type="hidden" name="resource" value="{{ resource }}">
{% endif %} {% endif %}
<input type="hidden" name="lang" value="{{ lang }}" id="oauth_lang">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}"> <input type="hidden" name="csrf_token" value="{{ csrf_token }}">
<!-- Authentication --> <!-- Authentication -->
{% if session_user %} {% if session_user %}
<!-- Session-based consent (user already logged in) --> <!-- Session-based consent (user already logged in) -->
<div {% if lang == 'fa' %}dir="rtl"{% endif %}> <div>
<div class="bg-green-50 dark:bg-green-500/10 border border-green-200 dark:border-green-500/30 rounded-lg p-4 mb-4"> <div class="oauth-panel mb-4">
<div class="flex items-center gap-3"> <div class="flex items-center gap-3 {% if lang == 'fa' %}flex-row-reverse text-right{% endif %}">
<svg class="w-5 h-5 text-green-600 dark:text-green-400 flex-shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor"> <svg class="w-5 h-5 text-green-600 dark:text-green-400 flex-shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7"/> <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7"/>
</svg> </svg>
@@ -106,20 +223,20 @@
</div> </div>
<!-- Hidden API key input (shown when user clicks "enter an API key instead") --> <!-- Hidden API key input (shown when user clicks "enter an API key instead") -->
<div id="api-key-section" class="hidden" {% if lang == 'fa' %}dir="rtl"{% endif %}> <div id="api-key-section" class="hidden oauth-panel">
<label for="api_key_input" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2"> <label for="api_key_input" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
{{ t.enter_api_key }} {{ t.enter_api_key }}
</label> </label>
<input type="password" id="api_key_input" name="api_key_manual" <input type="password" id="api_key_input" name="api_key_manual"
class="w-full px-4 py-3 border border-gray-300 dark:border-gray-600 rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-transparent dark:bg-gray-700 dark:text-white transition" class="w-full px-4 py-3 border border-gray-300 dark:border-gray-600 rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-transparent dark:bg-gray-700 dark:text-white transition"
placeholder="{{ t.api_key_placeholder }}" autocomplete="off" placeholder="{{ t.api_key_placeholder }}" autocomplete="off"
{% if lang == 'fa' %}dir="ltr" style="text-align: right;"{% endif %}> dir="ltr" {% if lang == 'fa' %}style="text-align: right;"{% endif %}>
<p class="text-xs text-gray-500 dark:text-gray-400 mt-2">{{ t.api_key_note }}</p> <p class="text-xs text-gray-500 dark:text-gray-400 mt-2">{{ t.api_key_note }}</p>
</div> </div>
{% else %} {% else %}
<!-- API key mode (no active session) --> <!-- API key mode (no active session) -->
<div {% if lang == 'fa' %}dir="rtl"{% endif %}> <div class="oauth-panel">
<label for="api_key" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2"> <label for="api_key" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
{{ t.enter_api_key }} {{ t.enter_api_key }}
</label> </label>
@@ -131,9 +248,9 @@
placeholder="{{ t.api_key_placeholder }}" placeholder="{{ t.api_key_placeholder }}"
class="w-full px-4 py-3 border border-gray-300 dark:border-gray-600 rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-transparent dark:bg-gray-700 dark:text-white transition" class="w-full px-4 py-3 border border-gray-300 dark:border-gray-600 rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-transparent dark:bg-gray-700 dark:text-white transition"
autocomplete="off" autocomplete="off"
{% if lang == 'fa' %}dir="ltr" style="text-align: right;"{% endif %} dir="ltr" {% if lang == 'fa' %}style="text-align: right;"{% endif %}
/> />
<p class="text-xs text-gray-500 dark:text-gray-400 mt-2" {% if lang == 'fa' %}dir="rtl"{% endif %}> <p class="text-xs text-gray-500 dark:text-gray-400 mt-2">
{{ t.api_key_note }} {{ t.api_key_note }}
</p> </p>
@@ -147,7 +264,7 @@
Or log in with your account: Or log in with your account:
{% endif %} {% endif %}
</p> </p>
<div class="flex gap-2 mt-2"> <div class="flex gap-2 mt-2 {% if lang == 'fa' %}flex-row-reverse{% endif %}">
<a href="/auth/github?next={{ return_url | urlencode }}" <a href="/auth/github?next={{ return_url | urlencode }}"
class="inline-flex items-center gap-2 px-3 py-1.5 bg-gray-800 hover:bg-gray-700 text-white text-sm rounded-lg transition-colors"> class="inline-flex items-center gap-2 px-3 py-1.5 bg-gray-800 hover:bg-gray-700 text-white text-sm rounded-lg transition-colors">
<svg class="w-4 h-4" fill="currentColor" viewBox="0 0 24 24"><path d="M12 0C5.374 0 0 5.373 0 12c0 5.302 3.438 9.8 8.207 11.387.599.111.793-.261.793-.577v-2.234c-3.338.726-4.033-1.416-4.033-1.416-.546-1.387-1.333-1.756-1.333-1.756-1.089-.745.083-.729.083-.729 1.205.084 1.839 1.237 1.839 1.237 1.07 1.834 2.807 1.304 3.492.997.107-.775.418-1.305.762-1.604-2.665-.305-5.467-1.334-5.467-5.931 0-1.311.469-2.381 1.236-3.221-.124-.303-.535-1.524.117-3.176 0 0 1.008-.322 3.301 1.23A11.509 11.509 0 0112 5.803c1.02.005 2.047.138 3.006.404 2.291-1.552 3.297-1.23 3.297-1.23.653 1.653.242 2.874.118 3.176.77.84 1.235 1.911 1.235 3.221 0 4.609-2.807 5.624-5.479 5.921.43.372.823 1.102.823 2.222v3.293c0 .319.192.694.801.576C20.566 21.797 24 17.3 24 12c0-6.627-5.373-12-12-12z"/></svg> <svg class="w-4 h-4" fill="currentColor" viewBox="0 0 24 24"><path d="M12 0C5.374 0 0 5.373 0 12c0 5.302 3.438 9.8 8.207 11.387.599.111.793-.261.793-.577v-2.234c-3.338.726-4.033-1.416-4.033-1.416-.546-1.387-1.333-1.756-1.333-1.756-1.089-.745.083-.729.083-.729 1.205.084 1.839 1.237 1.839 1.237 1.07 1.834 2.807 1.304 3.492.997.107-.775.418-1.305.762-1.604-2.665-.305-5.467-1.334-5.467-5.931 0-1.311.469-2.381 1.236-3.221-.124-.303-.535-1.524.117-3.176 0 0 1.008-.322 3.301 1.23A11.509 11.509 0 0112 5.803c1.02.005 2.047.138 3.006.404 2.291-1.552 3.297-1.23 3.297-1.23.653 1.653.242 2.874.118 3.176.77.84 1.235 1.911 1.235 3.221 0 4.609-2.807 5.624-5.479 5.921.43.372.823 1.102.823 2.222v3.293c0 .319.192.694.801.576C20.566 21.797 24 17.3 24 12c0-6.627-5.373-12-12-12z"/></svg>
@@ -165,7 +282,7 @@
{% endif %} {% endif %}
<!-- Action Buttons --> <!-- Action Buttons -->
<div class="flex gap-3 pt-4" {% if lang == 'fa' %}dir="rtl"{% endif %}> <div class="oauth-actions">
<button <button
type="submit" type="submit"
name="action" name="action"
@@ -186,9 +303,9 @@
</form> </form>
<!-- Security Notice --> <!-- Security Notice -->
<div class="mt-6 p-4 bg-blue-50 dark:bg-blue-900/20 rounded-lg" {% if lang == 'fa' %}dir="rtl"{% endif %}> <div class="oauth-panel">
<div class="flex {% if lang == 'fa' %}flex-row-reverse{% endif %}"> <div class="flex gap-2 {% if lang == 'fa' %}flex-row-reverse text-right{% endif %}">
<svg class="h-5 w-5 text-blue-600 dark:text-blue-400 {% if lang == 'fa' %}mr-2{% else %}mr-2{% endif %} flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <svg class="h-5 w-5 text-blue-600 dark:text-blue-400 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"/> <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"/>
</svg> </svg>
<p class="text-xs text-blue-800 dark:text-blue-300"> <p class="text-xs text-blue-800 dark:text-blue-300">
@@ -198,6 +315,7 @@
</div> </div>
</div> </div>
</div> </div>
</div>
{% endblock %} {% endblock %}
{% block scripts %} {% block scripts %}

0
core/templates/static/dist/.gitkeep vendored Normal file
View File

View File

@@ -38,13 +38,85 @@ from core.tool_registry import ToolDefinition
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
# ── Universal 3-tier scope system (F.7c) ───────────────────────────── # ── Universal scope tier system (F.7c → F.19.2.0) ───────────────────
# Maps a scope tier to the set of ``required_scope`` values it may access. # Maps a scope tier to the set of ``required_scope`` values it may access.
# Works for ALL plugins because every tool has ``required_scope``. # Works for ALL plugins because every tool has ``required_scope``.
#
# Ladder (each tier is a strict superset of the one above):
#
# read │ inventory + diagnostics only
# editor │ + content / theme-file edits (F.19.5 + F.19.7)
# settings │ + options / cron / maintenance / site-identity / permalinks (F.19.6)
# install │ + plugin & theme install / activate / update from wp.org (F.19.2)
# write │ catch-all "non-destructive write" tier (legacy WP/WC/Gitea)
# admin │ destructive ops (URL/zip install, delete user/plugin/theme,
# │ user CRUD, db_query, anything that can lose data)
#
# Why ``write`` is between ``install`` and ``admin``: keeps existing
# WordPress/WooCommerce/Gitea/etc. keys (which use plain ``write``)
# working. They retain the same effective access they had pre-F.19.2,
# minus the destructive ops which now require ``admin``.
UNIVERSAL_SCOPE_TIERS: dict[str, set[str]] = { UNIVERSAL_SCOPE_TIERS: dict[str, set[str]] = {
"read": {"read"}, "read": {"read"},
"write": {"read", "write"}, # F.19.5 — ``editor``: page-content editing without unlocking
"admin": {"read", "write", "admin"}, # destructive ops. wordpress_specialist uses it; other plugins fall
# through to the existing read/write/admin ladder.
"editor": {"read", "editor"},
# F.19.6 — ``settings``: options / cron / maintenance toggle /
# site identity / reading + permalinks. Fits between editor and
# install — settings changes are recoverable, plugin installs can
# reshape the site.
"settings": {"read", "editor", "settings"},
# F.19.2 — ``install``: plugin/theme install + activate + update
# from wp.org slugs. URL/zip install routes belong to ``admin``
# (sees more attack surface). delete_plugin / delete_theme also
# belong to ``admin`` because they can drop data with no undo.
"install": {"read", "editor", "settings", "install"},
# ``write`` is the historical catch-all for non-destructive writes
# (WordPress create_post, WooCommerce update_order, etc.).
"write": {"read", "editor", "settings", "install", "write"},
"admin": {"read", "editor", "settings", "install", "write", "admin"},
}
# Per-tier descriptions for dashboard tooltips + tier toggles. EN + FA
# in lockstep with the rest of the dashboard's i18n.
TIER_DESCRIPTIONS: dict[str, dict[str, str]] = {
"read": {
"label_en": "Read Only",
"label_fa": "فقط خواندن",
"hint_en": "Inventory and diagnostics — no changes.",
"hint_fa": "مشاهده و عیب‌یابی — بدون تغییر.",
},
"editor": {
"label_en": "Editor",
"label_fa": "ویرایشگر",
"hint_en": "Read + page / theme content edits.",
"hint_fa": "خواندن + ویرایش محتوا و فایل قالب.",
},
"settings": {
"label_en": "Settings",
"label_fa": "تنظیمات",
"hint_en": "Editor + site options, cron, identity, permalinks.",
"hint_fa": "ویرایشگر + تنظیمات سایت، کرون، هویت، پرمالینک.",
},
"install": {
"label_en": "Installer",
"label_fa": "نصب‌کننده",
"hint_en": "Settings + install / activate plugins & themes from wp.org.",
"hint_fa": "تنظیمات + نصب و فعال‌سازی پلاگین/قالب از wp.org.",
},
"write": {
"label_en": "Write",
"label_fa": "نوشتن",
"hint_en": "Non-destructive write tier (post/product CRUD, etc.).",
"hint_fa": "تیر نوشتن غیرتخریبی (ایجاد/ویرایش پست، محصول و …).",
},
"admin": {
"label_en": "Admin",
"label_fa": "مدیر",
"hint_en": "Includes destructive ops (delete, URL/zip install, user CRUD).",
"hint_fa": "شامل عملیات تخریبی (حذف، نصب از URL/zip، CRUD کاربر).",
},
} }
# ── Legacy Coolify category mapping (kept for ``custom`` overlay) ───── # ── Legacy Coolify category mapping (kept for ``custom`` overlay) ─────
@@ -214,7 +286,7 @@ def get_scope_presets_for_plugin(plugin_type: str) -> list[dict[str, str]]:
custom, custom,
] ]
if plugin_type in {"wordpress", "wordpress_advanced"}: if plugin_type == "wordpress":
# WordPress has no admin-scope tools (read=27, write=40, admin=0). # WordPress has no admin-scope tools (read=27, write=40, admin=0).
# SEO + plugin/theme tools require the Airano MCP SEO Bridge plugin # SEO + plugin/theme tools require the Airano MCP SEO Bridge plugin
# to be installed on the WP site itself. Present 2 tiers + custom. # to be installed on the WP site itself. Present 2 tiers + custom.
@@ -236,6 +308,47 @@ def get_scope_presets_for_plugin(plugin_type: str) -> list[dict[str, str]]:
custom, custom,
] ]
if plugin_type == "wordpress_specialist":
# F.19.1 ships READ-only tools (inventory + diagnostics).
# F.19.5 adds the EDITOR tier — page editing (Gutenberg + Elementor
# + Classic) on top of read.
# F.19.7 adds the THEME DEV surface on the editor tier (file CRUD)
# and the install/admin tiers for theme install/activate/delete.
# F.19.2.1 adds plugin install/activate/deactivate/update (install
# tier) and plugin install-from-zip + delete (admin tier).
# F.19.6 will surface a SETTINGS tier between editor and install.
return [
{
"value": "read",
"label": "Read Only",
"label_fa": "فقط خواندن",
"hint": "Inventory plugins/themes/users/options/cron — no changes",
"hint_fa": "مشاهده پلاگین‌ها/قالب‌ها/کاربران/تنظیمات/کرون — بدون تغییر",
},
{
"value": "editor",
"label": "Editor",
"label_fa": "ویرایشگر",
"hint": "Read + page editing (Gutenberg blocks, Elementor, Classic) + theme file CRUD",
"hint_fa": "خواندن + ویرایش صفحه (بلوک‌ها، المنتور، کلاسیک) + ویرایش فایل قالب",
},
{
"value": "install",
"label": "Installer",
"label_fa": "نصب‌کننده",
"hint": "Editor + install/activate plugins & themes from wp.org",
"hint_fa": "ویرایشگر + نصب و فعال‌سازی پلاگین/قالب از wp.org",
},
{
"value": "admin",
"label": "Admin",
"label_fa": "مدیر",
"hint": "Installer + URL/zip install, plugin/theme delete (destructive)",
"hint_fa": "نصب‌کننده + نصب از URL/zip، حذف پلاگین/قالب (تخریبی)",
},
custom,
]
if plugin_type == "gitea": if plugin_type == "gitea":
return [ return [
{ {
@@ -614,7 +727,12 @@ class ToolAccessManager:
# F.X.fix #8 — tools that need a per-site AI provider key to succeed. # F.X.fix #8 — tools that need a per-site AI provider key to succeed.
# Today only the AI image generator; expand as more AI tools land. # Today only the AI image generator; expand as more AI tools land.
_PROVIDER_KEY_REQUIRED_TOOLS: frozenset[str] = frozenset({"wordpress_generate_and_upload_image"}) _PROVIDER_KEY_REQUIRED_TOOLS: frozenset[str] = frozenset(
{
"wordpress_generate_and_upload_image",
"woocommerce_generate_and_upload_image",
}
)
def _tool_requires_provider_key(tool_name: str) -> bool: def _tool_requires_provider_key(tool_name: str) -> bool:

View File

@@ -35,6 +35,16 @@ _GITHUB_TOKEN_URL = "https://github.com/login/oauth/access_token"
_GITHUB_USER_URL = "https://api.github.com/user" _GITHUB_USER_URL = "https://api.github.com/user"
_GITHUB_EMAILS_URL = "https://api.github.com/user/emails" _GITHUB_EMAILS_URL = "https://api.github.com/user/emails"
# api.github.com rejects requests without a User-Agent header and treats
# requests that omit Accept/X-GitHub-Api-Version as forward-compatibility
# violations on some endpoints (observed as 403 on /user and /user/emails
# after httpx upgraded its default UA). Keep these explicit.
_GITHUB_API_HEADERS = {
"Accept": "application/vnd.github+json",
"X-GitHub-Api-Version": "2022-11-28",
"User-Agent": "mcphub-oauth-client",
}
_GOOGLE_AUTH_URL = "https://accounts.google.com/o/oauth2/v2/auth" _GOOGLE_AUTH_URL = "https://accounts.google.com/o/oauth2/v2/auth"
_GOOGLE_TOKEN_URL = "https://oauth2.googleapis.com/token" _GOOGLE_TOKEN_URL = "https://oauth2.googleapis.com/token"
_GOOGLE_USERINFO_URL = "https://www.googleapis.com/oauth2/v3/userinfo" _GOOGLE_USERINFO_URL = "https://www.googleapis.com/oauth2/v3/userinfo"
@@ -258,19 +268,25 @@ class UserAuth:
if not access_token: if not access_token:
raise ValueError(f"Failed to exchange GitHub code: {token_data}") raise ValueError(f"Failed to exchange GitHub code: {token_data}")
auth_headers = {
"Authorization": f"Bearer {access_token}",
**_GITHUB_API_HEADERS,
}
# Fetch user info # Fetch user info
user_resp = await client.get( user_resp = await client.get(_GITHUB_USER_URL, headers=auth_headers)
_GITHUB_USER_URL, if user_resp.status_code != 200:
headers={"Authorization": f"Bearer {access_token}"}, raise ValueError(f"GitHub /user returned {user_resp.status_code}: {user_resp.text}")
)
user_data = user_resp.json() user_data = user_resp.json()
if "id" not in user_data:
raise ValueError(f"GitHub /user response missing 'id' field: {user_data}")
email = user_data.get("email") email = user_data.get("email")
if not email: if not email:
# Fetch from /user/emails endpoint (private email fallback) # Fetch from /user/emails endpoint (private email fallback)
emails_resp = await client.get( emails_resp = await client.get(
_GITHUB_EMAILS_URL, _GITHUB_EMAILS_URL,
headers={"Authorization": f"Bearer {access_token}"}, headers=auth_headers,
) )
if emails_resp.status_code == 200: if emails_resp.status_code == 200:
emails = emails_resp.json() emails = emails_resp.json()

View File

@@ -19,7 +19,6 @@ Usage:
import json import json
import logging import logging
import os
import time import time
from copy import deepcopy from copy import deepcopy
from typing import Any from typing import Any
@@ -31,16 +30,12 @@ from core.tool_registry import ToolDefinition
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
# Per-user rate limiting defaults
USER_RATE_LIMIT_PER_MIN = int(os.getenv("USER_RATE_LIMIT_PER_MIN", "30"))
USER_RATE_LIMIT_PER_HR = int(os.getenv("USER_RATE_LIMIT_PER_HR", "500"))
# In-memory rate limit tracking: user_id -> list of timestamps # In-memory rate limit tracking: user_id -> list of timestamps
_rate_limits: dict[str, list[float]] = {} _rate_limits: dict[str, list[float]] = {}
def _check_user_rate_limit(user_id: str) -> tuple[bool, str]: def _check_user_rate_limit(user_id: str) -> tuple[bool, str]:
"""Check per-user rate limits. """Check per-user rate limits using the live cached settings (DB > ENV > default).
Args: Args:
user_id: User UUID. user_id: User UUID.
@@ -48,6 +43,11 @@ def _check_user_rate_limit(user_id: str) -> tuple[bool, str]:
Returns: Returns:
Tuple of (allowed, error_message). error_message is empty if allowed. Tuple of (allowed, error_message). error_message is empty if allowed.
""" """
from core.settings import get_cached_rate_per_hr, get_cached_rate_per_min
per_min = get_cached_rate_per_min()
per_hr = get_cached_rate_per_hr()
now = time.time() now = time.time()
timestamps = _rate_limits.setdefault(user_id, []) timestamps = _rate_limits.setdefault(user_id, [])
@@ -59,12 +59,12 @@ def _check_user_rate_limit(user_id: str) -> tuple[bool, str]:
# Check per-minute limit # Check per-minute limit
cutoff_min = now - 60 cutoff_min = now - 60
recent_min = sum(1 for t in timestamps if t > cutoff_min) recent_min = sum(1 for t in timestamps if t > cutoff_min)
if recent_min >= USER_RATE_LIMIT_PER_MIN: if recent_min >= per_min:
return False, f"Rate limit exceeded: {USER_RATE_LIMIT_PER_MIN} requests/minute" return False, f"Rate limit exceeded: {per_min} requests/minute"
# Check per-hour limit # Check per-hour limit
if len(timestamps) >= USER_RATE_LIMIT_PER_HR: if len(timestamps) >= per_hr:
return False, f"Rate limit exceeded: {USER_RATE_LIMIT_PER_HR} requests/hour" return False, f"Rate limit exceeded: {per_hr} requests/hour"
timestamps.append(now) timestamps.append(now)
return True, "" return True, ""
@@ -357,7 +357,28 @@ async def user_mcp_handler(request: Request) -> Response:
status_code=401, status_code=401,
) )
# --- Rate Limiting --- # --- Rate Limiting (admin users are exempt) ---
# Fetch the user record early so we can check the role; it's also needed
# for the plugin-visibility check below, so we hoist it here.
_db_early = None
_user_record_early: dict | None = None
try:
from core.database import get_database
_db_early = get_database()
_user_record_early = await _db_early.get_user_by_id(user_id)
except Exception:
pass
_is_admin_user = False
if _user_record_early:
from core.admin_utils import is_admin_email
_is_admin_user = (_user_record_early.get("role") == "admin") or is_admin_email(
_user_record_early.get("email")
)
if not _is_admin_user:
allowed, rate_msg = _check_user_rate_limit(user_id) allowed, rate_msg = _check_user_rate_limit(user_id)
if not allowed: if not allowed:
return JSONResponse( return JSONResponse(
@@ -369,7 +390,7 @@ async def user_mcp_handler(request: Request) -> Response:
try: try:
from core.database import get_database from core.database import get_database
db = get_database() db = _db_early if _db_early is not None else get_database()
site = await db.get_site_by_alias(user_id, alias) site = await db.get_site_by_alias(user_id, alias)
except RuntimeError: except RuntimeError:
return JSONResponse( return JSONResponse(
@@ -389,13 +410,15 @@ async def user_mcp_handler(request: Request) -> Response:
status_code=403, status_code=403,
) )
# --- Plugin visibility check --- # --- Plugin visibility check (admins bypass entirely) ---
from core.plugin_visibility import is_plugin_public from core.plugin_visibility import is_plugin_public
if not is_plugin_public(site["plugin_type"]): if not _is_admin_user and not is_plugin_public(site["plugin_type"]):
return JSONResponse( return JSONResponse(
_jsonrpc_error( _jsonrpc_error(
None, -32600, f"Plugin '{site['plugin_type']}' is not currently available" None,
-32600,
f"Plugin '{site['plugin_type']}' is not currently available",
), ),
status_code=403, status_code=403,
) )
@@ -462,27 +485,59 @@ async def user_mcp_handler(request: Request) -> Response:
# key_scopes is set during authentication (both mhu_ and JWT paths) # key_scopes is set during authentication (both mhu_ and JWT paths)
# F.7b: enforce category-based scope allowlist in addition to the # F.7b: enforce category-based scope allowlist in addition to the
# legacy read/write/admin hierarchy. A tool is allowed only if BOTH # universal scope tier. A tool is allowed only if BOTH
# (a) the legacy hierarchy grants it, AND # (a) the universal scope tier grants it (works for every plugin), AND
# (b) the tool's category is in BOTH the key-scope set AND the # (b) for plugins with fine-grained categories (Coolify), the
# site's stored tool_scope set (the narrower layer wins). # tool's category is in BOTH the key-scope set AND the site's
from core.tool_access import KNOWN_CATEGORIES, SCOPE_CUSTOM, scopes_to_categories # stored tool_scope set (the narrower layer wins).
from core.tool_access import (
KNOWN_CATEGORIES,
SCOPE_CUSTOM,
UNIVERSAL_SCOPE_TIERS,
scopes_to_categories,
)
scope_hierarchy = {"read": 1, "write": 2, "admin": 3} # F.19.5 introduced the ``editor`` tier (between ``read`` and
required_level = scope_hierarchy.get(required_scope, 0) # ``write``). The legacy 3-level hierarchy here ({read:1, write:2,
key_level = max([scope_hierarchy.get(s, 0) for s in key_scopes] + [0]) # admin:3}) silently dropped ``editor`` to level 0, which made
legacy_ok = key_level >= required_level # every tool call from an editor-scope key fail "Insufficient
# scope". Use the canonical UNIVERSAL_SCOPE_TIERS map from
# tool_access so this list stays single-sourced as new tiers
# land (F.19.2 introduces ``manage`` / ``install`` next).
allowed_scopes: set[str] = set()
for s in key_scopes:
allowed_scopes |= UNIVERSAL_SCOPE_TIERS.get(s.strip(), set())
legacy_ok = required_scope in allowed_scopes
plugin_type = tool_def.plugin_type
# Category check is for plugins with fine-grained categories
# (currently Coolify only). For other plugins ``tool_def.category``
# defaults to ``"read"`` which collides with one of Coolify's
# KNOWN_CATEGORIES — running the check against non-Coolify tools
# mis-rejected wordpress_specialist + wordpress + gitea tools when
# the key scope didn't happen to map to category "read".
if plugin_type == "coolify":
key_cats = scopes_to_categories(key_scopes) key_cats = scopes_to_categories(key_scopes)
key_category_ok = tool_def.category not in KNOWN_CATEGORIES or tool_def.category in key_cats key_category_ok = (
tool_def.category not in KNOWN_CATEGORIES or tool_def.category in key_cats
)
else:
key_category_ok = True
# Site-level scope check (skipped for "custom" preset). # Site-level scope check (skipped for "custom" preset).
site_scope = site.get("tool_scope") or "admin" site_scope = site.get("tool_scope") or "admin"
if site_scope and site_scope != SCOPE_CUSTOM: if site_scope and site_scope != SCOPE_CUSTOM:
if plugin_type == "coolify":
site_cats = scopes_to_categories([site_scope]) site_cats = scopes_to_categories([site_scope])
site_category_ok = ( site_category_ok = (
tool_def.category not in KNOWN_CATEGORIES or tool_def.category in site_cats tool_def.category not in KNOWN_CATEGORIES or tool_def.category in site_cats
) )
else:
# Non-Coolify plugins use the universal tier at site level
# too — site_scope is one of ``read`` / ``editor`` / ``write``
# / ``admin`` / ``custom``, same vocabulary as the key.
site_allowed = UNIVERSAL_SCOPE_TIERS.get(site_scope, set())
site_category_ok = required_scope in site_allowed
else: else:
site_category_ok = True site_category_ok = True

View File

@@ -56,7 +56,7 @@ class UserKeyManager:
self, self,
user_id: str, user_id: str,
name: str, name: str,
scopes: str = "read write admin", scopes: str = "read editor settings install write admin",
expires_in_days: int | None = None, expires_in_days: int | None = None,
site_id: str | None = None, site_id: str | None = None,
) -> dict[str, Any]: ) -> dict[str, Any]:
@@ -65,7 +65,14 @@ class UserKeyManager:
Args: Args:
user_id: Owner's UUID. user_id: Owner's UUID.
name: Human label (e.g. "Claude Desktop"). name: Human label (e.g. "Claude Desktop").
scopes: Access scopes (default: "read write admin" for full access). scopes: Access scopes. Default enumerates every tier explicitly
(read / editor / settings / install / write / admin) so the
key matches the per-site Tool Access dropdown 1:1, even if
``UNIVERSAL_SCOPE_TIERS`` is reorganised in a later phase.
The binding access filter for any tool call is the
**intersection** of these scopes with the per-site
``tool_scope`` preset — narrowing happens per-site, not
per-key, so a single key works for every site the user owns.
expires_in_days: Optional expiry in days from now. None = never. expires_in_days: Optional expiry in days from now. None = never.
site_id: Optional site UUID to scope key to a single site. site_id: Optional site UUID to scope key to a single site.

View File

@@ -1,84 +0,0 @@
# ===================================
# MCP Hub — Docker Compose (Coolify, mirror + proxy-fallback variant)
# ===================================
#
# Identical to docker-compose.coolify.yaml except `dockerfile:` points
# to Dockerfile.mirror. That file pulls base images from mirror.gcr.io,
# uses a Yandex apk mirror, and falls back to a temporary HTTP proxy
# when direct apk/pip calls fail. Switch the Coolify Source back to
# docker-compose.coolify.yaml once the build host can reach Docker Hub,
# Alpine CDN, and pypi.org directly again.
#
# Source mirror note (2026-04-16):
# Primary repo: github.com/airano-ir/coolify-mcp-hub (public)
# Gitea mirror: gitea.palebluedot.live/atlatl/mcphub-internal (private)
#
# If Coolify cannot reach api.github.com (e.g. cURL error 28 on the
# api.github.com/zen health probe), point the Coolify Source at the
# Gitea mirror instead. Revert the source back to GitHub once the
# GitHub App connection is restored.
# ===================================
services:
mcp-server:
build:
context: .
dockerfile: Dockerfile.mirror
container_name: mcphub
restart: unless-stopped
# No 'ports' — Coolify reverse proxy routes traffic via EXPOSE 8000
environment:
- PYTHONUNBUFFERED=1
# === REQUIRED ===
- MASTER_API_KEY=${MASTER_API_KEY}
- ENCRYPTION_KEY=${ENCRYPTION_KEY}
- DASHBOARD_SESSION_SECRET=${DASHBOARD_SESSION_SECRET}
# === OAUTH SOCIAL LOGIN (for user registration) ===
- GITHUB_CLIENT_ID=${GITHUB_CLIENT_ID:-}
- GITHUB_CLIENT_SECRET=${GITHUB_CLIENT_SECRET:-}
- GOOGLE_CLIENT_ID=${GOOGLE_CLIENT_ID:-}
- GOOGLE_CLIENT_SECRET=${GOOGLE_CLIENT_SECRET:-}
- PUBLIC_URL=${PUBLIC_URL:-}
- SESSION_EXPIRY_HOURS=${SESSION_EXPIRY_HOURS:-168}
# === ADMIN SYSTEM (F.4) ===
- ADMIN_EMAILS=${ADMIN_EMAILS:-}
- DISABLE_MASTER_KEY_LOGIN=${DISABLE_MASTER_KEY_LOGIN:-false}
# === LOGGING ===
- LOG_LEVEL=${LOG_LEVEL:-INFO}
# === SITE MANAGEMENT (E.3) ===
- MAX_SITES_PER_USER=${MAX_SITES_PER_USER:-10}
- USER_RATE_LIMIT_PER_MIN=${USER_RATE_LIMIT_PER_MIN:-30}
- USER_RATE_LIMIT_PER_HR=${USER_RATE_LIMIT_PER_HR:-500}
# === SITES ===
# Sites are managed via the web dashboard (DB-based).
# After deployment, open the dashboard to add sites.
healthcheck:
test: ["CMD", "wget", "-qO-", "http://localhost:8000/health"]
interval: 30s
timeout: 10s
retries: 3
start_period: 40s
volumes:
- mcp-data:/app/data # SQLite DB, API keys, OAuth data
- mcp-logs:/app/logs # Audit logs, health reports
- /var/run/docker.sock:/var/run/docker.sock:ro
# Coolify uses GID 988 for Docker socket
group_add:
- "988"
volumes:
mcp-data:
driver: local
mcp-logs:
driver: local

View File

@@ -59,6 +59,13 @@ services:
- USER_RATE_LIMIT_PER_MIN=${USER_RATE_LIMIT_PER_MIN:-30} - USER_RATE_LIMIT_PER_MIN=${USER_RATE_LIMIT_PER_MIN:-30}
- USER_RATE_LIMIT_PER_HR=${USER_RATE_LIMIT_PER_HR:-500} - USER_RATE_LIMIT_PER_HR=${USER_RATE_LIMIT_PER_HR:-500}
# === ANALYTICS (dashboard SPA) ===
# All four are optional — leave unset to disable that provider's tag.
- UMAMI_WEBSITE_ID=${UMAMI_WEBSITE_ID:-}
- UMAMI_URL=${UMAMI_URL:-}
- OPENPANEL_API_URL=${OPENPANEL_API_URL:-}
- OPENPANEL_CLIENT_ID=${OPENPANEL_CLIENT_ID:-}
# === SITES === # === SITES ===
# Sites are managed via the web dashboard (DB-based). # Sites are managed via the web dashboard (DB-based).
# After deployment, open the dashboard to add sites. # After deployment, open the dashboard to add sites.

View File

@@ -215,12 +215,13 @@ Some tools require specific WordPress plugins or infrastructure:
|-------|-------------| |-------|-------------|
| `wordpress_get_post_seo`, `wordpress_update_post_seo`, `wordpress_get_product_seo`, `wordpress_update_product_seo` | **Yoast SEO** or **RankMath** | | `wordpress_get_post_seo`, `wordpress_update_post_seo`, `wordpress_get_product_seo`, `wordpress_update_product_seo` | **Yoast SEO** or **RankMath** |
| `wordpress_wp_cache_*`, `wordpress_wp_db_*`, `wordpress_wp_plugin_*`, `wordpress_wp_theme_*`, `wordpress_wp_core_*`, `wordpress_wp_search_replace_dry_run` (15 tools) | Docker socket + `CONTAINER` env var | | `wordpress_wp_cache_*`, `wordpress_wp_db_*`, `wordpress_wp_plugin_*`, `wordpress_wp_theme_*`, `wordpress_wp_core_*`, `wordpress_wp_search_replace_dry_run` (15 tools) | Docker socket + `CONTAINER` env var |
| `wordpress_advanced_*` database/system tools | Docker socket + `CONTAINER` env var | | `wordpress_specialist_*` (51 tools — plugins, themes, users, options, cron, maintenance, page editing, site config + layout, db inspection, bulk fan-out) | **Airano MCP Bridge v2.18.0+** companion plugin (no Docker socket) |
| `woocommerce_*` | **WooCommerce** plugin (separate `WOOCOMMERCE_` config) | | `woocommerce_*` | **WooCommerce** plugin (separate `WOOCOMMERCE_` config) |
### Docker Socket for WP-CLI / WordPress Advanced ### Docker Socket for WP-CLI
WP-CLI and WordPress Advanced system tools require Docker socket access: A subset of legacy `wordpress_*` tools (15 WP-CLI helpers under
`wordpress_wp_*`) require Docker socket access:
```yaml ```yaml
services: services:
@@ -236,17 +237,17 @@ services:
WORDPRESS_SITE1_APP_PASSWORD: xxxx xxxx xxxx xxxx WORDPRESS_SITE1_APP_PASSWORD: xxxx xxxx xxxx xxxx
WORDPRESS_SITE1_CONTAINER: wordpress-container-name WORDPRESS_SITE1_CONTAINER: wordpress-container-name
WORDPRESS_SITE1_ALIAS: mysite WORDPRESS_SITE1_ALIAS: mysite
WORDPRESS_ADVANCED_SITE1_URL: https://your-site.com
WORDPRESS_ADVANCED_SITE1_USERNAME: admin
WORDPRESS_ADVANCED_SITE1_APP_PASSWORD: xxxx xxxx xxxx xxxx
WORDPRESS_ADVANCED_SITE1_CONTAINER: wordpress-container-name
WORDPRESS_ADVANCED_SITE1_ALIAS: mysite-admin
``` ```
`wordpress_specialist_*` tools do **not** need Docker — they're served
through the Airano MCP Bridge companion plugin via REST. Install
`wordpress-plugin/airano-mcp-bridge.zip` on the WordPress site and use
an Application Password for a `manage_options` user.
Without Docker socket: Without Docker socket:
- WP-CLI tools return "not available" errors - WP-CLI helpers (`wordpress_wp_*`) return "not available" errors
- WordPress Advanced database/system tools are unavailable - All REST API tools (bulk operations, content management,
- All REST API tools (bulk operations, content management) work normally `wordpress_specialist_*`) work normally
### Site Configuration ### Site Configuration
@@ -309,21 +310,6 @@ OPENPANEL_SITE1_CLIENT_SECRET=your-client-secret # Required
OPENPANEL_SITE1_ALIAS=my-analytics # Recommended OPENPANEL_SITE1_ALIAS=my-analytics # Recommended
``` ```
#### Appwrite
```env
APPWRITE_SITE1_URL=https://appwrite.example.com # Required
APPWRITE_SITE1_API_KEY=your-api-key # Required
APPWRITE_SITE1_PROJECT_ID=your-project-id # Required
APPWRITE_SITE1_ALIAS=my-appwrite # Recommended
```
#### Directus
```env
DIRECTUS_SITE1_URL=https://directus.example.com # Required
DIRECTUS_SITE1_TOKEN=your-admin-token # Required
DIRECTUS_SITE1_ALIAS=my-directus # Recommended
```
> **Note:** Use `APP_PASSWORD` (WordPress Application Password), not `PASSWORD`. > **Note:** Use `APP_PASSWORD` (WordPress Application Password), not `PASSWORD`.
### Configuration Tips ### Configuration Tips
@@ -528,13 +514,12 @@ Use the most specific endpoint for your use case to minimize token usage:
|----------|--------|------:| |----------|--------|------:|
| `/wordpress/mcp` | WordPress | 67 | | `/wordpress/mcp` | WordPress | 67 |
| `/woocommerce/mcp` | WooCommerce | 28 | | `/woocommerce/mcp` | WooCommerce | 28 |
| `/wordpress-advanced/mcp` | WordPress Advanced | 22 | | `/wordpress-specialist/mcp` | WordPress Specialist | 51 |
| `/gitea/mcp` | Gitea | 56 | | `/gitea/mcp` | Gitea | 56 |
| `/n8n/mcp` | n8n | 56 | | `/n8n/mcp` | n8n | 56 |
| `/supabase/mcp` | Supabase | 70 | | `/supabase/mcp` | Supabase | 70 |
| `/openpanel/mcp` | OpenPanel | 42 | | `/openpanel/mcp` | OpenPanel | 42 |
| `/appwrite/mcp` | Appwrite | 100 | | `/coolify/mcp` | Coolify | ~65 |
| `/directus/mcp` | Directus | 100 |
| `/system/mcp` | System Management | 24 | | `/system/mcp` | System Management | 24 |
**Plugin endpoint vs Project endpoint:** **Plugin endpoint vs Project endpoint:**

View File

@@ -34,6 +34,15 @@ DASHBOARD_SESSION_SECRET=
# Admin users always see all plugins regardless of this setting. # Admin users always see all plugins regardless of this setting.
# Default (if not set): wordpress,woocommerce,supabase,openpanel,gitea # Default (if not set): wordpress,woocommerce,supabase,openpanel,gitea
# ENABLED_PLUGINS=wordpress,woocommerce,supabase,openpanel,gitea # ENABLED_PLUGINS=wordpress,woocommerce,supabase,openpanel,gitea
#
# Plugin notes:
# wordpress_specialist — F.19 specialist tool surface (51 tools across
# read / editor / settings / install / admin tiers). Companion-
# backed (Airano MCP Bridge v2.18.0+ on the WP side). No Docker
# socket needed. Currently admin-only; will become public with
# a tier system in F.19.4 (companion v3.0 wp.org republish).
# Replaced the deprecated wordpress_advanced plugin (sunset
# 2026-05-04 in F.19.3.2-.3).
# ============================================ # ============================================
# ADMIN SYSTEM (Track F.4) # ADMIN SYSTEM (Track F.4)

View File

@@ -3,37 +3,44 @@ Plugins package
All project type plugins are here. 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.8.0 (Phase J): Directus CMS Plugin added
v2.6.0 (Phase I): Appwrite Backend Plugin added v2.6.0 (Phase I): Appwrite Backend Plugin added
v2.5.0 (Phase H): OpenPanel Analytics Plugin added v2.5.0 (Phase H): OpenPanel Analytics Plugin added
v2.3.0 (Phase G): Supabase Self-Hosted 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.base import BasePlugin, PluginRegistry
from plugins.coolify.plugin import CoolifyPlugin from plugins.coolify.plugin import CoolifyPlugin
from plugins.directus.plugin import DirectusPlugin
from plugins.gitea.plugin import GiteaPlugin from plugins.gitea.plugin import GiteaPlugin
from plugins.n8n.plugin import N8nPlugin from plugins.n8n.plugin import N8nPlugin
from plugins.openpanel.plugin import OpenPanelPlugin from plugins.openpanel.plugin import OpenPanelPlugin
from plugins.supabase.plugin import SupabasePlugin from plugins.supabase.plugin import SupabasePlugin
from plugins.woocommerce.plugin import WooCommercePlugin from plugins.woocommerce.plugin import WooCommercePlugin
from plugins.wordpress.plugin import WordPressPlugin from plugins.wordpress.plugin import WordPressPlugin
from plugins.wordpress_advanced.plugin import WordPressAdvancedPlugin from plugins.wordpress_specialist.plugin import WordPressSpecialistPlugin
# Create global registry # Create global registry
registry = PluginRegistry() registry = PluginRegistry()
# Register available plugins # Register available plugins (8 active plugins)
registry.register("wordpress", WordPressPlugin) registry.register("wordpress", WordPressPlugin)
registry.register("woocommerce", WooCommercePlugin) registry.register("woocommerce", WooCommercePlugin)
registry.register("wordpress_advanced", WordPressAdvancedPlugin) registry.register("wordpress_specialist", WordPressSpecialistPlugin)
registry.register("gitea", GiteaPlugin) registry.register("gitea", GiteaPlugin)
registry.register("n8n", N8nPlugin) registry.register("n8n", N8nPlugin)
registry.register("supabase", SupabasePlugin) registry.register("supabase", SupabasePlugin)
registry.register("openpanel", OpenPanelPlugin) registry.register("openpanel", OpenPanelPlugin)
registry.register("appwrite", AppwritePlugin)
registry.register("directus", DirectusPlugin)
registry.register("coolify", CoolifyPlugin) registry.register("coolify", CoolifyPlugin)
__all__ = [ __all__ = [
@@ -42,12 +49,10 @@ __all__ = [
"registry", "registry",
"WordPressPlugin", "WordPressPlugin",
"WooCommercePlugin", "WooCommercePlugin",
"WordPressAdvancedPlugin", "WordPressSpecialistPlugin",
"GiteaPlugin", "GiteaPlugin",
"N8nPlugin", "N8nPlugin",
"SupabasePlugin", "SupabasePlugin",
"OpenPanelPlugin", "OpenPanelPlugin",
"AppwritePlugin",
"DirectusPlugin",
"CoolifyPlugin", "CoolifyPlugin",
] ]

View File

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

View File

@@ -370,16 +370,86 @@ async def _apply_metadata_and_attach(
except Exception as exc: # noqa: BLE001 except Exception as exc: # noqa: BLE001
status["warnings"].append(f"featured_set_failed (product): {exc}") status["warnings"].append(f"featured_set_failed (product): {exc}")
else: else:
try: # Issue #90 — set_featured used to hit /wp/v2/posts/{id}
await wp_set_featured_media(client, attach_to_post, media["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_set"] = True
status["featured_context"] = "post" status["featured_context"] = ctx
except Exception as exc: # noqa: BLE001 else:
status["warnings"].append(f"featured_set_failed (post): {exc}") status["warnings"].append(
f"featured_set_failed: post {attach_to_post} not found "
"in any thumbnail-supporting post type"
)
return status 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]: def _format_upload_result(media: dict[str, Any], *, source: str) -> dict[str, Any]:
title = media.get("title") title = media.get("title")
rendered_title = title.get("rendered") if isinstance(title, dict) else 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", "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)}"}, {"error": str(e), "message": f"Failed to update menu item {item_id}: {str(e)}"},
indent=2, 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", "name": "update_page",
"method_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": { "schema": {
"type": "object", "type": "object",
"properties": { "properties": {
@@ -322,6 +322,14 @@ def get_tool_specifications() -> list[dict[str, Any]]:
"anyOf": [{"type": "integer"}, {"type": "null"}], "anyOf": [{"type": "integer"}, {"type": "null"}],
"description": "Parent page ID", "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"], "required": ["page_id"],
}, },
@@ -892,6 +900,8 @@ class PostsHandler:
status: str | None = None, status: str | None = None,
slug: str | None = None, slug: str | None = None,
parent: int | None = None, parent: int | None = None,
featured_media: int | None = None,
meta: dict | None = None,
) -> str: ) -> str:
""" """
Update an existing WordPress page. Update an existing WordPress page.
@@ -903,6 +913,8 @@ class PostsHandler:
status: Publication status status: Publication status
slug: Page URL slug slug: Page URL slug
parent: Parent page ID parent: Parent page ID
featured_media: Featured image attachment ID (0 clears it)
meta: Post meta key/value map (e.g. Yoast fields)
Returns: Returns:
JSON string with updated page data JSON string with updated page data
@@ -920,6 +932,10 @@ class PostsHandler:
data["slug"] = slug data["slug"] = slug
if parent is not None: if parent is not None:
data["parent"] = parent 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) page = await self.client.post(f"pages/{page_id}", json_data=data)

View File

@@ -119,7 +119,9 @@ class WordPressPlugin(BasePlugin):
else: else:
self.wp_cli = None 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 @staticmethod
def get_tool_specifications() -> list[dict[str, Any]]: def get_tool_specifications() -> list[dict[str, Any]]:
@@ -156,11 +158,12 @@ class WordPressPlugin(BasePlugin):
# Advanced content handlers # Advanced content handlers
specs.extend(handlers.get_seo_specs()) # 4 tools 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 specs.extend(handlers.get_wp_cli_specs()) # 15 tools
# Note: WooCommerce specs moved to woocommerce plugin (Phase D.1) # 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 return specs
@@ -479,6 +482,15 @@ class WordPressPlugin(BasePlugin):
async def update_menu_item(self, **kwargs): async def update_menu_item(self, **kwargs):
return await self.menus.update_menu_item(**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 === # === WP-CLI Operations ===
async def wp_cache_flush(self, **kwargs): async def wp_cache_flush(self, **kwargs):
if self.wp_cli: if self.wp_cli:
@@ -555,7 +567,8 @@ class WordPressPlugin(BasePlugin):
return await self.wp_cli.wp_core_update(**kwargs) return await self.wp_cli.wp_core_update(**kwargs)
return '{"error": "WP-CLI not available. Container not configured."}' 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 === # === Legacy compatibility methods ===
# These methods are kept for potential backward compatibility # 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 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__ = [ __all__ = [
# Common # Common
@@ -64,5 +65,4 @@ __all__ = [
# SEO # SEO
"SEOData", "SEOData",
"SEOUpdate", "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

View File

@@ -1,6 +1,6 @@
[project] [project]
name = "mcphub-server" name = "mcphub-server"
version = "3.12.0" version = "3.13.1"
description = "AI-native management hub for WordPress, WooCommerce, and self-hosted services via Model Context Protocol (MCP)" description = "AI-native management hub for WordPress, WooCommerce, and self-hosted services via Model Context Protocol (MCP)"
authors = [ authors = [
{name = "MCP Hub", email = "contact@mcphub.dev"} {name = "MCP Hub", email = "contact@mcphub.dev"}
@@ -78,8 +78,14 @@ include = ["core*", "plugins*"]
exclude = ["tests*", "data*", "logs*", "temp*", "scripts*", "docs*"] exclude = ["tests*", "data*", "logs*", "temp*", "scripts*", "docs*"]
[tool.setuptools.package-data] [tool.setuptools.package-data]
"core" = ["templates/**/*.html"] "core" = [
"*" = ["*.html", "*.css", "*.js", "*.json"] "templates/**/*.html",
"templates/static/*.svg",
"templates/static/*.css",
"templates/static/dist/*",
"templates/static/dist/**/*",
]
"*" = ["*.html", "*.css", "*.js", "*.json", "*.svg", "*.map", "*.woff", "*.woff2"]
# ==================================== # ====================================
# pytest Configuration # pytest Configuration
@@ -104,6 +110,7 @@ markers = [
"integration: marks tests as integration tests", "integration: marks tests as integration tests",
"unit: marks tests as unit tests", "unit: marks tests as unit tests",
"security: marks tests as security tests", "security: marks tests as security tests",
"frontend: marks tests covering the SPA / Track G backend support routes",
] ]
asyncio_mode = "auto" # Automatically detect async tests asyncio_mode = "auto" # Automatically detect async tests

45
screenshots/README.md Normal file
View File

@@ -0,0 +1,45 @@
# Screenshot reference for the Track G review
Captured against the deployed mcp-test.palebluedot.live with the bilingual
visual-testing harness:
- 3 viewports: `mobile` (375×812), `tablet` (768×1024), `desktop` (1280×800)
- 2 locales: `en`, `fa`
- 8 authenticated dashboard pages under `/dashboard/*`: overview, sites,
connect, api-keys, oauth-clients, health, audit-logs, settings
File name pattern: `{page}-{viewport}-{lang}.png` (48 files total). The
historical `v2-*` page prefixes are intentionally kept in filenames so md5 and
git diffs stay comparable across the G.12 cutover.
## How to regenerate
```bash
# One-time setup (Playwright + Chromium + 285 fallback fonts, all user-space)
cd /tmp && npm install playwright && npx playwright install chromium
# Re-run after a deploy
LD_LIBRARY_PATH=/tmp/playwright-libs/extracted/usr/lib/x86_64-linux-gnu \
FONTCONFIG_PATH=/config/.config/fontconfig \
FONTCONFIG_FILE=/config/.config/fontconfig/fonts.conf \
PLAYWRIGHT_BROWSERS_PATH=/config/.cache/ms-playwright \
MCPHUB_MASTER_KEY=<key> \
node /config/workspace/mcp-skills/skills/bilingual-page-review/scripts/snap-auth.mjs
```
The harness logs in once via the legacy Jinja master-key form at
`/dashboard-legacy/login`, saves the storage state to `.auth-state.json`
(gitignored), then reuses that cookie for every shot against `/dashboard/*`.
Set `MCPHUB_MASTER_KEY` from the Coolify env (`MASTER_API_KEY`) so it never
lands in the script file.
## What to look for when reviewing
| Aspect | EN | FA |
| --- | --- | --- |
| Sidebar position | left | right |
| Topbar control order | hamburger \| crumbs \| ... \| globe/EN \| theme \| bell | bell \| theme \| globe/FA \| ... \| crumbs \| hamburger |
| Numbers in stat cards | `0-9` | `۰-۹` |
| Dates in tables | `2026/05/08 02:47` | `۱۴۰۵/۰۲/۱۸ ۰۲:۴۷` (Shamsi) |
| Wordmark | "MCP Hub" | "MCP Hub" — always LTR, never "Hub MCP" |
| Sidebar nav labels | English | فارسی (مدیریت / سایت‌ها / اتصال / …) |

Binary file not shown.

After

Width:  |  Height:  |  Size: 122 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 106 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 42 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 38 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 58 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 52 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 101 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 98 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 41 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 40 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 54 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 48 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 136 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 132 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 48 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 46 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 69 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 65 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 76 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 72 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 44 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 42 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 51 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 48 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 107 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 112 KiB

Some files were not shown because too many files have changed in this diff Show More