diff --git a/CHANGELOG.md b/CHANGELOG.md
index e809aa3..d3bcf3c 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -5,6 +5,74 @@ All notable changes to MCP Hub will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
+## [3.12.0] — 2026-04-25
+
+### Media pipeline, AI image generation, capability probe, companion v2.9.0 (Tracks F.5a + F.7e + F.18)
+
+The biggest release since v3.11.0 — three months of incremental work batched into one community drop. Core themes:
+
+- **WordPress media pipeline**: end-to-end image flow with optimization, AI generation, and chunked uploads.
+- **Capability discovery**: every site now publishes what it can actually do, and the dashboard reflects it.
+- **Companion plugin overhaul**: `airano-mcp-bridge` (renamed from `airano-mcp-seo-bridge`) gained eight new endpoints and a wp.org Plugin Check pass.
+- **Reliability polish**: idempotent retries, fast-fail on unreachable sites, install hints in every error.
+
+#### Added
+- **AI image generation** (`F.5a.4` / `F.5a.9`): pluggable provider system (OpenAI, Stability, Replicate, Google Nano Banana, OpenRouter image models) → `wordpress_generate_and_upload_image`, attached as featured media or to WC products in one call.
+- **Pillow-based image optimization pipeline** (`F.5a.2`): client-side resize / format conversion / quality knobs before upload.
+- **Chunked media upload** (`F.5a.5`): large files uploaded in resumable chunks; `upload_media_chunked_status` lets you resume after a disconnect (`F.5a.8.4`).
+- **Bulk media tools** (`F.5a.8.3`): `bulk_delete_media` and `bulk_reassign_media`.
+- **Idempotency-Key dedup** for AI media upload retries — same key returns the previous result instead of double-uploading.
+- **Companion v2.9.0** (`airano-mcp-bridge`):
+ - `/upload-and-attach` — single-call combined endpoint (`F.5a.8.5`).
+ - `/regenerate-thumbnails` (`F.5a.8.2`).
+ - `/capabilities` + `wordpress_probe_capabilities` (`F.18.1`).
+ - `/bulk-meta` + `bulk_update_meta` (`F.18.2`).
+ - `/export` + `export_content` (`F.18.3`).
+ - `/cache-purge` + `cache_purge` (`F.18.4`).
+ - `/transient-flush` + `transient_flush` (`F.18.5`).
+ - `/site-health` + `site_health` (`F.18.6`).
+ - `/audit-hook` + MCPHub webhook receiver (`F.18.7`).
+- **Provider Keys dashboard** (`F.18.8`): per-site AI provider key management UI with no-reload UX; AI tool visibility is now gated on per-site provider key presence.
+- **Credential capability probe** (`F.7e`): WordPress / WooCommerce / Gitea adapters report what tier of operations the saved credential can actually perform; capability badge UI + HTMX partial re-check on the site manage page.
+- **Tier requirements engine**: `TIER_REQUIREMENTS` + `evaluate_tier_fit(granted ∪ roles)` so alias matching unions both sources.
+- **Companion install hint** in every companion-unreachable error (`F.7e`).
+- **Gitea ergonomics** (`F.17`): batch file ops, tree, search, compare, releases, fork shortcuts.
+- **Opportunistic bcrypt upgrade** for legacy SHA-256 admin keys (`F.8`).
+- **n8n refactor**: structured errors, capability probe, missing tools backfilled.
+
+#### Changed
+- **`get_post`** returns `slug`, `featured_media`, `featured_media_url` by default.
+- **`get_media` / `list_media`** expose `post_parent`.
+- **WordPress client** fast-fails on unreachable sites with an `install_hint`.
+- **Rank Math** integration uses canonical `rank_math_title` instead of the older `rank_math_seo_title`.
+- **Tool prerequisites** surfaced in the dashboard; tool list shows the expected provider/credential per tier.
+- **WooCommerce probe** infers capabilities from REST key scope.
+- **Default model** + dark background polish across dashboard surfaces.
+- **Companion route map** is single-source (`capabilities` and `site_health` share their definitions).
+
+#### Fixed
+- **AI provider gating**: per-site visibility now requires a present provider key; surfaces correctly hide tools when keys are absent.
+- **Capability badge re-check** is an HTMX partial swap (no full reload).
+- **WC media tools** accept WordPress App Password and global attribute id.
+- **`set_featured_image` for WC products**, AI image attach to WC products, provider enum narrowing, WC badge wording.
+- **Companion `airano-mcp-bridge` security**: scheme allowlist on the audit-hook endpoint URL; wp.org Plugin Check warnings (i18n + `WP_Filesystem`) cleared.
+
+#### Deployment
+- **PyPI mirror chain** + removed dead hardcoded proxy.
+- **Optional `BUILD_HTTP_PROXY`** for restrictive build networks.
+- Alpine `apk` swapped to Yandex mirror; **Debian-slim Plan-B** Dockerfile added.
+- `mirror.gcr.io` variant for Docker Hub TLS-timeout fallback.
+- Gitea mirror documented as fallback deploy source.
+
+#### Documentation
+- README: dropped fixed "633 tools" claims in favour of per-plugin approximations + a note that the count grows with each release.
+- CLAUDE.md: total tool count is no longer asserted as a fixed number.
+
+#### Companion plugin
+- **Renamed**: `airano-mcp-seo-bridge` → `airano-mcp-bridge` (the plugin now does much more than SEO).
+
+---
+
## [3.11.0] — 2026-04-14
### Plugin-specific access levels, regression tests, UX polish (Track F.7d)
diff --git a/CLAUDE.md b/CLAUDE.md
index 429fc1a..10343f9 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -4,7 +4,7 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
## 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) with 633 tools total. The tool count stays constant regardless of how many sites are configured.
+**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.
## Quick Setup
@@ -190,7 +190,7 @@ v4 development cycle with 15 phases. See `docs/ROADMAP.md` (Track F) and `.claud
## Gotchas
- 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-seo-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
- 5 plugins are tested for public use: WordPress, WooCommerce, Supabase, OpenPanel, Gitea. Others are admin-only or disabled.
- OAuth Clients (Client ID/Secret) are for MCP endpoint auth, NOT the same as GitHub/Google dashboard login
diff --git a/Dockerfile b/Dockerfile
index f2854eb..3a36f2e 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -27,7 +27,8 @@ RUN pip install --no-cache-dir --user -r requirements.txt
FROM python:3.12-alpine AS production
# CRITICAL: Install wget for health checks + docker-cli for WP-CLI tools
-RUN apk add --no-cache wget curl docker-cli
+# libmagic is required by python-magic (F.5a media upload MIME sniffing)
+RUN 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
diff --git a/Dockerfile.mirror b/Dockerfile.mirror
new file mode 100644
index 0000000..60e46e9
--- /dev/null
+++ b/Dockerfile.mirror
@@ -0,0 +1,123 @@
+# ===================================
+# 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"]
diff --git a/Dockerfile.slim b/Dockerfile.slim
new file mode 100644
index 0000000..9c9eaa9
--- /dev/null
+++ b/Dockerfile.slim
@@ -0,0 +1,113 @@
+# ===================================
+# MCP Hub — Dockerfile (Debian slim, restrictive-network variant)
+# ===================================
+# Plan B fallback when both registry-1.docker.io AND
+# dl-cdn.alpinelinux.org are unreachable from the build host.
+#
+# Switches the base from python:3.12-alpine (musl + apk) to
+# python:3.12-slim-bookworm (Debian + apt). Debian's package mirrors
+# are CDN-fronted (Cloudflare/Fastly) and are usually reachable from
+# networks that block Alpine's CDN. Image is pulled from mirror.gcr.io
+# to also bypass Docker Hub TLS issues.
+#
+# Tradeoffs vs Dockerfile.mirror (Alpine):
+# - Image size: ~120 MB (slim) vs ~60 MB (alpine) — acceptable
+# - Compatibility: ALL Python wheels work (no musl rebuild needed)
+# - Security baseline: equivalent (slim is minimal Debian, no shell extras)
+#
+# Switch back to Dockerfile.mirror once dl-cdn.alpinelinux.org reachable
+# OR back to Dockerfile once registry-1.docker.io is reachable.
+# ===================================
+
+# Stage 1: Build stage
+FROM mirror.gcr.io/library/python:3.12-slim-bookworm AS builder
+
+# Optional HTTP proxy for restricted networks. ARG values are NOT baked
+# into the final image, so the runtime container never carries the proxy.
+ARG BUILD_HTTP_PROXY=""
+ARG BUILD_HTTPS_PROXY=""
+ARG BUILD_NO_PROXY=""
+
+# Install build dependencies via apt (proxy honoured if BUILD_HTTP_PROXY set)
+RUN export HTTP_PROXY="${BUILD_HTTP_PROXY}" \
+ HTTPS_PROXY="${BUILD_HTTPS_PROXY}" \
+ NO_PROXY="${BUILD_NO_PROXY}" \
+ && apt-get update && apt-get install -y --no-install-recommends \
+ build-essential \
+ libffi-dev \
+ libssl-dev \
+ && rm -rf /var/lib/apt/lists/*
+
+# Create build directory
+WORKDIR /build
+
+# Copy requirements and install Python dependencies (proxy honoured)
+COPY requirements.txt .
+RUN export HTTP_PROXY="${BUILD_HTTP_PROXY}" \
+ HTTPS_PROXY="${BUILD_HTTPS_PROXY}" \
+ NO_PROXY="${BUILD_NO_PROXY}" \
+ && pip install --no-cache-dir --user -r requirements.txt
+
+
+# Stage 2: Production stage
+FROM mirror.gcr.io/library/python:3.12-slim-bookworm AS production
+
+# Re-declare proxy ARGs in this stage (ARGs don't cross stage boundaries).
+ARG BUILD_HTTP_PROXY=""
+ARG BUILD_HTTPS_PROXY=""
+ARG BUILD_NO_PROXY=""
+
+# CRITICAL: Install wget for health checks + docker-cli for WP-CLI tools
+# libmagic1 is required by python-magic (F.5a media upload MIME sniffing)
+RUN export HTTP_PROXY="${BUILD_HTTP_PROXY}" \
+ HTTPS_PROXY="${BUILD_HTTPS_PROXY}" \
+ NO_PROXY="${BUILD_NO_PROXY}" \
+ && apt-get update && apt-get install -y --no-install-recommends \
+ wget \
+ curl \
+ docker.io \
+ libmagic1 \
+ && rm -rf /var/lib/apt/lists/*
+
+# Create non-root user for security and grant Docker socket access
+# Docker group (GID 999) allows access to /var/run/docker.sock
+RUN groupadd -g 1001 appgroup && \
+ useradd -u 1001 -g appgroup -s /bin/sh -m appuser && \
+ (groupadd -g 999 docker 2>/dev/null || true) && \
+ (usermod -aG docker appuser 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"]
diff --git a/README.md b/README.md
index 21c3df8..65c1dde 100644
--- a/README.md
+++ b/README.md
@@ -13,8 +13,7 @@ Connect your sites, stores, repos, and databases — manage them all through Cla
[](https://www.python.org/)
[](https://pypi.org/project/mcphub-server/)
[](https://hub.docker.com/r/airano/mcphub)
-[]()
-[]()
+[]()
[](https://github.com/airano-ir/mcphub/actions/workflows/ci.yml)
@@ -25,7 +24,7 @@ Connect your sites, stores, repos, and databases — manage them all through Cla
WordPress powers 43% of the web. WooCommerce runs 36% of online stores. Yet **no MCP server existed** for managing them through AI — until now.
-MCP Hub is the first MCP server that lets you manage WordPress, WooCommerce, and 7 other self-hosted services through any AI assistant. Instead of clicking through dashboards, just tell your AI what to do:
+MCP Hub is the first MCP server that lets you manage WordPress, WooCommerce, and 8 other self-hosted services through any AI assistant. Instead of clicking through dashboards, just tell your AI what to do:
> *"Update the SEO meta description for all WooCommerce products that don't have one"*
>
@@ -41,7 +40,7 @@ MCP Hub is the first MCP server that lets you manage WordPress, WooCommerce, and
| AI agent integration | No | No | No | **Native (MCP)** |
| Full WordPress API | Dashboard | Dashboard | Content only | **67 tools** |
| WooCommerce management | No | Limited | No | **28 tools** |
-| Git/CI management | No | No | No | **56 tools (Gitea)** |
+| Git/CI management | No | No | No | **65 tools (Gitea)** |
| Automation workflows | No | No | No | **56 tools (n8n)** |
| Self-hosted | No | Yes | N/A | **Yes** |
| Open source | No | Core only | Varies | **Fully open** |
@@ -49,22 +48,28 @@ MCP Hub is the first MCP server that lets you manage WordPress, WooCommerce, and
---
-## 633 Tools Across 10 Plugins
+## 10 Plugins, Hundreds of Tools
-| Plugin | Tools | What You Can Do |
-|--------|-------|-----------------|
-| **WordPress** | 67 | Posts, pages, media, users, menus, taxonomies, SEO (Rank Math/Yoast) |
-| **WooCommerce** | 28 | Products, orders, customers, coupons, reports, shipping |
-| **WordPress Advanced** | 22 | Database ops, bulk operations, WP-CLI, system management |
-| **Gitea** | 58 | Repos, issues, pull requests, releases, webhooks, organizations, labels |
-| **n8n** | 56 | Workflows, executions, credentials, variables, audit |
-| **Supabase** | 70 | Database, auth, storage, edge functions, realtime |
-| **OpenPanel** | 42 | Events, export, insights, profiles, projects, system |
-| **Appwrite** | 100 | Databases, auth, storage, functions, teams, messaging |
-| **Directus** | 100 | Collections, items, users, files, flows, permissions |
-| **Coolify** | 67 | Applications, deployments, servers, projects, databases, services |
-| **System** | 23 | Health monitoring, API keys, OAuth management, audit |
-| **Total** | **633** | Constant count — scales to unlimited sites |
+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
+scope — pick a plugin-specific endpoint to keep the surface area small.
+
+| Plugin | Approx. Tools | What You Can Do |
+|--------|---------------:|-----------------|
+| **WordPress** | ~70 | Posts, pages, media (incl. AI image generation), users, menus, taxonomies, SEO (Rank Math/Yoast) |
+| **WooCommerce** | ~30 | Products, orders, customers, coupons, reports, shipping |
+| **WordPress Advanced** | ~20 | Database ops, bulk operations, WP-CLI, system management |
+| **Gitea** | ~65 | Repos, issues, pull requests, releases, webhooks, organizations, labels, batch files, tree, search, compare |
+| **n8n** | ~55 | Workflows, executions, credentials, variables, audit |
+| **Supabase** | ~70 | Database, auth, storage, edge functions, realtime |
+| **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 |
+| **System** | ~25 | Health monitoring, API keys, OAuth management, audit |
+
+> Per-site duplication does **not** inflate the tool count — adding a second
+> WordPress site reuses the same WordPress tools with a different `site` argument.
---
@@ -266,30 +271,32 @@ MCP Hub supports **Open Dynamic Client Registration** (RFC 7591). ChatGPT can au
## Architecture
```
-/mcp → Admin endpoint (all 633 tools)
-/system/mcp → System tools only (23 tools)
-/wordpress/mcp → WordPress tools (67 tools)
-/woocommerce/mcp → WooCommerce tools (28 tools)
-/wordpress-advanced/mcp → WordPress Advanced tools (22 tools)
-/gitea/mcp → Gitea tools (58 tools)
-/n8n/mcp → n8n tools (56 tools)
-/supabase/mcp → Supabase tools (70 tools)
-/openpanel/mcp → OpenPanel tools (42 tools)
-/appwrite/mcp → Appwrite tools (100 tools)
-/directus/mcp → Directus tools (100 tools)
-/coolify/mcp → Coolify tools (67 tools)
+/mcp → Admin endpoint (every enabled tool)
+/system/mcp → System tools only
+/wordpress/mcp → WordPress tools
+/woocommerce/mcp → WooCommerce tools
+/wordpress-advanced/mcp → WordPress Advanced tools
+/gitea/mcp → Gitea tools
+/n8n/mcp → n8n tools
+/supabase/mcp → Supabase tools
+/openpanel/mcp → OpenPanel tools
+/appwrite/mcp → Appwrite tools
+/directus/mcp → Directus tools
+/coolify/mcp → Coolify tools
/project/{alias}/mcp → Per-project endpoint (auto-injects site)
/u/{user_id}/{alias}/mcp → Per-user endpoint (hosted/OAuth users)
```
-**Recommendation**: Use plugin-specific endpoints instead of `/mcp` (633 tools) to minimize token usage.
+**Recommendation**: Use plugin-specific endpoints instead of the all-tools `/mcp`
+admin endpoint to keep your AI client's tool window small (and your token bill
+lower).
-| Endpoint | Use Case | Tools |
-|----------|----------|------:|
-| `/u/{user_id}/{alias}/mcp` | Hosted users (OAuth login) | 22-100 |
-| `/project/{alias}/mcp` | Single-site workflow (recommended) | 22-100 |
-| `/{plugin}/mcp` | Multi-site management | 23-101 |
-| `/mcp` | Admin & discovery only | 633 |
+| Endpoint | Use Case |
+|----------|----------|
+| `/u/{user_id}/{alias}/mcp` | Hosted users (OAuth login) — single service |
+| `/project/{alias}/mcp` | Single-site workflow (recommended) |
+| `/{plugin}/mcp` | Multi-site management for one service |
+| `/mcp` | Admin & discovery only — every enabled tool |
### Security
@@ -307,7 +314,7 @@ Some MCP Hub tools require companion WordPress plugins:
| Tools | Requirement |
|-------|-------------|
-| SEO tools (`wordpress_get_post_seo`, etc.) | [Airano MCP SEO Bridge](https://wordpress.org/plugins/airano-mcp-seo-bridge/) ([GitHub](wordpress-plugin/airano-mcp-seo-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 |
| WordPress Advanced database/system tools | Docker socket + `CONTAINER` config |
| OpenPanel analytics integration | [OpenPanel Self-Hosted](wordpress-plugin/openpanel-self-hosted/) ([Download ZIP](wordpress-plugin/openpanel-self-hosted.zip)) |
@@ -345,7 +352,7 @@ Set the `container` field when adding a WordPress site in the dashboard. Without
# Install with dev dependencies
pip install -e ".[dev]"
-# Run tests (481 tests)
+# Run tests
pytest
# Format and lint
diff --git a/core/api_keys.py b/core/api_keys.py
index 62abd6a..578c46c 100644
--- a/core/api_keys.py
+++ b/core/api_keys.py
@@ -6,6 +6,7 @@ and audit trail.
"""
import hashlib
+import hmac
import json
import logging
import os
@@ -185,13 +186,60 @@ class APIKeyManager:
"""Hash API key for storage using bcrypt."""
return bcrypt.hashpw(api_key.encode(), bcrypt.gensalt()).decode()
+ @staticmethod
+ def _is_bcrypt_hash(key_hash: str) -> bool:
+ """Return True if the stored hash is in bcrypt format ($2a/$2b/$2y)."""
+ return key_hash.startswith("$2")
+
def _verify_key(self, api_key: str, key_hash: str) -> bool:
- """Verify API key against stored hash (supports bcrypt and legacy SHA-256)."""
- if key_hash.startswith("$2"):
- # bcrypt hash
- return bcrypt.checkpw(api_key.encode(), key_hash.encode())
- # Legacy SHA-256 fallback
- return hashlib.sha256(api_key.encode()).hexdigest() == key_hash
+ """Verify API key against stored hash.
+
+ Supports both the current bcrypt format and a legacy SHA-256
+ fallback so pre-bcrypt keys keep working. F.8 security-
+ hardening note: SHA-256 is a fast hash with no per-hash salt —
+ a stolen keys.json file would enable offline brute-force on
+ any legacy entry. We therefore:
+
+ 1. accept legacy hashes here (so customers aren't locked out), and
+ 2. opportunistically upgrade every legacy hash to bcrypt the
+ first time it's successfully verified (see
+ :meth:`_upgrade_legacy_hash`).
+
+ Brand-new keys created via :meth:`create_key` are always
+ bcrypt-hashed — legacy SHA-256 only appears for rows that
+ existed before the F.4 / F.8 hardening passes.
+ """
+ if self._is_bcrypt_hash(key_hash):
+ try:
+ return bcrypt.checkpw(api_key.encode(), key_hash.encode())
+ except ValueError:
+ # Truncated / corrupt bcrypt hash — treat as mismatch
+ # rather than raising.
+ return False
+ # Legacy SHA-256 fallback (constant-time compare to avoid
+ # timing oracles on the legacy-hash path).
+ expected = hashlib.sha256(api_key.encode()).hexdigest()
+ return hmac.compare_digest(expected, key_hash)
+
+ def _upgrade_legacy_hash(self, key: APIKey, api_key: str) -> bool:
+ """Re-hash a legacy SHA-256 entry with bcrypt and persist.
+
+ Called from the verify paths the moment a legacy hash is
+ successfully matched, so the next verify uses bcrypt. Returns
+ True when an upgrade happened, False otherwise. Errors during
+ persist are logged but swallowed — the key still validates,
+ we just try again next time.
+ """
+ if self._is_bcrypt_hash(key.key_hash):
+ return False
+ try:
+ key.key_hash = self._hash_key(api_key)
+ self._save_keys()
+ logger.info("Upgraded legacy SHA-256 key hash %s to bcrypt", key.key_id)
+ return True
+ except Exception as exc: # pragma: no cover — defensive
+ logger.warning("Failed to upgrade legacy hash for key %s: %s", key.key_id, exc)
+ return False
def create_key(
self,
@@ -283,6 +331,9 @@ class APIKeyManager:
for key_id, key in self.keys.items():
if not self._verify_key(api_key, key.key_hash):
continue
+ # F.8: opportunistically upgrade legacy SHA-256 hashes to bcrypt
+ # the moment they validate successfully.
+ self._upgrade_legacy_hash(key, api_key)
# Check if valid (not revoked, not expired)
if not key.is_valid():
@@ -351,6 +402,8 @@ class APIKeyManager:
"""
for key_id, key in self.keys.items():
if self._verify_key(api_key, key.key_hash):
+ # F.8: upgrade legacy SHA-256 hashes on first successful match.
+ self._upgrade_legacy_hash(key, api_key)
logger.debug(f"Found API key {key_id} by token")
return key
diff --git a/core/capability_probe.py b/core/capability_probe.py
new file mode 100644
index 0000000..fba2699
--- /dev/null
+++ b/core/capability_probe.py
@@ -0,0 +1,485 @@
+"""F.7e — per-site credential capability probe.
+
+Each plugin's ``probe_capabilities()`` (defined on ``BasePlugin``) knows
+how to ask its upstream service what the saved credential can actually
+do. This module wraps those calls with:
+
+* Per-site in-memory TTL cache (default 10 min) so the probe doesn't
+ hammer the upstream service on every dashboard page view.
+* A thin wrapper that decrypts the site's credentials, instantiates the
+ plugin, calls the probe, and normalises the result.
+* ``/api/sites/{id}/capabilities`` Starlette handler that the dashboard
+ UI consumes (wired in ``core/dashboard/routes.py``).
+
+The cache is process-local and not persisted. Workers start cold and
+populate on demand; invalidation happens on cache expiry or explicit
+site-credential update.
+"""
+
+from __future__ import annotations
+
+import logging
+import os
+import time
+from typing import Any
+
+from starlette.requests import Request
+from starlette.responses import JSONResponse
+
+logger = logging.getLogger("mcphub.capability_probe")
+
+# Cache TTL in seconds (default 10 min). Upstream capability rarely
+# changes — most drift happens when the operator rotates an
+# app_password or changes a Gitea token's scopes, both of which are
+# infrequent.
+_CACHE_TTL_SECONDS = int(os.environ.get("CAPABILITY_PROBE_TTL", "600"))
+
+
+class _ProbeCache:
+ """Trivial in-memory ``site_id -> (expires_at, payload)`` cache."""
+
+ def __init__(self, ttl_seconds: int = _CACHE_TTL_SECONDS) -> None:
+ self._ttl = ttl_seconds
+ self._entries: dict[str, tuple[float, dict[str, Any]]] = {}
+
+ def get(self, site_id: str) -> dict[str, Any] | None:
+ entry = self._entries.get(site_id)
+ if entry is None:
+ return None
+ expires_at, payload = entry
+ if expires_at < time.time():
+ self._entries.pop(site_id, None)
+ return None
+ return payload
+
+ def set(self, site_id: str, payload: dict[str, Any]) -> None:
+ self._entries[site_id] = (time.time() + self._ttl, payload)
+
+ def invalidate(self, site_id: str) -> bool:
+ return self._entries.pop(site_id, None) is not None
+
+
+_cache = _ProbeCache()
+
+
+def get_probe_cache() -> _ProbeCache:
+ return _cache
+
+
+# ---------------------------------------------------------------------------
+# F.7e — tier-fit evaluation: compare probe.granted with what the site's
+# selected ``tool_scope`` actually needs.
+# ---------------------------------------------------------------------------
+
+
+# Capability names required for each (plugin_type, tier) pair. Tiers come
+# from ``core/tool_access.py``. For plugins whose probe doesn't map 1:1
+# onto these names (e.g. Gitea scopes use ``read:repository`` rather than
+# ``read``) the fit evaluator's ``_aliased_granted`` helper below
+# normalises both sides before comparing.
+TIER_REQUIREMENTS: dict[str, dict[str, set[str]]] = {
+ "wordpress": {
+ "read": {"read"},
+ "write": {"edit_posts", "upload_files"},
+ # WP-admin-tier tools need the same cap as WP's admin area itself.
+ "admin": {"manage_options"},
+ },
+ "woocommerce": {
+ "read": {"read_products"},
+ "write": {"write_products"},
+ "admin": {"write_products"},
+ },
+ "gitea": {
+ "read": {"read:repository"},
+ "write": {"write:repository"},
+ "admin": {"admin:repo_hook"},
+ },
+}
+
+# Aliases that plugins may return from their probe which should be
+# treated as equivalent to the canonical cap names in TIER_REQUIREMENTS.
+# Keeps the adapter implementations honest about what the upstream
+# service actually says while letting the evaluator compare sets.
+_CAP_ALIASES: dict[str, set[str]] = {
+ "manage_options": {"administrator", "manage_options"},
+ "edit_posts": {"edit_posts", "editor", "administrator"},
+ "upload_files": {"upload_files", "editor", "administrator"},
+ "read": {"read", "subscriber", "contributor", "author", "editor", "administrator"},
+ "read_products": {"read_products", "read", "read_write"},
+ "write_products": {"write_products", "write", "read_write"},
+ "read_orders": {"read_orders", "read", "read_write"},
+ "write_orders": {"write_orders", "write", "read_write"},
+}
+
+
+def _cap_matches(required: str, granted: set[str]) -> bool:
+ """Return True if ``required`` is satisfied by any cap in ``granted``.
+
+ Uses ``_CAP_ALIASES`` to accept role names (WP) and permission
+ strings (WC) alongside the canonical capability names.
+ """
+ if required in granted:
+ return True
+ aliases = _CAP_ALIASES.get(required, {required})
+ return bool(aliases & granted)
+
+
+def evaluate_tier_fit(
+ plugin_type: str,
+ tier: str | None,
+ probe_payload: dict[str, Any],
+) -> dict[str, Any]:
+ """Decide whether ``probe.granted`` covers what ``tier`` requires.
+
+ Args:
+ plugin_type: e.g. "wordpress", "woocommerce", "gitea".
+ tier: the site's selected ``tool_scope`` preset (read / write /
+ admin / custom / None).
+ probe_payload: the dict returned by ``probe_site_capabilities``
+ (must contain ``probe_available`` and ``granted``).
+
+ Returns:
+ A dict with:
+ * ``status``: one of ``ok`` | ``warning`` | ``probe_unavailable``
+ | ``unknown_tier``.
+ * ``required``: list[str] of caps the tier needs (empty when
+ the tier is ``custom`` / not in the table).
+ * ``missing``: list[str] of required caps not present in
+ ``granted``.
+ * ``reason``: passthrough from the probe when probe is
+ unavailable, else ``None``.
+
+ ``custom`` tier always returns ``status='ok'`` because by definition
+ the caller picked individual tools — we can't check a tier-level
+ contract.
+ """
+ tier_norm = (tier or "").strip().lower()
+
+ if not probe_payload.get("probe_available", False):
+ return {
+ "status": "probe_unavailable",
+ "required": [],
+ "missing": [],
+ "reason": probe_payload.get("reason"),
+ }
+
+ if tier_norm in {"", "custom"}:
+ return {
+ "status": "ok",
+ "required": [],
+ "missing": [],
+ "reason": None,
+ }
+
+ requirements = (TIER_REQUIREMENTS.get(plugin_type) or {}).get(tier_norm)
+ if requirements is None:
+ return {
+ "status": "unknown_tier",
+ "required": [],
+ "missing": [],
+ "reason": f"no_tier_table_for:{plugin_type}/{tier_norm}",
+ }
+
+ # F.X.fix #5: the probe places WP role names under ``roles`` and
+ # individual capability strings under ``granted`` — historically we
+ # only compared against ``granted``, so the ``read`` tier always
+ # reported ``warning: Missing read`` even for admin users, because
+ # ``read`` is implied by every role but not emitted as a bare cap
+ # in the companion's capability map. Union the two sets so the
+ # alias resolver in ``_cap_matches`` can see roles too.
+ granted = set(probe_payload.get("granted") or [])
+ roles = set(probe_payload.get("roles") or [])
+ effective = granted | roles
+ missing = sorted(cap for cap in requirements if not _cap_matches(cap, effective))
+
+ return {
+ "status": "warning" if missing else "ok",
+ "required": sorted(requirements),
+ "missing": missing,
+ "reason": None,
+ }
+
+
+async def probe_site_capabilities(
+ site_id: str,
+ user_id: str,
+ *,
+ force: bool = False,
+) -> dict[str, Any]:
+ """Return the capability probe payload for a user-owned site.
+
+ Uses a 10-minute per-site TTL cache unless ``force=True``.
+ Response shape:
+
+ {
+ "site_id": str,
+ "plugin_type": str,
+ "probe_available": bool,
+ "granted": list[str],
+ "source": str,
+ "reason": str | None, # only when probe_available=False
+ "cached": bool,
+ # + any plugin-specific extras (roles, plugin_version, ...)
+ }
+ """
+ from core.database import get_database
+ from core.encryption import get_credential_encryption
+ from plugins import registry as plugin_registry
+
+ db = get_database()
+ site = await db.get_site(site_id, user_id)
+ if site is None:
+ return {
+ "site_id": site_id,
+ "plugin_type": None,
+ "probe_available": False,
+ "granted": [],
+ "source": "unavailable",
+ "reason": "site_not_found",
+ "cached": False,
+ }
+
+ if not force:
+ cached = _cache.get(site_id)
+ if cached is not None:
+ out = dict(cached)
+ out["cached"] = True
+ return out
+
+ plugin_type = site["plugin_type"]
+ if not plugin_registry.is_registered(plugin_type):
+ return {
+ "site_id": site_id,
+ "plugin_type": plugin_type,
+ "probe_available": False,
+ "granted": [],
+ "source": "unavailable",
+ "reason": f"plugin_not_registered:{plugin_type}",
+ "cached": False,
+ }
+
+ try:
+ encryptor = get_credential_encryption()
+ credentials = encryptor.decrypt_credentials(site["credentials"], site_id)
+ except Exception as exc: # noqa: BLE001
+ logger.warning("capability_probe: decrypt failed for site %s: %s", site_id, exc)
+ return {
+ "site_id": site_id,
+ "plugin_type": plugin_type,
+ "probe_available": False,
+ "granted": [],
+ "source": "unavailable",
+ "reason": "credentials_decrypt_failed",
+ "cached": False,
+ }
+
+ config_dict: dict[str, Any] = {
+ "site_url": site["url"],
+ "url": site["url"],
+ "alias": site["alias"],
+ "user_id": user_id,
+ "site_id": site_id,
+ **credentials,
+ }
+
+ try:
+ instance = plugin_registry.create_instance(
+ plugin_type,
+ project_id=f"probe_{site_id}",
+ config=config_dict,
+ )
+ except Exception as exc: # noqa: BLE001
+ logger.warning("capability_probe: plugin instantiation failed for %s: %s", site_id, exc)
+ return {
+ "site_id": site_id,
+ "plugin_type": plugin_type,
+ "probe_available": False,
+ "granted": [],
+ "source": "unavailable",
+ "reason": f"plugin_instantiation_failed: {exc}",
+ "cached": False,
+ }
+
+ try:
+ result = await instance.probe_credential_capabilities()
+ except Exception as exc: # noqa: BLE001
+ logger.warning("capability_probe: probe call raised for site %s: %s", site_id, exc)
+ result = {
+ "probe_available": False,
+ "granted": [],
+ "source": "unavailable",
+ "reason": f"probe_call_failed: {exc}",
+ }
+
+ payload: dict[str, Any] = {
+ "site_id": site_id,
+ "plugin_type": plugin_type,
+ "probe_available": bool(result.get("probe_available", False)),
+ "granted": list(result.get("granted") or []),
+ "source": result.get("source") or "unavailable",
+ }
+ if not payload["probe_available"]:
+ payload["reason"] = result.get("reason")
+ # F.X.fix #3: propagate install_hint so the dashboard can render
+ # the "site unreachable / install companion" prompt without a
+ # second probe call.
+ # F.X.fix-pass3: also propagate routes + features so the
+ # tool-prerequisites resolver in core/tool_access can compute
+ # tool availability without a second probe call.
+ # F.X.fix-pass5: also propagate wp_credentials_present so the
+ # prerequisites resolver can auto-disable WC media tools when
+ # the site has no WP Application Password configured.
+ for extra in (
+ "roles",
+ "plugin_version",
+ "install_hint",
+ "routes",
+ "features",
+ "wp_credentials_present",
+ ):
+ if extra in result:
+ payload[extra] = result[extra]
+
+ # F.X.fix-pass2 — surface the site's configured AI-provider set so
+ # the badge can show a distinct "no AI provider key" warning
+ # independent of tier fit. Even an Administrator WP credential
+ # can't run the AI image tool without a provider key. Cheap: one
+ # SQLite query over site_provider_keys.
+ try:
+ from core.site_api import list_site_providers_set
+
+ payload["ai_providers_configured"] = sorted(await list_site_providers_set(site_id))
+ except Exception as exc: # noqa: BLE001
+ logger.debug("capability_probe: provider-set lookup skipped for %s: %s", site_id, exc)
+ payload["ai_providers_configured"] = []
+
+ # Cache everything, including the "probe unavailable" answer — a
+ # missing companion plugin is a stable fact until the operator
+ # installs it and re-tests the connection.
+ _cache.set(site_id, payload)
+ payload_out = dict(payload)
+ payload_out["cached"] = False
+ return payload_out
+
+
+# ---------------------------------------------------------------------------
+# Starlette handler: GET /api/sites/{id}/capabilities
+# ---------------------------------------------------------------------------
+
+
+async def api_site_capabilities(request: Request) -> JSONResponse:
+ """Return the capability probe for a user-owned site.
+
+ Auth: same OAuth user session guard as the other site endpoints.
+ Query params:
+ * ``force=1`` — bypass the 10-minute cache.
+ """
+ from core.dashboard.routes import _require_user_session
+
+ user_session, redirect = _require_user_session(request)
+ if redirect or user_session is None:
+ return JSONResponse({"ok": False, "error": "unauthorized"}, status_code=401)
+
+ site_id = (request.path_params.get("id") or "").strip()
+ if not site_id:
+ return JSONResponse({"ok": False, "error": "invalid_request"}, status_code=400)
+
+ force = request.query_params.get("force") in {"1", "true", "True"}
+
+ payload = await probe_site_capabilities(
+ site_id=site_id, user_id=user_session["user_id"], force=force
+ )
+
+ if payload.get("reason") == "site_not_found":
+ return JSONResponse({"ok": False, "error": "site_not_found"}, status_code=404)
+
+ # F.7e: also evaluate fit against the site's currently-selected
+ # tool_scope tier so the UI can render the badge without needing a
+ # second request. The caller-supplied ?tier=... query param lets the
+ # dashboard preview a tier before save.
+ from core.database import get_database
+
+ db = get_database()
+ site = await db.get_site(site_id, user_session["user_id"])
+ site_tier = (site or {}).get("tool_scope")
+ # Allow the caller to override the tier (e.g. preview before save).
+ tier_override = request.query_params.get("tier")
+ tier = tier_override or site_tier
+
+ fit = evaluate_tier_fit(
+ plugin_type=payload.get("plugin_type") or "",
+ tier=tier,
+ probe_payload=payload,
+ )
+
+ return JSONResponse(
+ {
+ "ok": True,
+ **payload,
+ "tier": tier,
+ "fit": fit,
+ }
+ )
+
+
+async def api_site_capabilities_badge(request: Request):
+ """F.X.fix #9 — render the capability-badge template fragment.
+
+ Used by the HTMX Re-check button so the badge swaps in place
+ instead of forcing a full-page reload. Response is HTML (not JSON)
+ — the caller sets ``hx-swap="outerHTML"`` on the badge element.
+ """
+ from starlette.responses import HTMLResponse
+
+ from core.dashboard.routes import _require_user_session, templates
+
+ user_session, redirect = _require_user_session(request)
+ if redirect or user_session is None:
+ return HTMLResponse("
unauthorized
", status_code=401)
+
+ site_id = (request.path_params.get("id") or "").strip()
+ if not site_id:
+ return HTMLResponse("invalid_request
", status_code=400)
+
+ force = request.query_params.get("force") in {"1", "true", "True"}
+
+ payload = await probe_site_capabilities(
+ site_id=site_id, user_id=user_session["user_id"], force=force
+ )
+ if payload.get("reason") == "site_not_found":
+ return HTMLResponse("site_not_found
", status_code=404)
+
+ from core.database import get_database
+
+ db = get_database()
+ site = await db.get_site(site_id, user_session["user_id"])
+ if site is None:
+ return HTMLResponse("site_not_found
", status_code=404)
+
+ tier = request.query_params.get("tier") or site.get("tool_scope")
+ fit = evaluate_tier_fit(
+ plugin_type=payload.get("plugin_type") or "",
+ tier=tier,
+ probe_payload=payload,
+ )
+ capability_probe = {**payload, "tier": tier, "fit": fit}
+
+ # Pull up the companion download URL the same way the page does.
+ try:
+ from plugins.wordpress.handlers._companion_hint import COMPANION_DOWNLOAD_URL
+
+ companion_download_url: str | None = COMPANION_DOWNLOAD_URL
+ except Exception: # noqa: BLE001
+ companion_download_url = None
+
+ lang = request.query_params.get("lang") or request.cookies.get("lang") or "en"
+ return templates.TemplateResponse(
+ request,
+ "dashboard/sites/_capability_badge.html",
+ {
+ "capability_probe": capability_probe,
+ "site": site,
+ "lang": lang,
+ "companion_download_url": companion_download_url,
+ },
+ )
diff --git a/core/companion_audit.py b/core/companion_audit.py
new file mode 100644
index 0000000..8022ba7
--- /dev/null
+++ b/core/companion_audit.py
@@ -0,0 +1,325 @@
+"""F.18.7 — Companion audit-hook receiver + per-site secret store.
+
+The ``airano-mcp-bridge`` companion plugin (v2.7.0+) pushes WordPress
+action events (post transitions, user events, plugin activations, etc.)
+to MCPHub as HMAC-SHA256-signed webhooks. This module provides:
+
+1. ``CompanionAuditSecretStore`` — file-backed map of ``site_url -> secret``.
+2. ``verify_companion_signature`` — constant-time HMAC verification.
+3. ``handle_companion_audit_request`` — Starlette handler that validates
+ the signature, appends the event to the audit log, and returns 200.
+
+The store is intentionally independent of the SQLite sites DB so the
+webhook can land before a dashboard UI exists; the UI (future work) can
+replace ``CompanionAuditSecretStore`` with DB-backed storage without
+changing the wire format.
+"""
+
+from __future__ import annotations
+
+import hashlib
+import hmac
+import json
+import logging
+import os
+import threading
+import time
+from datetime import datetime
+from pathlib import Path
+from typing import Any
+
+from starlette.requests import Request
+from starlette.responses import JSONResponse
+
+logger = logging.getLogger("mcphub.companion_audit")
+
+
+def _parse_timestamp(raw: Any) -> float | None:
+ """Coerce the envelope ``timestamp`` field to epoch seconds.
+
+ Accepts either an ISO 8601 string (``2026-04-15T09:00:00Z`` — the
+ companion plugin's current format via PHP ``gmdate``) or a numeric
+ epoch value (for forward compatibility with other senders).
+ Returns None on any parse failure so callers can emit a uniform
+ 401 without leaking which step failed.
+ """
+ if raw is None:
+ return None
+ if isinstance(raw, int | float):
+ return float(raw)
+ if isinstance(raw, str):
+ s = raw.strip()
+ if not s:
+ return None
+ # Python 3.11+ fromisoformat handles "Z" suffix; on older
+ # versions we translate it to +00:00 explicitly.
+ iso = s.replace("Z", "+00:00") if s.endswith("Z") else s
+ try:
+ return datetime.fromisoformat(iso).timestamp()
+ except ValueError:
+ pass
+ # Numeric-string fallback (e.g. "1712345678").
+ try:
+ return float(s)
+ except ValueError:
+ return None
+ return None
+
+
+# Pre-F.20 security sweep: bound incoming payload so a misconfigured
+# (or malicious) companion can't queue up unbounded memory with a
+# single request. Real audit events are well under 4 KB; 64 KB is a
+# generous ceiling that still fits within Starlette's default limit.
+_MAX_BODY_BYTES = int(os.environ.get("COMPANION_AUDIT_MAX_BODY", str(64 * 1024)))
+
+# Replay-protection window (seconds). Events whose ``timestamp`` field
+# falls outside ``now ± _REPLAY_WINDOW_SECONDS`` are rejected with 401.
+# Set to 0 or negative to disable (not recommended). Default 5 minutes
+# balances clock-skew tolerance against replay risk.
+_REPLAY_WINDOW_SECONDS = int(os.environ.get("COMPANION_AUDIT_REPLAY_WINDOW", "300"))
+
+
+def _normalise_url(url: str) -> str:
+ return url.rstrip("/").strip().lower()
+
+
+class CompanionAuditSecretStore:
+ """File-backed ``site_url -> shared_secret`` map.
+
+ The file is JSON; keys are normalised (lowercased, trailing slash
+ stripped). Access is serialised through a lock to keep reads and
+ writes atomic on a single-process server. Multi-process deployments
+ (gunicorn workers) can still race on the write path — that's fine
+ because the dashboard UI will be the only writer and operates in
+ the master process for this MVP.
+ """
+
+ def __init__(self, path: str | Path) -> None:
+ self.path = Path(path)
+ self._lock = threading.Lock()
+ self._cache: dict[str, str] | None = None
+
+ def _load(self) -> dict[str, str]:
+ if self._cache is not None:
+ return self._cache
+ if not self.path.exists():
+ self._cache = {}
+ return self._cache
+ try:
+ data = json.loads(self.path.read_text(encoding="utf-8"))
+ if not isinstance(data, dict):
+ logger.warning(
+ "companion audit secret file %s is not a JSON object; ignoring.",
+ self.path,
+ )
+ self._cache = {}
+ else:
+ self._cache = {_normalise_url(str(k)): str(v) for k, v in data.items() if v}
+ except (OSError, json.JSONDecodeError) as exc:
+ logger.warning("Could not read companion audit secret file %s: %s", self.path, exc)
+ self._cache = {}
+ return self._cache
+
+ def _save(self, data: dict[str, str]) -> None:
+ self.path.parent.mkdir(parents=True, exist_ok=True)
+ tmp = self.path.with_suffix(self.path.suffix + ".tmp")
+ tmp.write_text(json.dumps(data, indent=2, sort_keys=True), encoding="utf-8")
+ tmp.replace(self.path)
+ try:
+ os.chmod(self.path, 0o600)
+ except OSError:
+ pass
+ self._cache = dict(data)
+
+ def get(self, site_url: str) -> str | None:
+ with self._lock:
+ return self._load().get(_normalise_url(site_url))
+
+ def set(self, site_url: str, secret: str) -> None:
+ if not secret or len(secret) < 16:
+ raise ValueError("companion audit secret must be at least 16 characters")
+ with self._lock:
+ data = dict(self._load())
+ data[_normalise_url(site_url)] = secret
+ self._save(data)
+
+ def delete(self, site_url: str) -> bool:
+ with self._lock:
+ data = dict(self._load())
+ key = _normalise_url(site_url)
+ if key not in data:
+ return False
+ del data[key]
+ self._save(data)
+ return True
+
+ def list_sites(self) -> list[dict[str, Any]]:
+ """List sites with secret metadata only — never returns plaintext."""
+ with self._lock:
+ return [
+ {
+ "site_url": site_url,
+ "secret_set": True,
+ "secret_last4": secret[-4:] if len(secret) >= 4 else "",
+ }
+ for site_url, secret in self._load().items()
+ ]
+
+
+_DEFAULT_STORE_PATH = Path(
+ os.environ.get(
+ "COMPANION_AUDIT_SECRETS_PATH",
+ "/tmp/mcphub-data/companion-audit-secrets.json",
+ )
+)
+_default_store: CompanionAuditSecretStore | None = None
+
+
+def get_companion_audit_store(
+ path: str | Path | None = None,
+) -> CompanionAuditSecretStore:
+ global _default_store
+ if path is not None:
+ return CompanionAuditSecretStore(path)
+ if _default_store is None:
+ _default_store = CompanionAuditSecretStore(_DEFAULT_STORE_PATH)
+ return _default_store
+
+
+def verify_companion_signature(
+ body_bytes: bytes, signature_header: str | None, secret: str
+) -> bool:
+ """Constant-time HMAC-SHA256 verification.
+
+ Accepts the PHP side's ``sha256=HEX`` format and a bare-hex variant.
+ Returns False on any shape error so callers can emit 401 uniformly.
+ """
+ if not signature_header or not secret:
+ return False
+ sig = signature_header.strip()
+ if sig.startswith("sha256="):
+ sig = sig[len("sha256=") :]
+ if not sig or not all(c in "0123456789abcdefABCDEF" for c in sig):
+ return False
+ expected = hmac.new(secret.encode("utf-8"), body_bytes, hashlib.sha256).hexdigest()
+ return hmac.compare_digest(expected.lower(), sig.lower())
+
+
+# Accepted event names — mirror the PHP side. Events outside this list
+# are still accepted but tagged so operators can spot rogue sources.
+_KNOWN_EVENTS = frozenset(
+ {
+ "transition_post_status",
+ "deleted_post",
+ "user_register",
+ "profile_update",
+ "deleted_user",
+ "activated_plugin",
+ "deactivated_plugin",
+ "switch_theme",
+ }
+)
+
+
+async def handle_companion_audit_request(request: Request) -> JSONResponse:
+ """Starlette handler for ``POST /api/companion-audit``.
+
+ Validates the HMAC signature using a per-site secret, parses the
+ envelope, applies a replay window on the ``timestamp`` field, and
+ writes an audit-log entry. Returns 200 on success, 400 on shape
+ errors, 401 on signature/replay failure, 413 on oversized body.
+ """
+ # Pre-F.20 security sweep: bound the incoming body before reading the
+ # whole thing. Prefer the framework's Content-Length hint; fall back
+ # to reading up to one byte past the ceiling so we can cleanly 413.
+ content_length_header = request.headers.get("content-length")
+ if content_length_header:
+ try:
+ content_length = int(content_length_header)
+ except ValueError:
+ return JSONResponse({"ok": False, "error": "invalid_length"}, status_code=400)
+ if content_length > _MAX_BODY_BYTES:
+ return JSONResponse(
+ {"ok": False, "error": "body_too_large", "max_bytes": _MAX_BODY_BYTES},
+ status_code=413,
+ )
+
+ # Read raw body — we need the exact bytes for HMAC verification.
+ body_bytes = await request.body()
+ if len(body_bytes) > _MAX_BODY_BYTES:
+ return JSONResponse(
+ {"ok": False, "error": "body_too_large", "max_bytes": _MAX_BODY_BYTES},
+ status_code=413,
+ )
+ if not body_bytes:
+ return JSONResponse({"ok": False, "error": "empty_body"}, status_code=400)
+
+ site_header = request.headers.get("X-Airano-MCP-Site") or ""
+ signature = request.headers.get("X-Airano-MCP-Signature")
+
+ try:
+ envelope = json.loads(body_bytes.decode("utf-8"))
+ except (UnicodeDecodeError, json.JSONDecodeError):
+ return JSONResponse({"ok": False, "error": "invalid_json"}, status_code=400)
+
+ if not isinstance(envelope, dict):
+ return JSONResponse({"ok": False, "error": "invalid_envelope"}, status_code=400)
+
+ site_url = str(envelope.get("site_url") or site_header or "")
+ if not site_url:
+ return JSONResponse({"ok": False, "error": "missing_site"}, status_code=400)
+
+ store = get_companion_audit_store()
+ secret = store.get(site_url)
+ if secret is None:
+ # Don't leak whether the site exists; same response as a bad sig.
+ return JSONResponse({"ok": False, "error": "unauthorized"}, status_code=401)
+
+ if not verify_companion_signature(body_bytes, signature, secret):
+ return JSONResponse({"ok": False, "error": "unauthorized"}, status_code=401)
+
+ # Pre-F.20 security sweep: enforce a replay window on the signed
+ # timestamp. Captured webhooks replayed outside the window are
+ # rejected with 401 (same opaque response as a bad signature to
+ # avoid giving attackers a distinguishing oracle).
+ if _REPLAY_WINDOW_SECONDS > 0:
+ ts = _parse_timestamp(envelope.get("timestamp"))
+ if ts is None:
+ return JSONResponse({"ok": False, "error": "unauthorized"}, status_code=401)
+ now = time.time()
+ if abs(now - ts) > _REPLAY_WINDOW_SECONDS:
+ logger.warning(
+ "companion audit replay rejected site=%s skew=%.1fs",
+ site_url,
+ now - ts,
+ )
+ return JSONResponse({"ok": False, "error": "unauthorized"}, status_code=401)
+
+ event_name = str(envelope.get("event") or "unknown")
+ details = {
+ "site_url": site_url,
+ "event": event_name,
+ "known_event": event_name in _KNOWN_EVENTS,
+ "timestamp": envelope.get("timestamp"),
+ "wp_user_id": envelope.get("user_id"),
+ "plugin_version": envelope.get("plugin_version"),
+ "data": envelope.get("data"),
+ }
+
+ try:
+ # Deferred import — avoid pulling audit_log into the store for tests.
+ from core.audit_log import get_audit_logger
+
+ audit_logger = get_audit_logger()
+ audit_logger.log_system_event(
+ event=f"companion_audit:{event_name}",
+ details=details,
+ )
+ except Exception as exc: # noqa: BLE001
+ logger.error("failed to append companion audit event: %s", exc)
+ return JSONResponse(
+ {"ok": False, "error": "audit_write_failed"},
+ status_code=500,
+ )
+
+ return JSONResponse({"ok": True, "event": event_name}, status_code=200)
diff --git a/core/dashboard/routes.py b/core/dashboard/routes.py
index f4f2104..ac25243 100644
--- a/core/dashboard/routes.py
+++ b/core/dashboard/routes.py
@@ -7,6 +7,7 @@ Phase K.1: Core Infrastructure
import logging
import os
from datetime import UTC, datetime
+from typing import Any
from starlette.requests import Request
from starlette.responses import HTMLResponse, JSONResponse, RedirectResponse, Response
@@ -2927,7 +2928,14 @@ async def dashboard_sites_view(request: Request) -> Response:
import json
from core.config_snippets import get_supported_clients
- from core.site_api import get_user_credential_fields, get_user_plugin_names, get_user_site
+ from core.site_api import (
+ SITE_PROVIDERS,
+ get_user_credential_fields,
+ get_user_plugin_names,
+ get_user_site,
+ list_site_providers_set,
+ site_supports_provider_keys,
+ )
from core.tool_access import get_scope_presets_for_plugin
site = await get_user_site(site_id, user_session["user_id"])
@@ -2946,6 +2954,97 @@ async def dashboard_sites_view(request: Request) -> Response:
plugin_names = get_user_plugin_names()
scope_presets = get_scope_presets_for_plugin(site["plugin_type"])
+ # F.5a.9.x: per-site AI provider keys — only surface the section on
+ # WordPress/WooCommerce sites (the only consumers of
+ # wordpress_generate_and_upload_image).
+ provider_keys_supported = site_supports_provider_keys(site["plugin_type"])
+ provider_key_rows: list[dict[str, Any]] = []
+ if provider_keys_supported:
+ configured = await list_site_providers_set(site_id)
+ # F.X.fix-pass3: pull each row's default_model so the UI can
+ # render a "Set default" pill that's pre-highlighted on the
+ # current selection. One query, all rows.
+ default_models: dict[str, str | None] = {}
+ try:
+ from core.database import get_database
+
+ db = get_database()
+ rows = await db.list_site_provider_keys(site_id)
+ for r in rows:
+ default_models[r["provider"]] = r.get("default_model")
+ except Exception as exc: # noqa: BLE001
+ logger.debug("default_model lookup skipped site=%s: %s", site_id, exc)
+ _provider_labels = {
+ "openai": "OpenAI",
+ "stability": "Stability AI",
+ "replicate": "Replicate",
+ "openrouter": "OpenRouter (Gemini / multi-model)",
+ }
+ for provider in SITE_PROVIDERS:
+ provider_key_rows.append(
+ {
+ "provider": provider,
+ "label": _provider_labels.get(provider, provider.title()),
+ "status": "set" if provider in configured else "unset",
+ "default_model": default_models.get(provider),
+ }
+ )
+
+ # F.20 prep: companion-plugin download hint, shown on WP/WC site
+ # pages only. See dashboard_service_page for the matching hint on
+ # the services catalogue.
+ companion_download_url = None
+ if site["plugin_type"] in {"wordpress", "woocommerce"}:
+ companion_download_url = (
+ "https://github.com/airano-ir/mcphub/raw/main/" "wordpress-plugin/airano-mcp-bridge.zip"
+ )
+
+ # F.7e: run the capability probe server-side so the manage page can
+ # render the badge on first paint without a second round-trip. Any
+ # failure falls through to "probe unavailable" so the page always
+ # loads. The frontend Re-check button hits the same endpoint with
+ # force=1 to bypass the 10-min cache.
+ capability_probe = None
+ try:
+ from core.capability_probe import evaluate_tier_fit, probe_site_capabilities
+
+ probe_payload = await probe_site_capabilities(
+ site_id=site_id, user_id=user_session["user_id"]
+ )
+ fit = evaluate_tier_fit(
+ plugin_type=site["plugin_type"],
+ tier=site.get("tool_scope"),
+ probe_payload=probe_payload,
+ )
+ capability_probe = {**probe_payload, "fit": fit}
+ except Exception as exc: # noqa: BLE001
+ logger.warning("capability probe render failed for %s: %s", site_id, exc)
+ capability_probe = {
+ "probe_available": False,
+ "granted": [],
+ "source": "unavailable",
+ "reason": f"render_error: {exc}",
+ "fit": {"status": "probe_unavailable", "required": [], "missing": []},
+ }
+
+ # F.X.fix-pass5 — surface which credential fields are *currently
+ # stored* (not the values themselves) so the form can show a
+ # "✓ Stored" badge and require an explicit "Clear" action to
+ # remove an existing value, instead of silently wiping it on a
+ # blank-input save.
+ cred_states: dict[str, bool] = {}
+ try:
+ from core.encryption import get_credential_encryption
+
+ encryptor = get_credential_encryption()
+ decrypted = encryptor.decrypt_credentials(site["credentials"], site_id)
+ for field in plugin_fields.get(site["plugin_type"], []):
+ value = decrypted.get(field["name"])
+ cred_states[field["name"]] = bool(value and str(value).strip())
+ except Exception as exc: # noqa: BLE001
+ logger.debug("cred_states lookup failed for site %s: %s", site_id, exc)
+ cred_states = {}
+
return templates.TemplateResponse(
request,
"dashboard/sites/manage.html",
@@ -2962,6 +3061,12 @@ async def dashboard_sites_view(request: Request) -> Response:
"mcp_url": mcp_url,
"clients": get_supported_clients(),
"current_page": "my_sites",
+ "provider_keys_supported": provider_keys_supported,
+ "provider_key_rows": provider_key_rows,
+ "companion_download_url": companion_download_url,
+ "capability_probe": capability_probe,
+ "cred_states": cred_states,
+ "cred_states_json": json.dumps(cred_states),
},
)
@@ -3337,15 +3442,24 @@ async def api_list_site_tools(request: Request) -> Response:
return err
assert site is not None
+ from core.site_api import list_site_providers_set
from core.tool_access import get_tool_access_manager
access = get_tool_access_manager()
tools = await access.list_tools_for_site(site["id"], site["plugin_type"])
+ # F.X.fix #8: expose the site's configured AI-provider set so the
+ # Tool Access template can render "Configure key for X" CTAs
+ # without issuing a second /api/sites/{id}/provider-keys call.
+ try:
+ configured_providers = sorted(await list_site_providers_set(site["id"]))
+ except Exception: # noqa: BLE001
+ configured_providers = []
return JSONResponse(
{
"site_id": site["id"],
"plugin_type": site["plugin_type"],
"tool_scope": site.get("tool_scope", "admin"),
+ "configured_providers": configured_providers,
"tools": tools,
}
)
@@ -3729,6 +3843,15 @@ async def dashboard_service_page(request: Request) -> Response:
lang = detect_language(accept_language, query_lang)
t = get_translations(lang)
+ # F.20 prep: dashboard UX hint — surface the companion-plugin download
+ # link on the WP / WC service pages. The link switches from the GitHub
+ # raw-download URL to wp.org once the plugin ships (see F.20 scope).
+ companion_download_url = None
+ if plugin_type in {"wordpress", "woocommerce"}:
+ companion_download_url = (
+ "https://github.com/airano-ir/mcphub/raw/main/" "wordpress-plugin/airano-mcp-bridge.zip"
+ )
+
return templates.TemplateResponse(
request,
"dashboard/service.html",
@@ -3740,10 +3863,206 @@ async def dashboard_service_page(request: Request) -> Response:
"display_info": display_info,
"current_page": "services",
"service": data,
+ "companion_download_url": companion_download_url,
},
)
+# ======================================================================
+# F.18.8 — Provider Keys Dashboard UI (REMOVED in F.5a.9.x)
+# ======================================================================
+#
+# Per-user AI provider keys and the /dashboard/provider-keys page have
+# been replaced by per-site keys defined in each WordPress / WooCommerce
+# site's Connection Settings. The site-scoped API is the F.5a.9.x block
+# below. The user_provider_keys table is dropped in schema migration v12.
+#
+# Deleted helpers / handlers:
+# _PROVIDER_LABELS, _build_provider_rows,
+# dashboard_provider_keys_page,
+# api_user_provider_keys_set, api_user_provider_keys_delete.
+
+
+# ======================================================================
+# F.5a.9.x — Per-site AI provider key endpoints
+# ======================================================================
+
+
+async def api_site_provider_keys_list(request: Request) -> Response:
+ """GET /api/sites/{id}/provider-keys — list providers with a key stored.
+
+ Returns ``{"providers": ["openai", ...]}``. Does not leak ciphertext
+ or plaintext.
+ """
+ user_session, redirect = _require_user_session(request)
+ if redirect or user_session is None:
+ return JSONResponse({"ok": False, "error": "unauthorized"}, status_code=401)
+
+ site_id = (request.path_params.get("id") or "").strip()
+ from core.site_api import get_user_site, list_site_providers_set, site_supports_provider_keys
+
+ site = await get_user_site(site_id, user_session["user_id"])
+ if site is None:
+ return JSONResponse({"ok": False, "error": "site_not_found"}, status_code=404)
+ if not site_supports_provider_keys(site["plugin_type"]):
+ return JSONResponse({"ok": False, "error": "plugin_unsupported"}, status_code=400)
+
+ providers = sorted(await list_site_providers_set(site_id))
+ return JSONResponse({"ok": True, "providers": providers})
+
+
+async def api_site_provider_keys_set(request: Request) -> Response:
+ """POST /api/sites/{id}/provider-keys/{provider} — upsert a per-site key.
+
+ Body: ``{"api_key": "..."}``. Enforces site ownership, plugin-type gate,
+ and provider allow-list (site_api.set_site_provider_key).
+ """
+ user_session, redirect = _require_user_session(request)
+ if redirect or user_session is None:
+ return JSONResponse({"ok": False, "error": "unauthorized"}, status_code=401)
+
+ site_id = (request.path_params.get("id") or "").strip()
+ provider = (request.path_params.get("provider") or "").strip().lower()
+
+ try:
+ body = await request.json()
+ except Exception:
+ return JSONResponse({"ok": False, "error": "invalid_json"}, status_code=400)
+ if not isinstance(body, dict):
+ return JSONResponse({"ok": False, "error": "invalid_body"}, status_code=400)
+
+ api_key = str(body.get("api_key") or "").strip()
+ if len(api_key) < 8:
+ return JSONResponse(
+ {
+ "ok": False,
+ "error": "key_too_short",
+ "message": "api_key must be at least 8 characters",
+ },
+ status_code=400,
+ )
+
+ from core.site_api import set_site_provider_key
+
+ try:
+ await set_site_provider_key(site_id, user_session["user_id"], provider, api_key)
+ except ValueError as exc:
+ return JSONResponse(
+ {"ok": False, "error": "invalid_request", "message": str(exc)},
+ status_code=400,
+ )
+ except Exception as exc:
+ logger.error(
+ "site_provider_keys set failed user=%s site=%s provider=%s: %s",
+ user_session["user_id"],
+ site_id,
+ provider,
+ exc,
+ )
+ return JSONResponse(
+ {"ok": False, "error": "storage_failed", "message": str(exc)},
+ status_code=500,
+ )
+
+ return JSONResponse(
+ {
+ "ok": True,
+ "site_id": site_id,
+ "provider": provider,
+ "secret_last4": api_key[-4:],
+ }
+ )
+
+
+async def api_site_provider_keys_delete(request: Request) -> Response:
+ """DELETE /api/sites/{id}/provider-keys/{provider} — remove a per-site key."""
+ user_session, redirect = _require_user_session(request)
+ if redirect or user_session is None:
+ return JSONResponse({"ok": False, "error": "unauthorized"}, status_code=401)
+
+ site_id = (request.path_params.get("id") or "").strip()
+ provider = (request.path_params.get("provider") or "").strip().lower()
+
+ from core.site_api import delete_site_provider_key
+
+ try:
+ deleted = await delete_site_provider_key(site_id, user_session["user_id"], provider)
+ except Exception as exc:
+ logger.error(
+ "site_provider_keys delete failed user=%s site=%s provider=%s: %s",
+ user_session["user_id"],
+ site_id,
+ provider,
+ exc,
+ )
+ return JSONResponse(
+ {"ok": False, "error": "storage_failed", "message": str(exc)},
+ status_code=500,
+ )
+ return JSONResponse({"ok": True, "site_id": site_id, "provider": provider, "deleted": deleted})
+
+
+async def api_site_provider_default_model(request: Request) -> Response:
+ """PATCH /api/sites/{id}/provider-keys/{provider}/default-model.
+
+ Body: ``{"model": ""}`` to set, ``{"model": null}`` (or empty
+ string) to clear. F.X.fix-pass3 — lets the user pin a discovered
+ OpenRouter image-model id as the implicit default for a site, so
+ MCP callers don't have to pass ``model=`` every time.
+ """
+ user_session, redirect = _require_user_session(request)
+ if redirect or user_session is None:
+ return JSONResponse({"ok": False, "error": "unauthorized"}, status_code=401)
+
+ site_id = (request.path_params.get("id") or "").strip()
+ provider = (request.path_params.get("provider") or "").strip().lower()
+
+ try:
+ body = await request.json()
+ except Exception:
+ return JSONResponse({"ok": False, "error": "invalid_json"}, status_code=400)
+ if not isinstance(body, dict):
+ return JSONResponse({"ok": False, "error": "invalid_body"}, status_code=400)
+
+ raw = body.get("model")
+ model: str | None
+ if raw is None:
+ model = None
+ elif isinstance(raw, str):
+ stripped = raw.strip()
+ model = stripped or None
+ else:
+ return JSONResponse({"ok": False, "error": "invalid_model"}, status_code=400)
+
+ from core.site_api import get_user_site
+
+ site = await get_user_site(site_id, user_session["user_id"])
+ if site is None:
+ return JSONResponse({"ok": False, "error": "site_not_found"}, status_code=404)
+
+ from core.database import get_database
+
+ db = get_database()
+ updated = await db.set_site_provider_default_model(site_id, provider, model)
+ if not updated:
+ return JSONResponse(
+ {
+ "ok": False,
+ "error": "provider_key_not_found",
+ "message": "Save the provider key first, then set the default model.",
+ },
+ status_code=404,
+ )
+ return JSONResponse(
+ {
+ "ok": True,
+ "site_id": site_id,
+ "provider": provider,
+ "default_model": model,
+ }
+ )
+
+
def register_dashboard_routes(mcp):
"""
Register dashboard routes with the MCP server.
@@ -3848,6 +4167,15 @@ def register_dashboard_routes(mcp):
mcp.custom_route("/api/sites/{id}", methods=["DELETE"])(api_delete_site)
mcp.custom_route("/api/sites/{id}", methods=["PATCH"])(api_update_site)
+ # Per-site AI provider keys (F.5a.9.x)
+ mcp.custom_route("/api/sites/{id}/provider-keys", methods=["GET"])(api_site_provider_keys_list)
+ mcp.custom_route("/api/sites/{id}/provider-keys/{provider}", methods=["POST"])(
+ api_site_provider_keys_set
+ )
+ mcp.custom_route("/api/sites/{id}/provider-keys/{provider}", methods=["DELETE"])(
+ api_site_provider_keys_delete
+ )
+
# User API Key routes (E.3)
mcp.custom_route("/api/keys", methods=["GET"])(api_list_keys)
mcp.custom_route("/api/keys", methods=["POST"])(api_create_key)
@@ -3867,4 +4195,11 @@ def register_dashboard_routes(mcp):
dashboard_user_oauth_clients_delete
)
+ # F.18.8 /dashboard/provider-keys routes removed in F.5a.9.x —
+ # per-site AI provider keys replace the per-user page. See the
+ # per-site endpoints registered above:
+ # GET /api/sites/{id}/provider-keys
+ # POST /api/sites/{id}/provider-keys/{provider}
+ # DELETE /api/sites/{id}/provider-keys/{provider}
+
logger.info("Dashboard routes registered successfully")
diff --git a/core/database.py b/core/database.py
index 1ce3b89..1fa6a78 100644
--- a/core/database.py
+++ b/core/database.py
@@ -37,7 +37,7 @@ logger = logging.getLogger(__name__)
_DEFAULT_DATA_DIR = "/app/data" if Path("/app").exists() else "./data"
# Schema version — increment when adding migrations
-SCHEMA_VERSION = 8
+SCHEMA_VERSION = 13
# Initial schema DDL
_SCHEMA_SQL = """\
@@ -114,6 +114,48 @@ CREATE TABLE IF NOT EXISTS site_tool_toggles (
UNIQUE(site_id, tool_name)
);
CREATE INDEX IF NOT EXISTS idx_site_tool_toggles_site ON site_tool_toggles(site_id);
+
+-- F.5a.4 user_provider_keys table removed in F.5a.9.x / schema v12 —
+-- replaced by site_provider_keys (defined below) which stores AI provider
+-- credentials per-site instead of per-user.
+
+-- F.5a.5: chunked-upload sessions (SQLite metadata + disk spill)
+CREATE TABLE IF NOT EXISTS upload_sessions (
+ id TEXT PRIMARY KEY,
+ user_id TEXT NOT NULL,
+ filename TEXT NOT NULL,
+ total_bytes INTEGER NOT NULL,
+ mime TEXT,
+ sha256 TEXT,
+ received_bytes INTEGER NOT NULL DEFAULT 0,
+ next_chunk INTEGER NOT NULL DEFAULT 0,
+ spill_path TEXT NOT NULL,
+ status TEXT NOT NULL DEFAULT 'open',
+ created_at TEXT NOT NULL,
+ expires_at TEXT NOT NULL
+);
+CREATE INDEX IF NOT EXISTS idx_upload_sessions_user_status
+ ON upload_sessions(user_id, status);
+CREATE INDEX IF NOT EXISTS idx_upload_sessions_expires
+ ON upload_sessions(expires_at);
+
+-- F.5a.9.x: per-site AI provider keys (AES-GCM encrypted, reversible).
+-- Replaces the per-user user_provider_keys table with a per-site model so
+-- each WordPress/WooCommerce site carries its own OpenAI/Stability/Replicate
+-- credential in its Connection Settings. Encryption scope:
+-- site_provider:{site_id}:{provider}
+CREATE TABLE IF NOT EXISTS site_provider_keys (
+ id TEXT PRIMARY KEY,
+ site_id TEXT NOT NULL REFERENCES sites(id) ON DELETE CASCADE,
+ provider TEXT NOT NULL,
+ key_ciphertext BLOB NOT NULL,
+ created_at TEXT NOT NULL,
+ last_used TEXT,
+ default_model TEXT,
+ UNIQUE(site_id, provider)
+);
+CREATE INDEX IF NOT EXISTS idx_site_provider_keys_site
+ ON site_provider_keys(site_id);
"""
# Migration registry: version -> SQL string
@@ -170,6 +212,75 @@ _MIGRATIONS: dict[int, str] = {
# F.7c: per-site API keys — allow keys scoped to a single site
"ALTER TABLE user_api_keys ADD COLUMN site_id TEXT;\n"
),
+ 9: (
+ # F.5a.4: per-user AI provider keys (AES-GCM encrypted, reversible —
+ # distinct from the bcrypt-hashed user_api_keys which authenticate MCP
+ # clients and cannot be used to call outbound provider APIs).
+ "CREATE TABLE IF NOT EXISTS user_provider_keys (\n"
+ " id TEXT PRIMARY KEY,\n"
+ " user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,\n"
+ " provider TEXT NOT NULL,\n"
+ " key_ciphertext BLOB NOT NULL,\n"
+ " created_at TEXT NOT NULL,\n"
+ " last_used TEXT,\n"
+ " UNIQUE(user_id, provider)\n"
+ ");\n"
+ "CREATE INDEX IF NOT EXISTS idx_user_provider_keys_user "
+ "ON user_provider_keys(user_id);\n"
+ ),
+ 10: (
+ # F.5a.5: chunked upload sessions (SQLite metadata + disk spill)
+ "CREATE TABLE IF NOT EXISTS upload_sessions (\n"
+ " id TEXT PRIMARY KEY,\n"
+ " user_id TEXT NOT NULL,\n"
+ " filename TEXT NOT NULL,\n"
+ " total_bytes INTEGER NOT NULL,\n"
+ " mime TEXT,\n"
+ " sha256 TEXT,\n"
+ " received_bytes INTEGER NOT NULL DEFAULT 0,\n"
+ " next_chunk INTEGER NOT NULL DEFAULT 0,\n"
+ " spill_path TEXT NOT NULL,\n"
+ " status TEXT NOT NULL DEFAULT 'open',\n"
+ " created_at TEXT NOT NULL,\n"
+ " expires_at TEXT NOT NULL\n"
+ ");\n"
+ "CREATE INDEX IF NOT EXISTS idx_upload_sessions_user_status "
+ "ON upload_sessions(user_id, status);\n"
+ "CREATE INDEX IF NOT EXISTS idx_upload_sessions_expires "
+ "ON upload_sessions(expires_at);\n"
+ ),
+ 11: (
+ # F.5a.9.x step 1: add per-site provider keys table. The legacy
+ # per-user user_provider_keys table is dropped in migration 12
+ # (after the code paths that read it are removed).
+ "CREATE TABLE IF NOT EXISTS site_provider_keys (\n"
+ " id TEXT PRIMARY KEY,\n"
+ " site_id TEXT NOT NULL REFERENCES sites(id) ON DELETE CASCADE,\n"
+ " provider TEXT NOT NULL,\n"
+ " key_ciphertext BLOB NOT NULL,\n"
+ " created_at TEXT NOT NULL,\n"
+ " last_used TEXT,\n"
+ " default_model TEXT,\n"
+ " UNIQUE(site_id, provider)\n"
+ ");\n"
+ "CREATE INDEX IF NOT EXISTS idx_site_provider_keys_site "
+ "ON site_provider_keys(site_id);\n"
+ ),
+ 12: (
+ # F.5a.9.x step 2: drop the legacy per-user provider-keys store
+ # now that the resolver, dashboard page, and HTTP endpoints have
+ # been removed. Confirmed with operator that no users had keys
+ # stored on the live instance, so no data migration is needed.
+ "DROP INDEX IF EXISTS idx_user_provider_keys_user;\n"
+ "DROP TABLE IF EXISTS user_provider_keys;\n"
+ ),
+ 13: (
+ # F.X.fix-pass3: per-site default image model. Lets the user
+ # pick a discovered OpenRouter model (e.g. google/gemini-2.5-
+ # flash-image) as the implicit default for that site, so MCP
+ # callers don't have to pass `model=...` every time.
+ "ALTER TABLE site_provider_keys ADD COLUMN default_model TEXT;\n"
+ ),
}
@@ -880,6 +991,104 @@ class Database:
return "admin"
return row["tool_scope"] or "admin"
+ # ------------------------------------------------------------------
+ # Site provider keys (F.5a.9.x) — per-site AI provider credentials
+ # (replaces the F.5a.4 per-user ``*_provider_key`` helpers dropped in v12)
+ # ------------------------------------------------------------------
+
+ async def upsert_site_provider_key(
+ self,
+ site_id: str,
+ provider: str,
+ key_ciphertext: bytes,
+ ) -> dict[str, Any]:
+ """Insert or replace a site's API key for a given AI provider.
+
+ Args:
+ site_id: Site UUID.
+ provider: Provider identifier (``openai``, ``stability``, ``replicate``).
+ key_ciphertext: AES-256-GCM encrypted key bytes.
+
+ Returns:
+ The stored row (without plaintext).
+ """
+ key_id = str(uuid.uuid4())
+ now = _utc_now()
+ await self.execute(
+ "INSERT INTO site_provider_keys (id, site_id, provider, key_ciphertext, created_at) "
+ "VALUES (?, ?, ?, ?, ?) "
+ "ON CONFLICT(site_id, provider) DO UPDATE SET "
+ "key_ciphertext = excluded.key_ciphertext, created_at = excluded.created_at",
+ (key_id, site_id, provider, key_ciphertext, now),
+ )
+ row = await self.fetchone(
+ "SELECT id, site_id, provider, created_at, last_used FROM site_provider_keys "
+ "WHERE site_id = ? AND provider = ?",
+ (site_id, provider),
+ )
+ if row is None:
+ raise RuntimeError(f"Failed to read back site provider key for {site_id}/{provider}")
+ return row
+
+ async def get_site_provider_key(self, site_id: str, provider: str) -> dict[str, Any] | None:
+ """Return the encrypted provider key row for a site (includes ciphertext)."""
+ return await self.fetchone(
+ "SELECT * FROM site_provider_keys WHERE site_id = ? AND provider = ?",
+ (site_id, provider),
+ )
+
+ async def list_site_provider_keys(self, site_id: str) -> list[dict[str, Any]]:
+ """List providers a site has keys for (excluding ciphertext)."""
+ return await self.fetchall(
+ "SELECT id, site_id, provider, created_at, last_used, default_model "
+ "FROM site_provider_keys WHERE site_id = ? ORDER BY provider",
+ (site_id,),
+ )
+
+ async def delete_site_provider_key(self, site_id: str, provider: str) -> bool:
+ """Remove a site's provider key. Returns True if a row was deleted."""
+ cursor = await self.execute(
+ "DELETE FROM site_provider_keys WHERE site_id = ? AND provider = ?",
+ (site_id, provider),
+ )
+ return cursor.rowcount > 0
+
+ async def touch_site_provider_key(self, site_id: str, provider: str) -> None:
+ """Update last_used timestamp after a successful provider call."""
+ await self.execute(
+ "UPDATE site_provider_keys SET last_used = ? WHERE site_id = ? AND provider = ?",
+ (_utc_now(), site_id, provider),
+ )
+
+ async def set_site_provider_default_model(
+ self, site_id: str, provider: str, model: str | None
+ ) -> bool:
+ """F.X.fix-pass3 — record the per-site default model for a provider.
+
+ ``model=None`` clears the default. Returns ``True`` when a row
+ was actually updated (i.e. the site has a key for the provider);
+ callers can surface a 404 to the user when the row doesn't exist
+ rather than silently writing nothing.
+ """
+ cursor = await self.execute(
+ "UPDATE site_provider_keys SET default_model = ? " "WHERE site_id = ? AND provider = ?",
+ (model, site_id, provider),
+ )
+ return cursor.rowcount > 0
+
+ async def get_site_provider_default_model(self, site_id: str, provider: str) -> str | None:
+ """Return the default model id for a site's provider, or None."""
+ row = await self.fetchone(
+ "SELECT default_model FROM site_provider_keys " "WHERE site_id = ? AND provider = ?",
+ (site_id, provider),
+ )
+ if row is None:
+ return None
+ value = row.get("default_model")
+ if isinstance(value, str) and value:
+ return value
+ return None
+
async def set_site_tool_scope(self, site_id: str, scope: str) -> None:
"""Update the ``tool_scope`` preset for a site.
diff --git a/core/encryption.py b/core/encryption.py
index 16999c7..835ec15 100644
--- a/core/encryption.py
+++ b/core/encryption.py
@@ -178,6 +178,35 @@ class CredentialEncryption:
json_str = self.decrypt(cipherdata, site_id)
return json.loads(json_str)
+ def encrypt_for_scope(self, plaintext: str, scope: str) -> bytes:
+ """Encrypt a plaintext string using an arbitrary HKDF scope string.
+
+ Used when the encrypted value is not a per-site credentials blob but
+ still needs per-key isolation (e.g. per-site AI provider API keys
+ where scope is ``site_provider:{site_id}:{provider}``).
+
+ Args:
+ plaintext: The string to encrypt.
+ scope: Scope string used as HKDF info for key derivation. Any
+ caller reading back the ciphertext must pass the same scope.
+
+ Returns:
+ Encrypted bytes (same wire format as :meth:`encrypt`).
+ """
+ return self.encrypt(plaintext, scope)
+
+ def decrypt_for_scope(self, cipherdata: bytes, scope: str) -> str:
+ """Decrypt cipherdata produced by :meth:`encrypt_for_scope`.
+
+ Args:
+ cipherdata: Encrypted bytes.
+ scope: Scope string — must exactly match what was used to encrypt.
+
+ Returns:
+ Original plaintext string.
+ """
+ return self.decrypt(cipherdata, scope)
+
# Global credential encryption instance
_credential_encryption: CredentialEncryption | None = None
diff --git a/core/media_audit.py b/core/media_audit.py
new file mode 100644
index 0000000..5a28e9e
--- /dev/null
+++ b/core/media_audit.py
@@ -0,0 +1,67 @@
+"""F.5a.6.4 — Audit-log emission for media uploads.
+
+One ``media.upload`` entry is written per **successful** upload regardless
+of source (base64 / url / chunked / ai:). Failures are intentionally
+NOT logged here — they surface to the caller as typed ``UploadError`` JSON
+and to the dashboard via the existing tool-call audit emitted by the
+ToolRouter wrapper. Logging failures twice would double-count error rates.
+
+GDPR: never log raw bytes, base64, prompts, or URLs that may carry tokens.
+"""
+
+from __future__ import annotations
+
+import logging
+from typing import Any
+
+logger = logging.getLogger(__name__)
+
+
+def log_media_upload(
+ *,
+ site: str | None,
+ user_id: str | None,
+ mime: str | None,
+ size_bytes: int,
+ source: str,
+ media_id: int | None,
+ cost_usd: float | None = None,
+) -> None:
+ """Emit a single ``media.upload`` audit entry. Best-effort — never raises.
+
+ Args:
+ site: Site URL or alias (whichever the handler has on hand).
+ user_id: Calling user id (None for admin / env-fallback).
+ mime: Sniffed MIME type after security validation.
+ size_bytes: Final uploaded byte count (post-optimization).
+ source: One of ``"base64"``, ``"url"``, ``"chunked"``, ``"ai:"``.
+ media_id: WordPress media library id of the resulting attachment.
+ cost_usd: Provider cost in USD for AI-generated uploads only.
+ """
+ try:
+ from core.audit_log import get_audit_logger
+
+ audit = get_audit_logger()
+ except Exception: # noqa: BLE001
+ return
+
+ params: dict[str, Any] = {
+ "site": site,
+ "mime": mime,
+ "size_bytes": int(size_bytes),
+ "source": source,
+ "media_id": media_id,
+ }
+ if cost_usd is not None:
+ params["cost_usd"] = round(float(cost_usd), 6)
+
+ try:
+ audit.log_tool_call(
+ tool_name="media.upload",
+ site=site,
+ params=params,
+ result_summary=(f"{source} {size_bytes}B {mime or '?'} -> media_id={media_id}"),
+ user_id=user_id,
+ )
+ except Exception: # noqa: BLE001
+ logger.debug("media.upload audit emit failed", exc_info=True)
diff --git a/core/media_error_codes.py b/core/media_error_codes.py
new file mode 100644
index 0000000..e06b21f
--- /dev/null
+++ b/core/media_error_codes.py
@@ -0,0 +1,91 @@
+"""F.5a.6.2 — Stable error-code taxonomy for media upload tools.
+
+Every :class:`plugins.wordpress.handlers._media_security.UploadError` and
+:class:`core.upload_sessions.UploadSessionError` raised inside the media
+stack MUST use one of the codes listed here (or match the dynamic
+``WP_`` pattern for upstream WordPress REST responses).
+
+The accompanying test ``tests/plugins/wordpress/test_media_error_taxonomy.py``
+scans the source files for raise-site literals and asserts each one is in
+this set — so the contract stays stable as the code evolves.
+
+The full reference is in ``docs/media-error-codes.md``.
+"""
+
+from __future__ import annotations
+
+import re
+
+#: All stable upload/media error codes. Keep alphabetical within groups.
+MEDIA_ERROR_CODES: frozenset[str] = frozenset(
+ {
+ # --- Input / validation ------------------------------------------
+ "BAD_BASE64",
+ "BAD_MODE",
+ "BAD_ROLE",
+ "BAD_SIZE",
+ "BAD_SOURCE",
+ "EMPTY_FILE",
+ "MEDIA_NOT_FOUND",
+ "MIME_REJECTED",
+ "MISSING_FIELD",
+ "SSRF",
+ "TOO_LARGE",
+ "URL_FETCH_FAILED",
+ # --- WordPress REST upstream -------------------------------------
+ "WP_413",
+ "WP_AUTH",
+ "WP_BAD_RESPONSE",
+ # F.X.fix-pass4 — WC sites with consumer_key/consumer_secret
+ # auth need a separate WP Application Password to upload to
+ # /wp/v2/media. Surfaced by media_attach.py when the user
+ # hasn't filled wp_username/wp_app_password in Connection
+ # Settings.
+ "WP_CREDENTIALS_MISSING",
+ # WP_ (e.g. WP_500) is also allowed — see MEDIA_ERROR_CODE_RE
+ # --- Companion plugin upload-chunk route (F.5a.7) ----------------
+ "COMPANION_BAD_RESPONSE",
+ # COMPANION_ (e.g. COMPANION_500) is also allowed — same
+ # shape as WP_, see _COMPANION_STATUS_RE.
+ # --- Chunked upload session --------------------------------------
+ "BAD_STATE",
+ "CHECKSUM_MISMATCH",
+ "CHUNK_CHECKSUM",
+ "CHUNK_ORDER",
+ "CHUNK_OVERFLOW",
+ "EXPIRED",
+ "INCOMPLETE",
+ "NO_SESSION",
+ "QUOTA_EXCEEDED",
+ "SESSION_TOO_LARGE",
+ # --- AI generation providers -------------------------------------
+ "GENERATION_FAILED",
+ "NO_PROVIDER_KEY",
+ "PROVIDER_AUTH",
+ "PROVIDER_BAD_REQUEST",
+ "PROVIDER_BAD_RESPONSE",
+ "PROVIDER_QUOTA",
+ "PROVIDER_TIMEOUT",
+ "PROVIDER_UNAVAILABLE",
+ "PROVIDER_UNKNOWN",
+ # --- Rate / policy -----------------------------------------------
+ "TOOL_RATE_LIMITED",
+ # --- Catchall ----------------------------------------------------
+ "INTERNAL",
+ }
+)
+
+#: Dynamic WP REST status codes (e.g. WP_400, WP_500).
+_WP_STATUS_RE = re.compile(r"^WP_\d{3}$")
+
+#: Dynamic companion upload-chunk status codes (e.g. COMPANION_400, COMPANION_500).
+_COMPANION_STATUS_RE = re.compile(r"^COMPANION_\d{3}$")
+
+
+def is_valid_code(code: str) -> bool:
+ """Return True if ``code`` is a documented media error code."""
+ return (
+ code in MEDIA_ERROR_CODES
+ or bool(_WP_STATUS_RE.match(code))
+ or bool(_COMPANION_STATUS_RE.match(code))
+ )
diff --git a/core/site_api.py b/core/site_api.py
index f1eb2a4..1b5c7e1 100644
--- a/core/site_api.py
+++ b/core/site_api.py
@@ -35,14 +35,18 @@ PLUGIN_CREDENTIAL_FIELDS: dict[str, list[dict[str, Any]]] = {
"label": "Username",
"type": "text",
"required": True,
- "hint": "Your WordPress admin username",
+ "hint": "Your WordPress admin username (used as the HTTP Basic username for every API call).",
},
{
"name": "app_password",
"label": "Application Password",
"type": "password",
"required": True,
- "hint": "WordPress Admin → Users → Profile → Application Passwords",
+ "hint": (
+ "WordPress Admin → Users → Profile → Application Passwords. "
+ "This IS the API credential — no separate API key is needed. "
+ "Paste the value WP shows once after creation (spaces can stay or be removed)."
+ ),
},
],
"woocommerce": [
@@ -51,14 +55,53 @@ PLUGIN_CREDENTIAL_FIELDS: dict[str, list[dict[str, Any]]] = {
"label": "Consumer Key",
"type": "text",
"required": True,
- "hint": "WooCommerce → Settings → Advanced → REST API",
+ "hint": (
+ "WooCommerce → Settings → Advanced → REST API → Add Key. "
+ "Read/Write permission. This pair IS the API auth — no extra API key field exists."
+ ),
},
{
"name": "consumer_secret",
"label": "Consumer Secret",
"type": "password",
"required": True,
- "hint": "Shown once when creating the REST API key",
+ "hint": (
+ "Shown once when the REST API key is created. "
+ "Starts with ``cs_``. Save it immediately — WooCommerce will not display it again."
+ ),
+ },
+ # F.X.fix-pass4 — optional WP Application Password, only used by
+ # media tools (upload_and_attach_to_product / attach_media_to_
+ # product / set_featured_image). WooCommerce's Consumer Key +
+ # Secret authenticate ``/wc/v3/*`` but NOT ``/wp/v2/media``;
+ # WordPress core REST media uploads require an Application
+ # Password from the WP admin user. Leave blank if the store
+ # never needs MCP-driven image uploads.
+ {
+ "name": "wp_username",
+ "label": "WordPress Username (for media tools)",
+ "type": "text",
+ "required": False,
+ "advanced": True,
+ "hint": (
+ "Only required if you want the AI / media tools "
+ "(upload_and_attach_to_product, attach_media_to_product, "
+ "set_featured_image, generate_and_upload_image with attach_to_post). "
+ "Other WC tools work with Consumer Key / Secret alone."
+ ),
+ },
+ {
+ "name": "wp_app_password",
+ "label": "WordPress Application Password (for media tools)",
+ "type": "password",
+ "required": False,
+ "advanced": True,
+ "hint": (
+ "WP Admin → Users → Profile → Application Passwords. "
+ "WC media uploads hit /wp/v2/media which only accepts "
+ "Application Passwords (not Consumer Key / Secret). "
+ "Leave empty if you don't use MCP for image uploads."
+ ),
},
],
"wordpress_advanced": [
@@ -67,21 +110,24 @@ PLUGIN_CREDENTIAL_FIELDS: dict[str, list[dict[str, Any]]] = {
"label": "Username",
"type": "text",
"required": True,
- "hint": "Your WordPress admin username",
+ "hint": "Your WordPress admin username (HTTP Basic username for every API call).",
},
{
"name": "app_password",
"label": "Application Password",
"type": "password",
"required": True,
- "hint": "WordPress Admin → Users → Profile → Application Passwords",
+ "hint": (
+ "WordPress Admin → Users → Profile → Application Passwords. "
+ "IS the API credential — no separate API key needed."
+ ),
},
{
"name": "container",
"label": "Docker Container Name",
"type": "text",
"required": False,
- "hint": "Docker container running WordPress (for WP-CLI access)",
+ "hint": "Docker container running WordPress (for WP-CLI access). Leave empty if unused.",
},
],
"gitea": [
@@ -654,6 +700,170 @@ async def update_user_site(
return site_dict
+# ---------------------------------------------------------------------------
+# F.5a.9.x: per-site AI provider keys (OpenAI / Stability / Replicate)
+# ---------------------------------------------------------------------------
+
+# Only WordPress / WooCommerce sites can host AI image provider keys — the
+# only consumer is ``wordpress_generate_and_upload_image``, which uploads into
+# a WordPress media library. Other plugin types never surface the section.
+PROVIDER_KEYS_ALLOWED_PLUGIN_TYPES: frozenset[str] = frozenset({"wordpress", "woocommerce"})
+
+# Providers supported for per-site keys. Must stay in sync with
+# plugins/ai_image/registry.py ``_PROVIDERS`` (order is preserved to
+# drive the UI).
+SITE_PROVIDERS: tuple[str, ...] = ("openai", "stability", "replicate", "openrouter")
+
+
+def site_provider_scope(site_id: str, provider: str) -> str:
+ """Return the HKDF scope used for per-site provider-key encryption.
+
+ Format: ``site_provider:{site_id}:{provider}`` — distinct from the
+ per-site credentials scope (bare ``site_id``) so the same master key
+ never produces the same derived key for both credential types.
+ """
+ return f"site_provider:{site_id}:{provider}"
+
+
+def site_supports_provider_keys(plugin_type: str) -> bool:
+ """Return True if the plugin type can hold AI provider keys."""
+ return plugin_type in PROVIDER_KEYS_ALLOWED_PLUGIN_TYPES
+
+
+async def set_site_provider_key(
+ site_id: str,
+ user_id: str,
+ provider: str,
+ api_key: str,
+) -> dict[str, Any]:
+ """Encrypt and store an AI provider API key for a user-owned site.
+
+ Args:
+ site_id: Site UUID.
+ user_id: Owner's UUID (enforces tenant isolation).
+ provider: One of :data:`SITE_PROVIDERS`.
+ api_key: The plaintext API key to store.
+
+ Returns:
+ The stored row (without plaintext).
+
+ Raises:
+ ValueError: If the site is not found, the plugin type does not
+ support provider keys, the provider is unknown, or the key
+ is empty.
+ """
+ from core.database import get_database
+ from core.encryption import get_credential_encryption
+
+ if provider not in SITE_PROVIDERS:
+ raise ValueError(
+ f"Unsupported provider '{provider}'. " f"Supported: {', '.join(SITE_PROVIDERS)}"
+ )
+ if not api_key or not api_key.strip():
+ raise ValueError("API key must not be empty")
+
+ db = get_database()
+ site = await db.get_site(site_id, user_id)
+ if site is None:
+ raise ValueError("Site not found")
+
+ if not site_supports_provider_keys(site["plugin_type"]):
+ raise ValueError(
+ f"Plugin type '{site['plugin_type']}' does not support AI "
+ f"provider keys (only WordPress/WooCommerce)."
+ )
+
+ encryptor = get_credential_encryption()
+ ciphertext = encryptor.encrypt_for_scope(
+ api_key.strip(), site_provider_scope(site_id, provider)
+ )
+
+ row = await db.upsert_site_provider_key(site_id, provider, ciphertext)
+ logger.info("Stored %s provider key for site %s (user %s)", provider, site_id, user_id)
+ return row
+
+
+async def get_site_provider_key(
+ site_id: str,
+ provider: str,
+) -> str | None:
+ """Return the decrypted AI provider key for a site, or None.
+
+ Caller is responsible for verifying site ownership — this helper is
+ also called from the AI tool handler which already trusts the
+ resolved site_id.
+
+ Args:
+ site_id: Site UUID.
+ provider: Provider identifier.
+
+ Returns:
+ Decrypted API key string, or None if no key is stored.
+ """
+ from core.database import get_database
+ from core.encryption import get_credential_encryption
+
+ if provider not in SITE_PROVIDERS:
+ return None
+
+ db = get_database()
+ row = await db.get_site_provider_key(site_id, provider)
+ if row is None:
+ return None
+
+ encryptor = get_credential_encryption()
+ try:
+ return encryptor.decrypt_for_scope(
+ row["key_ciphertext"], site_provider_scope(site_id, provider)
+ )
+ except Exception:
+ logger.exception(
+ "Failed to decrypt site provider key (site=%s provider=%s)",
+ site_id,
+ provider,
+ )
+ return None
+
+
+async def list_site_providers_set(site_id: str) -> set[str]:
+ """Return the set of providers that have a key stored for the site."""
+ from core.database import get_database
+
+ db = get_database()
+ rows = await db.list_site_provider_keys(site_id)
+ return {row["provider"] for row in rows}
+
+
+async def delete_site_provider_key(
+ site_id: str,
+ user_id: str,
+ provider: str,
+) -> bool:
+ """Remove an AI provider key for a site. Returns True if a row was deleted.
+
+ Args:
+ site_id: Site UUID.
+ user_id: Owner's UUID — enforces tenant isolation before deletion.
+ provider: Provider identifier.
+ """
+ from core.database import get_database
+
+ db = get_database()
+ site = await db.get_site(site_id, user_id)
+ if site is None:
+ return False
+
+ deleted = await db.delete_site_provider_key(site_id, provider)
+ if deleted:
+ logger.info(
+ "Deleted %s provider key for site %s (user %s)",
+ provider,
+ site_id,
+ user_id,
+ )
+ return deleted
+
+
async def test_site_connection(site_id: str, user_id: str) -> tuple[bool, str]:
"""Test connectivity to an existing site.
diff --git a/core/templates/dashboard/connect.html b/core/templates/dashboard/connect.html
index 82b2313..fc37b5d 100644
--- a/core/templates/dashboard/connect.html
+++ b/core/templates/dashboard/connect.html
@@ -143,13 +143,13 @@
{% if lang == 'fa' %}
- وردپرس: برای استفاده از ابزارهای SEO، افزونه
- Airano MCP SEO Bridge
- را روی سایت وردپرسی خود نصب کنید.
+ وردپرس: برای قابلیتهای SEO، رسانه، و سایر ابزارهای کمکی، افزونهی همراه
+ Airano MCP Bridge
+ را دانلود و روی سایت وردپرسی خود نصب کنید.
{% else %}
- WordPress: For SEO tools, install the
- Airano MCP SEO Bridge
- plugin on your WordPress site.
+ WordPress: For SEO, media, and other helper tools, download and install the
+ Airano MCP Bridge
+ companion plugin on your WordPress site.
{% endif %}
diff --git a/core/templates/dashboard/keys/list.html b/core/templates/dashboard/keys/list.html
index 071a42a..919865d 100644
--- a/core/templates/dashboard/keys/list.html
+++ b/core/templates/dashboard/keys/list.html
@@ -259,13 +259,13 @@
{% if lang == 'fa' %}
- وردپرس: برای استفاده از ابزارهای SEO، افزونه
- Airano MCP SEO Bridge
- را روی سایت وردپرسی خود نصب کنید.
+ وردپرس: برای قابلیتهای SEO، رسانه، و سایر ابزارهای کمکی، افزونهی همراه
+ Airano MCP Bridge
+ را دانلود و روی سایت وردپرسی خود نصب کنید.
{% else %}
- WordPress: For SEO tools, install the
- Airano MCP SEO Bridge
- plugin on your WordPress site.
+ WordPress: For SEO, media, and other helper tools, download and install the
+ Airano MCP Bridge
+ companion plugin on your WordPress site.
{% endif %}
diff --git a/core/templates/dashboard/service.html b/core/templates/dashboard/service.html
index 37b73a0..84fe42b 100644
--- a/core/templates/dashboard/service.html
+++ b/core/templates/dashboard/service.html
@@ -134,6 +134,57 @@
{% endif %}
+
+ {% if companion_download_url %}
+
+
+
+
+
+ {% if lang == 'fa' %}
+ افزونه همراه Airano MCP Bridge (اختیاری اما توصیهشده)
+ {% else %}
+ Airano MCP Bridge — companion plugin (optional but recommended)
+ {% endif %}
+
+
+ {% if lang == 'fa' %}
+ نصب افزونه همراه، این قابلیتها را فعال میکند: آپلود فایل بزرگتر از
+ upload_max_filesize، گزارش یکپارچهی سلامت سایت،
+ purge کش، پاکسازی transient، متاهای bulk، خروجی ساختاریافته، probe قابلیتها،
+ و webhook لاگ ممیزی. بدون آن، ابزارهای پایه کار میکنند اما موارد فوق غیرفعال خواهند بود.
+ {% else %}
+ Installing the companion plugin unlocks: uploads larger than
+ upload_max_filesize, unified site-health snapshot,
+ cache purge, transient flush, bulk meta writes, structured export, capability probe,
+ and audit-hook webhooks. Without it, basic tools still work but those features stay disabled.
+ {% endif %}
+
+
+
+
+
+ {% endif %}
+
{% if service.credential_fields %}
diff --git a/core/templates/dashboard/sites/_capability_badge.html b/core/templates/dashboard/sites/_capability_badge.html
new file mode 100644
index 0000000..5284348
--- /dev/null
+++ b/core/templates/dashboard/sites/_capability_badge.html
@@ -0,0 +1,173 @@
+{# F.X.fix #9 — capability-badge partial, HTMX-swappable.
+
+Rendered both (a) inline by manage.html on first page load and (b) as
+the response body of GET /api/sites/{id}/capabilities/badge when the
+Re-check button is clicked. Swapping this element with hx-swap=
+"outerHTML" avoids the full-page reload the legacy button triggered.
+
+F.X.fix-pass2 — wording corrected for WordPress: application passwords
+inherit ALL capabilities from the user account, so the "credential
+tier" framing is misleading. WP-specific branches reference the WP
+user's role instead. AI-provider availability is surfaced here too
+so removing a provider key gives visible feedback on Re-check.
+
+Required context:
+ capability_probe — probe payload (with .fit, .granted, .reason,
+ .ai_providers_configured, optionally .install_hint)
+ site — site dict (for .plugin_type, .tool_scope)
+ lang — "fa" | "en"
+ companion_download_url — string, may be empty
+#}
+{% set fit_status = capability_probe.fit.status %}
+{% set is_wp = site.plugin_type in ['wordpress', 'wordpress_advanced'] %}
+{% set is_wc = site.plugin_type == 'woocommerce' %}
+{% set is_wp_like = is_wp or is_wc %}
+{% set ai_providers = capability_probe.ai_providers_configured or [] %}
+{% set ai_provider_missing = is_wp_like and (ai_providers | length == 0) %}
+
+
+
+ {% if fit_status == 'ok' %}
+
+ {% if lang == 'fa' %}✓ دسترسی credential برای سطح انتخابشده کافی است{% else %}✓ Credential grants what this tier needs{% endif %}
+
+ {% if capability_probe.granted %}
+
+ {% if lang == 'fa' %}دسترسیهای احراز شده:{% else %}Granted:{% endif %}
+ {{ capability_probe.granted | join(', ') }}
+
+ {% endif %}
+ {% elif fit_status == 'warning' %}
+
+ {% if is_wc %}
+ {% if lang == 'fa' %}
+ ⚠ سطح دسترسی Consumer Key ووکامرس برای سطح «{{ site.tool_scope }}» کافی نیست
+ {% else %}
+ ⚠ The WooCommerce REST API key permission is below the selected "{{ site.tool_scope }}" tier
+ {% endif %}
+ {% elif is_wp %}
+ {% if lang == 'fa' %}
+ ⚠ کاربر وردپرس این credential نقش لازم برای سطح «{{ site.tool_scope }}» را ندارد
+ {% else %}
+ ⚠ The WordPress user behind this application password lacks the role for the "{{ site.tool_scope }}" tier
+ {% endif %}
+ {% else %}
+ {% if lang == 'fa' %}
+ ⚠ credential ذخیرهشده برای سطح «{{ site.tool_scope }}» کافی نیست
+ {% else %}
+ ⚠ Saved credential is below the selected "{{ site.tool_scope }}" tier
+ {% endif %}
+ {% endif %}
+
+
+ {% if lang == 'fa' %}دسترسیهای ناموجود:{% else %}Missing:{% endif %}
+ {{ capability_probe.fit.missing | join(', ') }}
+
+ {% if capability_probe.granted %}
+
+ {% if lang == 'fa' %}آنچه در دسترس است:{% else %}Currently granted:{% endif %}
+ {{ capability_probe.granted | join(', ') }}
+
+ {% endif %}
+
+ {% if is_wc %}
+ {% if lang == 'fa' %}
+ کلید ووکامرس (Consumer Key + Secret) دارای فیلد permissions با مقادیر
+ read یا read_write است. گزینهها:
+ (۱) سطح پایینتر انتخاب کنید؛
+ (۲) در WP Admin → WooCommerce → Settings → Advanced → REST API یک کلید جدید با Permission مناسب (Read / Write یا Read/Write) بسازید و در Connection Settings ذخیره کنید؛
+ (۳) ادامه — ابزارهای نیازمند این دسترسی هنگام فراخوانی ۴۰۳ میدهند.
+ {% else %}
+ WooCommerce REST API keys carry a permissions field
+ (read / read_write). Options:
+ (1) pick a lower access level;
+ (2) regenerate the REST key with Read/Write permission in WP Admin → WooCommerce → Settings → Advanced → REST API and re-paste it in Connection Settings;
+ (3) continue — tools needing missing caps will return 403 at call time.
+ {% endif %}
+ {% elif is_wp %}
+ {% if lang == 'fa' %}
+ application password در وردپرس همیشه تمام دسترسیهای کاربر صاحب آن را میگیرد و قابل محدودکردن نیست. گزینهها:
+ (۱) سطح دسترسی پایینتری انتخاب کنید؛
+ (۲) application password را از کاربری با نقش بالاتر (مثلاً Administrator یا Editor) بسازید؛
+ (۳) ادامه — ابزارهای نیازمند این دسترسی هنگام فراخوانی ۴۰۳ میدهند.
+ {% else %}
+ WordPress application passwords inherit ALL capabilities of the user account and cannot be scoped down.
+ Options: (1) pick a lower access level;
+ (2) create the application password from a higher-role user (e.g. Administrator or Editor);
+ (3) continue — tools needing missing caps will return 403 at call time.
+ {% endif %}
+ {% else %}
+ {% if lang == 'fa' %}
+ گزینهها: (۱) انتخاب سطح پایینتر؛ (۲) استفاده از credential با دسترسی بالاتر؛ (۳) ادامه — ابزارهای نیازمند این دسترسی هنگام فراخوانی ۴۰۳ میدهند.
+ {% else %}
+ Options: (1) pick a lower tier; (2) use a higher-privileged credential; (3) continue — tools needing missing caps will return 403 at call time.
+ {% endif %}
+ {% endif %}
+
+ {% elif fit_status == 'probe_unavailable' %}
+
+ {% if lang == 'fa' %}ℹ امکان بررسی خودکار credential در این سایت وجود ندارد{% else %}ℹ Capability probe is unavailable for this site{% endif %}
+
+
+ {{ capability_probe.reason or 'probe_unavailable' }}
+
+ {% if site.plugin_type in ['wordpress', 'woocommerce'] and companion_download_url %}
+
+ {% if lang == 'fa' %}
+ برای فعالسازی probe، افزونه
+
Airano MCP Bridge
+ را روی وردپرس نصب کنید.
+ {% else %}
+ Install the
+
Airano MCP Bridge
+ plugin on your WordPress to enable the probe.
+ {% endif %}
+
+ {% endif %}
+ {% elif fit_status == 'unknown_tier' %}
+
+ {% if lang == 'fa' %}ℹ probe برای این plugin + سطح پیادهسازی نشده{% else %}ℹ Probe not implemented for this plugin/tier combination{% endif %}
+
+ {% endif %}
+
+ {# F.X.fix-pass2 — AI provider status line. Independent of the
+ tier-fit state: even a perfectly-fit credential cannot run
+ the AI image tool without an OpenRouter / OpenAI / … key. #}
+ {% if ai_provider_missing %}
+
+ {% if lang == 'fa' %}⚠ هیچ کلید AI Provider تنظیم نشده{% else %}⚠ No AI provider key configured{% endif %}
+
+ {% if lang == 'fa' %}
+ — ابزار wordpress_generate_and_upload_image غیرفعال است. کلید را در بخش «تولید تصویر با هوش مصنوعی» بالا ذخیره کنید.
+ {% else %}
+ — wordpress_generate_and_upload_image is disabled. Add a key in the "AI Image Generation" section above.
+ {% endif %}
+
+
+ {% elif is_wp_like and ai_providers %}
+
+ {% if lang == 'fa' %}کلیدهای AI Provider فعال:{% else %}Configured AI providers:{% endif %}
+ {{ ai_providers | join(', ') }}
+
+ {% endif %}
+
+
+ {% if lang == 'fa' %}بررسی دوباره{% else %}Re-check{% endif %}
+
+
+
diff --git a/core/templates/dashboard/sites/manage.html b/core/templates/dashboard/sites/manage.html
index aeeb5fe..58fe4f5 100644
--- a/core/templates/dashboard/sites/manage.html
+++ b/core/templates/dashboard/sites/manage.html
@@ -62,13 +62,20 @@
{% set fields = plugin_fields.get(site.plugin_type, []) %}
{% for field in fields %}
{% if not field.get('advanced', False) %}
-
-
- {{ field.label }}{% if field.required %} *{% endif %}
+ {% set is_stored = cred_states.get(field.name, False) %}
+
+
+ {{ field.label }}{% if field.required %} *{% endif %}
+ {% if is_stored %}
+
+ ✓ {% if lang == 'fa' %}ذخیرهشده{% else %}Stored{% endif %}
+
+ {% endif %}
{% if field.hint %}
{{ field.hint }}
@@ -81,15 +88,30 @@
{% if has_advanced %}
- Advanced Settings
+ {% if lang == 'fa' %}تنظیمات پیشرفته{% else %}Advanced Settings{% endif %}
{% for field in fields %}
{% if field.get('advanced', False) %}
-
-
{{ field.label }}
+ {% set is_stored = cred_states.get(field.name, False) %}
+
+
+ {{ field.label }}
+ {% if is_stored %}
+
+ ✓ {% if lang == 'fa' %}ذخیرهشده{% else %}Stored{% endif %}
+
+
+ {% if lang == 'fa' %}🗑 پاک کن{% else %}🗑 Clear{% endif %}
+
+ {% endif %}
+
{% if field.hint %}
{{ field.hint }}
@@ -118,6 +140,113 @@
+ {% if provider_keys_supported %}
+
+
+
+
+
+
+
+
+
+ {% if lang == 'fa' %}تولید تصویر با هوش مصنوعی{% else %}AI Image Generation{% endif %}
+
+
+ {% if lang == 'fa' %}
+ کلید API هر ارائهدهنده را تنها برای این سایت ذخیره کنید. بدون کلید، ابزار wordpress_generate_and_upload_image غیرفعال است.
+ {% else %}
+ Store an API key per provider, scoped only to this site. Without a key, wordpress_generate_and_upload_image is disabled for this site.
+ {% endif %}
+
+
+
+
+
+
+
+
+
+
+ {% for row in provider_key_rows %}
+
+
+
+
{{ row.label }}
+ {% if row.status == 'set' %}
+
+ {% if lang == 'fa' %}تنظیمشده{% else %}Set{% endif %}
+
+ {% else %}
+
+ {% if lang == 'fa' %}تنظیم نشده{% else %}Unset{% endif %}
+
+ {% endif %}
+
+
+
+
+
+ {% if lang == 'fa' %}حذف{% else %}Remove{% endif %}
+
+
+
+ {% if row.provider == 'openrouter' %}
+
+
+ {% if lang == 'fa' %}🖼 مدلهای تصویری در دسترس{% if row.default_model %} — پیشفرض: {{ row.default_model }}{% endif %}{% else %}🖼 Available image models{% if row.default_model %} — default: {{ row.default_model }}{% endif %}{% endif %}
+
+
+
+ {% if lang == 'fa' %}در حال بارگذاری…{% else %}Loading…{% endif %}
+
+
+
+ {% if lang == 'fa' %}
+ با کلیک روی «پیشفرض» مدل بهعنوان مدل پیشفرض این سایت ذخیره میشود — اگر model در فراخوانی wordpress_generate_and_upload_image داده نشود، از این مقدار استفاده میشود.
+ {% else %}
+ Click "Set default" to make a model the site's default — used when wordpress_generate_and_upload_image is called without an explicit model.
+ {% endif %}
+
+
+
+ {% endif %}
+ {% endfor %}
+
+
+ {% if lang == 'fa' %}
+ کلیدها با AES-256-GCM رمزگذاری میشوند و فقط برای همین سایت قابل استفادهاند.
+ {% else %}
+ Keys are encrypted at rest (AES-256-GCM) and scoped to this site only.
+ {% endif %}
+
+
+
+ {% endif %}
+
@@ -141,6 +270,31 @@
+ {% if companion_download_url %}
+
+
+ {% if lang == 'fa' %}
+ برای قابلیتهای کامل (آپلود بزرگ، cache purge، site-health، و…) افزونهی
+
Airano MCP Bridge
+ را در وردپرس نصب کنید (اختیاری).
+ {% else %}
+ For full capability (large uploads, cache purge, site-health, bulk meta, audit hooks, …), install the
+
Airano MCP Bridge
+ plugin on your WordPress (optional).
+ {% endif %}
+
+ {% endif %}
+
+ {# F.7e: capability probe badge — tells the user whether the
+ saved credential can actually back the selected tier,
+ before they hit a 403 from a real tool call. #}
+ {# F.X.fix #9: badge is an HTMX-swappable partial. Re-check
+ button hits /api/sites/{id}/capabilities/badge and swaps
+ this element in place — no full-page reload. #}
+ {% if capability_probe %}
+ {% include "dashboard/sites/_capability_badge.html" %}
+ {% endif %}
+
{% if lang == 'fa' %}در حال بارگذاری...{% else %}Loading...{% endif %}
@@ -303,6 +457,30 @@
}
// ── Connection form ─────────────────────────────────────────────
+ // F.X.fix-pass5 — Clear-button wiring. Marks the matching input
+ // with data-clear="1" + visually flashes the row so the user
+ // knows the next Save will wipe the stored value.
+ document.querySelectorAll('[data-cred-clear]').forEach(btn => {
+ btn.addEventListener('click', (ev) => {
+ ev.preventDefault();
+ const fieldName = btn.dataset.credClear;
+ const input = document.querySelector(`[name="cred_${fieldName}"]`);
+ if (!input) return;
+ input.value = '';
+ input.dataset.clear = '1';
+ input.classList.add('border-red-400', 'dark:border-red-500');
+ const row = btn.closest('[data-cred-row]');
+ const stored = row?.querySelector('[data-cred-stored]');
+ if (stored) {
+ stored.textContent = '{% if lang == "fa" %}پس از Save پاک میشود{% else %}Will clear on Save{% endif %}';
+ stored.classList.remove('bg-green-100', 'dark:bg-green-900/40', 'text-green-700', 'dark:text-green-300');
+ stored.classList.add('bg-red-100', 'dark:bg-red-900/40', 'text-red-700', 'dark:text-red-300');
+ }
+ btn.disabled = true;
+ btn.classList.add('opacity-50');
+ });
+ });
+
document.getElementById('edit-site-form').addEventListener('submit', async (e) => {
e.preventDefault();
const btn = document.getElementById('submit-btn');
@@ -313,13 +491,22 @@
const url = document.getElementById('url').value;
const creds = {};
+ // F.X.fix-pass5 — Empty input means "keep existing"; clearing
+ // a stored optional value requires an explicit Clear button
+ // click (which sets data-clear="1" on the input). Old
+ // behaviour silently wiped optional fields on every save.
if (pluginFields[pluginType]) {
pluginFields[pluginType].forEach(field => {
const input = document.querySelector(`[name="cred_${field.name}"]`);
if (!input) return;
const val = input.value.trim();
- if (val) creds[field.name] = val;
- else if (input.dataset.required === 'false') creds[field.name] = '';
+ if (val) {
+ creds[field.name] = val;
+ } else if (input.dataset.clear === '1') {
+ // Explicit clear: send empty string to wipe.
+ creds[field.name] = '';
+ }
+ // else: leave out → server keeps existing value.
});
}
@@ -391,6 +578,11 @@
} catch (_) {}
}
+ // F.X.fix-pass2 — exposed so the AI-keys set/delete handlers can
+ // trigger a live refresh of the gating UI after configuring a
+ // provider key (no full-page reload).
+ window.loadToolAccess = loadToolAccess;
+
function renderToolAccess() {
document.getElementById('tool-access-loading').classList.add('hidden');
document.getElementById('tool-access-content').classList.remove('hidden');
@@ -642,16 +834,35 @@
function renderToolRow(tool) {
const inScope = isToolInScope(tool);
+ // F.X.fix-pass3 — central prerequisite gating. Backend has
+ // already evaluated `available` + `unavailable_reason` so the
+ // UI just renders accordingly. Reasons we know how to handle:
+ // provider_key — needs an AI provider key on this site
+ // companion_route — needs the Airano MCP Bridge companion plugin
+ // feature — needs an SEO plugin (Rank Math / Yoast)
+ const reason = tool.unavailable_reason || null;
+ const missingProviderKey = reason === 'provider_key';
+ const missingCompanion = reason === 'companion_route';
+ const missingSeoPlugin = reason === 'feature';
+ const missingWpCreds = reason === 'wp_credentials';
+ const unavailable = !tool.available;
+ const dimmed = !inScope || unavailable;
const row = document.createElement('div');
- row.className = 'flex items-center justify-between py-1.5 px-2 rounded hover:bg-gray-50 dark:hover:bg-gray-700/30' + (inScope ? '' : ' opacity-40');
+ row.className = 'flex items-center justify-between py-1.5 px-2 rounded hover:bg-gray-50 dark:hover:bg-gray-700/30' + (dimmed ? ' opacity-40' : '');
const left = document.createElement('div');
left.className = 'flex items-center gap-2 flex-1 min-w-0';
const nameEl = document.createElement('span');
- nameEl.className = 'text-sm truncate font-mono ' + (inScope ? 'text-gray-800 dark:text-gray-200' : 'text-gray-400 dark:text-gray-500 line-through');
+ nameEl.className = 'text-sm truncate font-mono ' + (dimmed ? 'text-gray-400 dark:text-gray-500 line-through' : 'text-gray-800 dark:text-gray-200');
nameEl.textContent = tool.name;
- nameEl.title = (tool.description || '') + (inScope ? '' : ' — {% if lang == "fa" %}خارج از سطح دسترسی{% else %}outside current scope{% endif %}');
+ const titleParts = [tool.description || ''];
+ if (!inScope) titleParts.push('— {% if lang == "fa" %}خارج از سطح دسترسی{% else %}outside current scope{% endif %}');
+ if (missingProviderKey) titleParts.push('— {% if lang == "fa" %}نیاز به تنظیم کلید AI Provider{% else %}needs an AI provider key{% endif %}');
+ if (missingCompanion) titleParts.push('— {% if lang == "fa" %}نیاز به افزونه Airano MCP Bridge{% else %}needs the Airano MCP Bridge companion plugin{% endif %}');
+ if (missingSeoPlugin) titleParts.push('— {% if lang == "fa" %}نیاز به افزونهی SEO (Rank Math یا Yoast){% else %}needs an SEO plugin (Rank Math or Yoast){% endif %}');
+ if (missingWpCreds) titleParts.push('— {% if lang == "fa" %}نیاز به Application Password وردپرس{% else %}needs a WordPress Application Password{% endif %}');
+ nameEl.title = titleParts.join(' ');
left.appendChild(nameEl);
if (!inScope) {
@@ -668,29 +879,74 @@
left.appendChild(badge);
}
+ if (missingProviderKey) {
+ const configurePill = document.createElement('button');
+ configurePill.type = 'button';
+ configurePill.className = 'flex-shrink-0 px-1.5 py-0.5 rounded text-xs font-medium bg-pink-100 dark:bg-pink-500/20 text-pink-700 dark:text-pink-300 hover:bg-pink-200 dark:hover:bg-pink-500/30 transition-colors';
+ configurePill.textContent = '{% if lang == "fa" %}⚙ تنظیم کلید{% else %}⚙ Configure key{% endif %}';
+ configurePill.addEventListener('click', (ev) => {
+ ev.stopPropagation();
+ const section = document.getElementById('aikeys-section');
+ if (section && section.classList.contains('hidden')) {
+ toggleSection('aikeys');
+ }
+ const target = document.getElementById('aikeys-section');
+ if (target) target.scrollIntoView({ behavior: 'smooth', block: 'start' });
+ });
+ left.appendChild(configurePill);
+ } else if (missingCompanion) {
+ const pill = document.createElement('span');
+ pill.className = 'flex-shrink-0 px-1.5 py-0.5 rounded text-xs font-medium bg-indigo-100 dark:bg-indigo-500/20 text-indigo-700 dark:text-indigo-300';
+ pill.textContent = '{% if lang == "fa" %}🧩 افزونهی همراه نیاز است{% else %}🧩 needs companion plugin{% endif %}';
+ left.appendChild(pill);
+ } else if (missingSeoPlugin) {
+ const pill = document.createElement('span');
+ pill.className = 'flex-shrink-0 px-1.5 py-0.5 rounded text-xs font-medium bg-amber-100 dark:bg-amber-500/20 text-amber-700 dark:text-amber-300';
+ pill.textContent = '{% if lang == "fa" %}🔍 SEO plugin نیاز است{% else %}🔍 needs SEO plugin{% endif %}';
+ left.appendChild(pill);
+ } else if (missingWpCreds) {
+ const pill = document.createElement('button');
+ pill.type = 'button';
+ pill.className = 'flex-shrink-0 px-1.5 py-0.5 rounded text-xs font-medium bg-orange-100 dark:bg-orange-500/20 text-orange-700 dark:text-orange-300 hover:bg-orange-200 dark:hover:bg-orange-500/30 transition-colors';
+ pill.textContent = '{% if lang == "fa" %}🔑 WP App Password نیاز{% else %}🔑 needs WP App Password{% endif %}';
+ pill.addEventListener('click', (ev) => {
+ ev.stopPropagation();
+ const link = document.createElement('a');
+ link.href = '/dashboard/sites/' + encodeURIComponent(siteId) + '/edit';
+ link.click();
+ });
+ left.appendChild(pill);
+ }
+
const label = document.createElement('label');
- label.className = 'relative inline-flex items-center cursor-pointer flex-shrink-0';
+ label.className = 'relative inline-flex items-center flex-shrink-0 ' + (unavailable ? 'cursor-not-allowed' : 'cursor-pointer');
const input = document.createElement('input');
input.type = 'checkbox';
input.className = 'sr-only peer';
- input.checked = tool.enabled !== false;
- input.onchange = async () => {
- const enabled = input.checked;
- try {
- const r = await fetch('/api/sites/' + siteId + '/tools/' + tool.name, {
- method: 'PATCH',
- headers: { 'Content-Type': 'application/json' },
- body: JSON.stringify({ enabled }),
- });
- if (!r.ok) input.checked = !enabled;
- else {
- tool.enabled = enabled;
- const en = allTools.filter(t => t.enabled !== false).length;
- document.getElementById('tool-summary').textContent =
- '{% if lang == "fa" %}' + en + ' از ' + allTools.length + ' ابزار فعال{% else %}' + en + ' of ' + allTools.length + ' tools enabled{% endif %}';
- }
- } catch (_) { input.checked = !enabled; }
- };
+ // When prerequisites unmet, force toggle visually OFF regardless
+ // of the user's saved preference — backend filter already drops
+ // the tool from the live endpoint.
+ input.checked = tool.available !== false && tool.enabled !== false;
+ input.disabled = unavailable;
+ if (!unavailable) {
+ input.onchange = async () => {
+ const enabled = input.checked;
+ try {
+ const r = await fetch('/api/sites/' + siteId + '/tools/' + tool.name, {
+ method: 'PATCH',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ enabled }),
+ });
+ if (!r.ok) input.checked = !enabled;
+ else {
+ tool.enabled = enabled;
+ const en = allTools.filter(t => t.enabled !== false).length;
+ document.getElementById('tool-summary').textContent =
+ '{% if lang == "fa" %}' + en + ' از ' + allTools.length + ' ابزار فعال{% else %}' + en + ' of ' + allTools.length + ' tools enabled{% endif %}';
+ }
+ } catch (_) { input.checked = !enabled; }
+ };
+ }
const slider = document.createElement('div');
slider.className = 'w-9 h-5 bg-gray-300 dark:bg-gray-600 peer-checked:bg-blue-600 rounded-full peer peer-focus:ring-2 peer-focus:ring-blue-300 dark:peer-focus:ring-blue-800 transition-colors after:content-[""] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:rounded-full after:h-4 after:w-4 after:transition-all peer-checked:after:translate-x-4';
label.appendChild(input);
@@ -759,6 +1015,252 @@
btn.disabled = false;
}
+ // ── Per-site AI provider keys (F.5a.9.x) ────────────────────────
+ {% if provider_keys_supported %}
+ (function () {
+ const flash = document.getElementById('aikeys-flash');
+ function show(kind, msg) {
+ if (!flash) return;
+ flash.textContent = msg;
+ flash.className = 'rounded-lg p-3 text-sm ' + (
+ kind === 'ok'
+ ? 'bg-green-50 dark:bg-green-500/10 text-green-700 dark:text-green-300 border border-green-200 dark:border-green-500/30'
+ : 'bg-red-50 dark:bg-red-500/10 text-red-700 dark:text-red-300 border border-red-200 dark:border-red-500/30'
+ );
+ flash.classList.remove('hidden');
+ }
+
+ // F.X.fix-pass2 — in-place DOM update after set/delete (no
+ // window.location.reload()), so the AI Image Generation panel
+ // stays expanded and the scroll position is preserved. Also
+ // triggers a Tool Access reload + a capability-badge re-check
+ // so the gating UI reflects the new provider state live.
+ function _updateRowStatus(provider, isSet) {
+ const row = document.querySelector('[data-site-ai-row="' + provider + '"]');
+ if (!row) return;
+ const badge = row.querySelector('span.inline-flex');
+ if (badge) {
+ if (isSet) {
+ badge.className = 'inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-green-100 dark:bg-green-900 text-green-700 dark:text-green-300';
+ badge.textContent = '{% if lang == "fa" %}تنظیمشده{% else %}Set{% endif %}';
+ } else {
+ badge.className = 'inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-gray-100 dark:bg-gray-700 text-gray-700 dark:text-gray-300';
+ badge.textContent = '{% if lang == "fa" %}تنظیم نشده{% else %}Unset{% endif %}';
+ }
+ }
+ const removeBtn = document.querySelector('[data-site-ai-remove="' + provider + '"]');
+ if (removeBtn) {
+ removeBtn.classList.toggle('hidden', !isSet);
+ }
+ const form = document.querySelector('[data-site-ai-set="' + provider + '"]');
+ if (form) {
+ const input = form.querySelector('input[name="api_key"]');
+ if (input) input.value = '';
+ }
+ }
+
+ function _refreshLinkedUi() {
+ // Re-fetch tools so the AI tool's gating updates inline.
+ if (typeof window.loadToolAccess === 'function') {
+ window.loadToolAccess();
+ }
+ // Trigger the HTMX re-check button so the capability badge
+ // re-renders with the new ai_providers_configured state.
+ const recheck = document.querySelector('#capability-badge button[hx-get]');
+ if (recheck && window.htmx) {
+ window.htmx.trigger(recheck, 'click');
+ }
+ }
+
+ async function setKey(ev, provider) {
+ ev.preventDefault();
+ const form = ev.target;
+ const fd = new FormData(form);
+ const apiKey = (fd.get('api_key') || '').toString();
+ if (!apiKey || apiKey.length < 8) {
+ show('err', '{% if lang == "fa" %}کلید باید حداقل ۸ کاراکتر باشد{% else %}Key must be at least 8 characters{% endif %}');
+ return false;
+ }
+ try {
+ const resp = await fetch('/api/sites/' + encodeURIComponent(siteId) + '/provider-keys/' + encodeURIComponent(provider), {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ api_key: apiKey }),
+ credentials: 'same-origin',
+ });
+ const data = await resp.json();
+ if (!resp.ok || !data.ok) {
+ show('err', data.message || data.error || 'save failed');
+ return false;
+ }
+ _updateRowStatus(provider, true);
+ _refreshLinkedUi();
+ show('ok', '{% if lang == "fa" %}ذخیره شد{% else %}Saved{% endif %} — last4: ' + (data.secret_last4 || ''));
+ } catch (e) {
+ show('err', e.message || String(e));
+ }
+ return false;
+ }
+
+ async function deleteKey(provider) {
+ if (!window.confirm('{% if lang == "fa" %}آیا مطمئن هستید؟{% else %}Remove this provider key?{% endif %}')) {
+ return false;
+ }
+ try {
+ const resp = await fetch('/api/sites/' + encodeURIComponent(siteId) + '/provider-keys/' + encodeURIComponent(provider), {
+ method: 'DELETE',
+ credentials: 'same-origin',
+ });
+ const data = await resp.json();
+ if (!resp.ok || !data.ok) {
+ show('err', data.message || data.error || 'delete failed');
+ return false;
+ }
+ _updateRowStatus(provider, false);
+ _refreshLinkedUi();
+ show('ok', '{% if lang == "fa" %}حذف شد{% else %}Removed{% endif %}');
+ } catch (e) {
+ show('err', e.message || String(e));
+ }
+ return false;
+ }
+
+ // F.X.fix-pass2 — OpenRouter model discovery UI. Lazy-loaded on
+ // first
expand so the panel open doesn't pay the
+ // network cost unless the user asks. Falls back gracefully
+ // when no key is set (catalog is public but availability
+ // flags reflect the operator's account when we pass a key).
+ async function _loadOpenRouterModels(details) {
+ if (details.dataset.loaded === '1') return;
+ details.dataset.loaded = '1';
+ const placeholder = details.querySelector('[data-models-placeholder]');
+ const list = details.querySelector('[data-models-list]');
+ try {
+ const resp = await fetch(
+ '/api/providers/openrouter/models?site_id=' + encodeURIComponent(siteId),
+ { credentials: 'same-origin' }
+ );
+ const data = await resp.json();
+ if (!resp.ok || !data.ok) {
+ placeholder.textContent = data.message || data.error || '{% if lang == "fa" %}خطا در دریافت لیست{% else %}Failed to load models{% endif %}';
+ return;
+ }
+ const models = Array.isArray(data.models) ? data.models : [];
+ if (!models.length) {
+ placeholder.textContent = '{% if lang == "fa" %}هیچ مدلی یافت نشد{% else %}No image models found{% endif %}';
+ return;
+ }
+ placeholder.classList.add('hidden');
+ list.classList.remove('hidden');
+ list.innerHTML = '';
+ const provider = details.dataset.siteAiModels || 'openrouter';
+ let currentDefault = details.dataset.currentDefault || '';
+ const renderRow = (m) => {
+ const li = document.createElement('li');
+ li.className = 'flex items-start justify-between gap-2 pb-1 border-b border-gray-200 dark:border-gray-700 last:border-0';
+ const info = document.createElement('div');
+ info.className = 'flex-1 min-w-0';
+ const id = document.createElement('code');
+ id.className = 'font-mono text-gray-800 dark:text-gray-200 break-all';
+ id.textContent = m.id;
+ const nameLine = document.createElement('div');
+ nameLine.className = 'text-[11px] text-gray-500 dark:text-gray-400 mt-0.5';
+ const nameBits = [];
+ if (m.name && m.name !== m.id) nameBits.push(m.name);
+ if (m.price_per_image_usd != null) {
+ nameBits.push('$' + Number(m.price_per_image_usd).toFixed(4) + '/img');
+ }
+ nameLine.textContent = nameBits.join(' · ');
+ info.appendChild(id);
+ if (nameBits.length) info.appendChild(nameLine);
+
+ const actions = document.createElement('div');
+ actions.className = 'flex-shrink-0 flex items-center gap-1';
+
+ const isDefault = currentDefault === m.id;
+ const defaultBtn = document.createElement('button');
+ defaultBtn.type = 'button';
+ defaultBtn.className = 'px-2 py-0.5 rounded border text-[11px] transition-colors ' + (
+ isDefault
+ ? 'border-emerald-300 dark:border-emerald-600 bg-emerald-100 dark:bg-emerald-500/20 text-emerald-700 dark:text-emerald-300'
+ : 'border-gray-300 dark:border-gray-600 text-gray-600 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700'
+ );
+ defaultBtn.textContent = isDefault
+ ? '{% if lang == "fa" %}★ پیشفرض{% else %}★ Default{% endif %}'
+ : '{% if lang == "fa" %}پیشفرض{% else %}Set default{% endif %}';
+ defaultBtn.addEventListener('click', async () => {
+ const target = isDefault ? null : m.id;
+ defaultBtn.disabled = true;
+ const orig = defaultBtn.textContent;
+ defaultBtn.textContent = '…';
+ try {
+ const resp = await fetch(
+ '/api/sites/' + encodeURIComponent(siteId) +
+ '/provider-keys/' + encodeURIComponent(provider) +
+ '/default-model',
+ {
+ method: 'PATCH',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ model: target }),
+ credentials: 'same-origin',
+ }
+ );
+ const data = await resp.json();
+ if (!resp.ok || !data.ok) {
+ defaultBtn.textContent = orig;
+ show('err', data.message || data.error || '{% if lang == "fa" %}خطا{% else %}Error{% endif %}');
+ return;
+ }
+ currentDefault = target || '';
+ details.dataset.currentDefault = currentDefault;
+ // Re-render the entire list so all rows reflect the new default.
+ list.innerHTML = '';
+ for (const x of models) list.appendChild(renderRow(x));
+ show('ok', '{% if lang == "fa" %}پیشفرض ذخیره شد{% else %}Default saved{% endif %}');
+ } catch (e) {
+ defaultBtn.textContent = orig;
+ show('err', e.message || String(e));
+ } finally {
+ defaultBtn.disabled = false;
+ }
+ });
+
+ const copy = document.createElement('button');
+ copy.type = 'button';
+ copy.className = 'px-2 py-0.5 rounded border border-gray-300 dark:border-gray-600 text-[11px] text-gray-600 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700';
+ copy.textContent = '{% if lang == "fa" %}کپی{% else %}Copy{% endif %}';
+ copy.addEventListener('click', () => {
+ navigator.clipboard.writeText(m.id);
+ copy.textContent = '{% if lang == "fa" %}کپی شد{% else %}Copied{% endif %}';
+ setTimeout(() => { copy.textContent = '{% if lang == "fa" %}کپی{% else %}Copy{% endif %}'; }, 1500);
+ });
+
+ actions.appendChild(defaultBtn);
+ actions.appendChild(copy);
+ li.appendChild(info);
+ li.appendChild(actions);
+ return li;
+ };
+ for (const m of models) list.appendChild(renderRow(m));
+ } catch (e) {
+ placeholder.textContent = e.message || String(e);
+ }
+ }
+
+ document.querySelectorAll('details[data-site-ai-models]').forEach((d) => {
+ d.addEventListener('toggle', () => {
+ if (d.open) _loadOpenRouterModels(d);
+ });
+ });
+
+ window.__siteAiKeys = { setKey, deleteKey };
+ })();
+ {% endif %}
+
+ // F.X.fix #9: Re-check is now an HTMX partial swap — see the
+ // hx-get attribute on the button in _capability_badge.html. No JS
+ // shim needed here any more.
+
// ── Init ────────────────────────────────────────────────────────
document.addEventListener('DOMContentLoaded', () => {
loadToolAccess();
diff --git a/core/tool_access.py b/core/tool_access.py
index f4e9f2e..d0f471a 100644
--- a/core/tool_access.py
+++ b/core/tool_access.py
@@ -403,6 +403,11 @@ class ToolAccessManager:
tools = self.apply_scope_filter(tools, [site_scope], plugin_type=plugin_type)
tools = await self.apply_site_toggles(tools, site_id)
+ # F.X.fix-pass3: filter out tools whose central prerequisites
+ # are unmet (no AI provider key, missing companion route,
+ # missing SEO plugin) so the live endpoint never advertises a
+ # tool that would 100% fail at call time.
+ tools = await self.apply_prerequisites_filter(tools, site_id)
return tools
async def toggle_tool(
@@ -488,6 +493,19 @@ class ToolAccessManager:
Used by the dashboard API to present the per-site management view.
Does not apply scope filters — the UI decides what to show.
+ F.X.fix #8: each row also carries ``provider_key_required`` and
+ ``provider_key_configured`` so the Tool Access template can
+ render AI tools greyed + "Configure key" CTA when the site has
+ no keys for the required provider, instead of letting the user
+ enable a tool that errors at call time with ``NO_PROVIDER_KEY``.
+
+ F.X.fix-pass3: rows also carry ``available`` + ``unavailable_reason``
+ derived from a central :data:`_TOOL_PREREQUISITES` resolver so
+ the UI can auto-grey/disable tools whose requirements (companion
+ route, SEO plugin, AI key) the site doesn't satisfy. This is the
+ same data the live MCP endpoint uses to silently filter the
+ catalog (see :meth:`get_visible_tools`).
+
Args:
site_id: Site UUID.
plugin_type: Plugin type.
@@ -504,20 +522,235 @@ class ToolAccessManager:
except RuntimeError:
toggles = {}
+ try:
+ from core.site_api import list_site_providers_set
+
+ configured_providers = await list_site_providers_set(site_id)
+ except Exception: # noqa: BLE001
+ configured_providers = set()
+
+ # F.X.fix-pass3: pull the cached probe payload so we can
+ # evaluate prerequisites for SEO / companion-route tools.
+ # ``probe_site_capabilities`` reuses its 10-min cache so the
+ # cost is one SQLite query in the common path.
+ probe_payload: dict[str, Any] | None = None
+ try:
+ from core.capability_probe import get_probe_cache
+
+ cached = get_probe_cache().get(site_id)
+ if cached is not None:
+ probe_payload = cached
+ except Exception: # noqa: BLE001
+ probe_payload = None
+
registry = get_tool_registry()
tools = registry.get_by_plugin_type(plugin_type)
- return [
- {
- "name": t.name,
- "description": t.description,
- "plugin_type": t.plugin_type,
- "category": t.category,
- "sensitivity": t.sensitivity,
- "required_scope": t.required_scope,
- "enabled": toggles.get(t.name, True),
- }
- for t in tools
- ]
+ rows: list[dict[str, Any]] = []
+ for t in tools:
+ available, reason = check_tool_prerequisites(
+ t.name,
+ probe_payload=probe_payload,
+ configured_providers=configured_providers,
+ )
+ rows.append(
+ {
+ "name": t.name,
+ "description": t.description,
+ "plugin_type": t.plugin_type,
+ "category": t.category,
+ "sensitivity": t.sensitivity,
+ "required_scope": t.required_scope,
+ "enabled": toggles.get(t.name, True),
+ "provider_key_required": _tool_requires_provider_key(t.name),
+ "provider_key_configured": _tool_has_configured_provider(
+ t.name, configured_providers
+ ),
+ "available": available,
+ "unavailable_reason": reason,
+ }
+ )
+ return rows
+
+ async def apply_prerequisites_filter(
+ self,
+ tools: list[ToolDefinition],
+ site_id: str,
+ ) -> list[ToolDefinition]:
+ """Drop tools whose central prerequisites are not satisfied.
+
+ Mirrors :meth:`list_tools_for_site` but returns ``ToolDefinition``
+ objects so the live MCP endpoint pipeline (``get_visible_tools``)
+ can call it inline. Reads the same cached probe + provider-key
+ set so a fresh probe never gets triggered from this hot path.
+ """
+ try:
+ from core.site_api import list_site_providers_set
+
+ configured_providers = await list_site_providers_set(site_id)
+ except Exception: # noqa: BLE001
+ configured_providers = set()
+
+ probe_payload: dict[str, Any] | None = None
+ try:
+ from core.capability_probe import get_probe_cache
+
+ cached = get_probe_cache().get(site_id)
+ if cached is not None:
+ probe_payload = cached
+ except Exception: # noqa: BLE001
+ probe_payload = None
+
+ kept: list[ToolDefinition] = []
+ for tool in tools:
+ available, _ = check_tool_prerequisites(
+ tool.name,
+ probe_payload=probe_payload,
+ configured_providers=configured_providers,
+ )
+ if available:
+ kept.append(tool)
+ return kept
+
+
+# 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.
+_PROVIDER_KEY_REQUIRED_TOOLS: frozenset[str] = frozenset({"wordpress_generate_and_upload_image"})
+
+
+def _tool_requires_provider_key(tool_name: str) -> bool:
+ return tool_name in _PROVIDER_KEY_REQUIRED_TOOLS
+
+
+def _tool_has_configured_provider(tool_name: str, configured: set[str]) -> bool:
+ """``True`` iff the site has at least one provider key for this tool.
+
+ The AI image tool is happy with ANY supported provider key (the
+ caller picks which one at call time via the ``provider`` arg) so
+ any non-empty configured set counts.
+ """
+ if not _tool_requires_provider_key(tool_name):
+ return True
+ return bool(configured)
+
+
+# ── F.X.fix-pass3: central tool-prerequisites resolver ───────────────
+# Single source of truth for "this tool needs X to work". The resolver
+# below decides per-call whether a tool is *available* on a given site
+# given the cached probe payload + the site's configured provider keys.
+# Three predicate kinds:
+#
+# provider_key — site needs at least one of the listed AI provider
+# keys.
+# companion_route — the companion plugin must advertise the named
+# route in probe.routes.
+# feature_any — the WP probe features must include at least one of
+# the listed feature names (e.g. rank_math / yoast).
+#
+# The resolver's output (``available`` + ``unavailable_reason``) is
+# attached to every tool row in ``list_tools_for_site`` AND used by
+# ``apply_prerequisites_filter`` to drop unavailable tools from the
+# live MCP endpoint, so models calling the endpoint never see a tool
+# that is guaranteed to fail at call time.
+
+_TOOL_PREREQUISITES: dict[str, list[dict[str, Any]]] = {
+ # AI image — any provider key is enough; the caller picks at call time.
+ "wordpress_generate_and_upload_image": [
+ {
+ "kind": "provider_key",
+ "any_of": ["openai", "stability", "replicate", "openrouter"],
+ }
+ ],
+ # Companion-route-backed WP tools.
+ "wordpress_cache_purge": [{"kind": "companion_route", "name": "cache_purge"}],
+ "wordpress_bulk_update_meta": [{"kind": "companion_route", "name": "bulk_meta"}],
+ "wordpress_export_content": [{"kind": "companion_route", "name": "export"}],
+ "wordpress_site_health": [{"kind": "companion_route", "name": "site_health"}],
+ "wordpress_transient_flush": [{"kind": "companion_route", "name": "transient_flush"}],
+ "wordpress_audit_hook_status": [{"kind": "companion_route", "name": "audit_hook"}],
+ "wordpress_audit_hook_configure": [{"kind": "companion_route", "name": "audit_hook"}],
+ "wordpress_audit_hook_disable": [{"kind": "companion_route", "name": "audit_hook"}],
+ "wordpress_regenerate_thumbnails": [
+ {"kind": "companion_route", "name": "regenerate_thumbnails"}
+ ],
+ "wordpress_bulk_delete_media": [{"kind": "companion_route", "name": "bulk_meta"}],
+ "wordpress_bulk_reassign_media": [{"kind": "companion_route", "name": "bulk_meta"}],
+ # SEO — needs Rank Math (or Yoast in the future). Probe surfaces
+ # both flags; we accept either.
+ "wordpress_get_post_seo": [{"kind": "feature_any", "names": ["rank_math", "yoast"]}],
+ "wordpress_update_post_seo": [{"kind": "feature_any", "names": ["rank_math", "yoast"]}],
+ "wordpress_get_product_seo": [{"kind": "feature_any", "names": ["rank_math", "yoast"]}],
+ "wordpress_update_product_seo": [{"kind": "feature_any", "names": ["rank_math", "yoast"]}],
+ "wordpress_get_internal_links": [{"kind": "feature_any", "names": ["rank_math", "yoast"]}],
+ # F.X.fix-pass5 — WooCommerce media tools hit /wp/v2/media which
+ # WC consumer_key + secret can't authenticate. Need a WP App
+ # Password (wp_username + wp_app_password fields, or legacy
+ # username + app_password single-credential mode).
+ "woocommerce_attach_media_to_product": [{"kind": "wp_credentials"}],
+ "woocommerce_upload_and_attach_to_product": [{"kind": "wp_credentials"}],
+ "woocommerce_set_featured_image": [{"kind": "wp_credentials"}],
+ # AI image, exposed on both WP and WC plugins. The WC variant
+ # needs both an AI provider key AND WP credentials (to upload
+ # /wp/v2/media); the WP variant only needs the provider key
+ # since its primary client already uses Application Password.
+ "woocommerce_generate_and_upload_image": [
+ {"kind": "provider_key", "any_of": ["openai", "stability", "replicate", "openrouter"]},
+ {"kind": "wp_credentials"},
+ ],
+}
+
+
+def check_tool_prerequisites(
+ tool_name: str,
+ *,
+ probe_payload: dict[str, Any] | None,
+ configured_providers: set[str],
+) -> tuple[bool, str | None]:
+ """Decide whether a tool's prerequisites are satisfied.
+
+ Returns ``(available, unavailable_reason)``. ``unavailable_reason``
+ is one of ``provider_key`` | ``companion_route`` | ``feature``
+ | ``probe_unknown`` when the tool is unavailable, else ``None``.
+
+ A tool with no prerequisites declared is always available — most
+ of the catalog (plain CRUD over WP REST) needs no companion / no
+ AI key, so the default of "no entry → True" keeps the rule list
+ short.
+ """
+ rules = _TOOL_PREREQUISITES.get(tool_name)
+ if not rules:
+ return True, None
+
+ routes = ((probe_payload or {}).get("routes")) or {}
+ features = ((probe_payload or {}).get("features")) or {}
+
+ for rule in rules:
+ kind = rule.get("kind")
+ if kind == "provider_key":
+ allowed = set(rule.get("any_of") or [])
+ if allowed and not (allowed & configured_providers):
+ return False, "provider_key"
+ elif kind == "companion_route":
+ name = rule.get("name") or ""
+ # Probe is authoritative when present; absence of probe data
+ # (e.g. companion not installed → companion_available=False)
+ # is also treated as "unavailable".
+ if not routes.get(name):
+ return False, "companion_route"
+ elif kind == "feature_any":
+ names = list(rule.get("names") or [])
+ # WP companion's features dict carries booleans like
+ # rank_math: true / yoast: false. WC has no analogous gate.
+ if not any(features.get(n) for n in names):
+ return False, "feature"
+ elif kind == "wp_credentials":
+ # F.X.fix-pass5 — WC media tools and the WC AI image tool
+ # need a WP Application Password to authenticate
+ # /wp/v2/media. The WC plugin probe surfaces a
+ # ``wp_credentials_present`` flag; absence is a hard fail.
+ if not (probe_payload or {}).get("wp_credentials_present"):
+ return False, "wp_credentials"
+
+ return True, None
# Singleton
diff --git a/core/tool_rate_limiter.py b/core/tool_rate_limiter.py
new file mode 100644
index 0000000..af02431
--- /dev/null
+++ b/core/tool_rate_limiter.py
@@ -0,0 +1,122 @@
+"""Per-tool, per-user rate limiting (F.5a.6).
+
+Provides a small token-bucket limiter keyed by (user_id, tool_name) for
+expensive tools such as AI image generation and chunked-upload finish.
+Admin / env-fallback callers (``user_id is None``) are exempt.
+
+Intentionally minimal — separate from :mod:`core.rate_limiter` (which is a
+global per-client limiter). This one is scoped to specific tools with
+small hourly caps.
+"""
+
+from __future__ import annotations
+
+import logging
+import threading
+from dataclasses import dataclass
+
+from core.rate_limiter import TokenBucket
+
+logger = logging.getLogger(__name__)
+
+
+# Default caps (per hour per user). Documented in ROADMAP F.5a.6.
+DEFAULT_LIMITS: dict[str, int] = {
+ "wordpress_generate_and_upload_image": 10,
+ "wordpress_upload_media_chunked_finish": 30,
+}
+
+
+@dataclass
+class ToolRateLimitError(Exception):
+ """Raised when a per-tool limit is exceeded."""
+
+ tool_name: str
+ limit_per_hour: int
+ retry_after_seconds: float
+
+ def __post_init__(self) -> None:
+ super().__init__(
+ f"Per-tool rate limit exceeded for '{self.tool_name}' "
+ f"({self.limit_per_hour}/hour per user). "
+ f"Retry in {self.retry_after_seconds:.0f}s."
+ )
+
+ def to_dict(self) -> dict:
+ return {
+ "error_code": "TOOL_RATE_LIMITED",
+ "message": str(self),
+ "details": {
+ "tool": self.tool_name,
+ "limit_per_hour": self.limit_per_hour,
+ "retry_after_seconds": round(self.retry_after_seconds, 2),
+ },
+ }
+
+
+class PerToolRateLimiter:
+ """Token-bucket limiter keyed by (user_id, tool_name).
+
+ ``check(tool, user_id)`` consumes one token and raises
+ :class:`ToolRateLimitError` when the user is over quota. Admin / env
+ callers (``user_id`` is None or empty) are exempt.
+ """
+
+ def __init__(self, limits: dict[str, int] | None = None) -> None:
+ self._limits = dict(limits if limits is not None else DEFAULT_LIMITS)
+ self._buckets: dict[tuple[str, str], TokenBucket] = {}
+ self._lock = threading.Lock()
+
+ def configure(self, tool_name: str, per_hour: int) -> None:
+ """Override the per-hour cap for a tool."""
+ self._limits[tool_name] = per_hour
+ # Existing buckets keep their old capacity until reset — tests can
+ # reset() to re-read the new limit.
+
+ def reset(self) -> None:
+ with self._lock:
+ self._buckets.clear()
+
+ def check(self, tool_name: str, user_id: str | None) -> None:
+ """Consume one token for (user_id, tool_name). Exempt when user_id is falsy."""
+ if not user_id:
+ return
+ limit = self._limits.get(tool_name)
+ if limit is None or limit <= 0:
+ return
+
+ key = (user_id, tool_name)
+ with self._lock:
+ bucket = self._buckets.get(key)
+ if bucket is None:
+ bucket = TokenBucket(capacity=limit, refill_rate=limit / 3600.0)
+ self._buckets[key] = bucket
+
+ if not bucket.consume(1):
+ wait = bucket.get_wait_time(1)
+ logger.warning(
+ "Per-tool rate limit hit: user=%s tool=%s limit=%d/h retry_after=%.1fs",
+ user_id,
+ tool_name,
+ limit,
+ wait,
+ )
+ raise ToolRateLimitError(
+ tool_name=tool_name, limit_per_hour=limit, retry_after_seconds=wait
+ )
+
+
+_limiter: PerToolRateLimiter | None = None
+
+
+def get_tool_rate_limiter() -> PerToolRateLimiter:
+ global _limiter
+ if _limiter is None:
+ _limiter = PerToolRateLimiter()
+ return _limiter
+
+
+def set_tool_rate_limiter(limiter: PerToolRateLimiter | None) -> None:
+ """Override the singleton (used by tests)."""
+ global _limiter
+ _limiter = limiter
diff --git a/core/upload_sessions.py b/core/upload_sessions.py
new file mode 100644
index 0000000..444037a
--- /dev/null
+++ b/core/upload_sessions.py
@@ -0,0 +1,474 @@
+"""Chunked upload session store (F.5a.5).
+
+Server-side buffering for large media uploads: SQLite metadata + disk spill.
+Sessions are deterministic (same user+file metadata yields the same
+session_id), enabling resumable uploads. Enforces per-user concurrency
+quota, per-session byte cap, and a 1h TTL reaped by a background task.
+
+Chunks are appended sequentially; out-of-order or duplicate indexes raise
+a typed error. Optional full-payload sha256 (supplied at `start`) is
+verified when the session is finalized.
+"""
+
+from __future__ import annotations
+
+import asyncio
+import hashlib
+import logging
+import os
+import uuid
+from datetime import UTC, datetime, timedelta
+from pathlib import Path
+from typing import Any
+
+from core.database import Database, get_database
+
+logger = logging.getLogger(__name__)
+
+
+# --- Config ----------------------------------------------------------------
+
+DEFAULT_SPILL_DIR = Path(os.environ.get("MCPHUB_UPLOAD_SPILL_DIR", "/tmp/mcphub-uploads"))
+SESSION_TTL = timedelta(seconds=int(os.environ.get("MCPHUB_UPLOAD_TTL_SEC", "3600")))
+MAX_SESSION_BYTES = int(os.environ.get("MCPHUB_UPLOAD_MAX_BYTES", str(500 * 1024 * 1024)))
+MAX_CONCURRENT_PER_USER = int(os.environ.get("MCPHUB_UPLOAD_MAX_CONCURRENT", "10"))
+
+
+# --- Errors ----------------------------------------------------------------
+
+
+class UploadSessionError(Exception):
+ """Typed session error with stable JSON code."""
+
+ def __init__(self, code: str, message: str, details: dict | None = None):
+ super().__init__(message)
+ self.code = code
+ self.message = message
+ self.details = details or {}
+
+ def to_dict(self) -> dict:
+ return {"error_code": self.code, "message": self.message, "details": self.details}
+
+
+# --- Helpers ---------------------------------------------------------------
+
+
+def _utc_now() -> datetime:
+ return datetime.now(UTC)
+
+
+def _iso(dt: datetime) -> str:
+ return dt.isoformat()
+
+
+def _parse_iso(value: str) -> datetime:
+ dt = datetime.fromisoformat(value)
+ if dt.tzinfo is None:
+ dt = dt.replace(tzinfo=UTC)
+ return dt
+
+
+def make_session_id(
+ user_id: str,
+ filename: str,
+ total_bytes: int,
+ mime: str | None,
+ sha256: str | None,
+) -> str:
+ """Deterministic session id — same tuple → same id (enables resume)."""
+ payload = f"{user_id}|{filename}|{total_bytes}|{mime or ''}|{sha256 or ''}"
+ return hashlib.sha256(payload.encode("utf-8")).hexdigest()[:32]
+
+
+def _ensure_spill_dir(spill_dir: Path) -> None:
+ spill_dir.mkdir(parents=True, exist_ok=True)
+ try:
+ os.chmod(spill_dir, 0o700)
+ except OSError:
+ pass
+
+
+# --- Store -----------------------------------------------------------------
+
+
+class UploadSession:
+ """Runtime view of an upload session row."""
+
+ __slots__ = (
+ "id",
+ "user_id",
+ "filename",
+ "total_bytes",
+ "mime",
+ "sha256",
+ "received_bytes",
+ "next_chunk",
+ "spill_path",
+ "status",
+ "created_at",
+ "expires_at",
+ )
+
+ def __init__(self, **kwargs: Any) -> None:
+ for name in self.__slots__:
+ setattr(self, name, kwargs.get(name))
+
+ @classmethod
+ def from_row(cls, row: dict[str, Any]) -> UploadSession:
+ return cls(
+ id=row["id"],
+ user_id=row["user_id"],
+ filename=row["filename"],
+ total_bytes=int(row["total_bytes"]),
+ mime=row["mime"],
+ sha256=row["sha256"],
+ received_bytes=int(row["received_bytes"]),
+ next_chunk=int(row["next_chunk"]),
+ spill_path=Path(row["spill_path"]),
+ status=row["status"],
+ created_at=_parse_iso(row["created_at"]),
+ expires_at=_parse_iso(row["expires_at"]),
+ )
+
+ def to_public_dict(self) -> dict[str, Any]:
+ return {
+ "session_id": self.id,
+ "filename": self.filename,
+ "total_bytes": self.total_bytes,
+ "received_bytes": self.received_bytes,
+ "next_chunk": self.next_chunk,
+ "status": self.status,
+ "expires_at": _iso(self.expires_at),
+ }
+
+
+class UploadSessionStore:
+ """Persistent chunk-upload session store with disk spill."""
+
+ def __init__(
+ self,
+ *,
+ db: Database | None = None,
+ spill_dir: Path | None = None,
+ ttl: timedelta = SESSION_TTL,
+ max_session_bytes: int = MAX_SESSION_BYTES,
+ max_concurrent_per_user: int = MAX_CONCURRENT_PER_USER,
+ ) -> None:
+ self._db = db
+ self.spill_dir = Path(spill_dir or DEFAULT_SPILL_DIR)
+ self.ttl = ttl
+ self.max_session_bytes = max_session_bytes
+ self.max_concurrent_per_user = max_concurrent_per_user
+ self._lock = asyncio.Lock()
+ _ensure_spill_dir(self.spill_dir)
+
+ @property
+ def db(self) -> Database:
+ return self._db or get_database()
+
+ # -- start -------------------------------------------------------------
+
+ async def start(
+ self,
+ *,
+ user_id: str,
+ filename: str,
+ total_bytes: int,
+ mime: str | None = None,
+ sha256: str | None = None,
+ ) -> UploadSession:
+ if total_bytes <= 0:
+ raise UploadSessionError("BAD_SIZE", "total_bytes must be positive.")
+ if total_bytes > self.max_session_bytes:
+ raise UploadSessionError(
+ "SESSION_TOO_LARGE",
+ f"total_bytes {total_bytes} exceeds limit {self.max_session_bytes}.",
+ {"max": self.max_session_bytes},
+ )
+
+ session_id = make_session_id(user_id, filename, total_bytes, mime, sha256)
+
+ async with self._lock:
+ existing = await self._get_row(session_id)
+ if existing is not None:
+ sess = UploadSession.from_row(existing)
+ if sess.status == "open" and sess.expires_at > _utc_now():
+ return sess
+ # Stale/finished — replace
+ await self._delete_row(session_id)
+ _unlink_silent(sess.spill_path)
+
+ open_count = await self._count_open_for_user(user_id)
+ if open_count >= self.max_concurrent_per_user:
+ raise UploadSessionError(
+ "QUOTA_EXCEEDED",
+ f"User has {open_count} open upload sessions "
+ f"(max {self.max_concurrent_per_user}).",
+ {"open": open_count, "max": self.max_concurrent_per_user},
+ )
+
+ now = _utc_now()
+ expires = now + self.ttl
+ spill_path = self.spill_dir / f"{session_id}.part"
+ # Touch an empty spill file with 0600 perms
+ _ensure_spill_dir(self.spill_dir)
+ with open(spill_path, "wb") as f:
+ f.truncate(0)
+ try:
+ os.chmod(spill_path, 0o600)
+ except OSError:
+ pass
+
+ await self.db.execute(
+ "INSERT INTO upload_sessions "
+ "(id, user_id, filename, total_bytes, mime, sha256, "
+ " received_bytes, next_chunk, spill_path, status, "
+ " created_at, expires_at) "
+ "VALUES (?, ?, ?, ?, ?, ?, 0, 0, ?, 'open', ?, ?)",
+ (
+ session_id,
+ user_id,
+ filename,
+ total_bytes,
+ mime,
+ sha256,
+ str(spill_path),
+ _iso(now),
+ _iso(expires),
+ ),
+ )
+ return UploadSession(
+ id=session_id,
+ user_id=user_id,
+ filename=filename,
+ total_bytes=total_bytes,
+ mime=mime,
+ sha256=sha256,
+ received_bytes=0,
+ next_chunk=0,
+ spill_path=spill_path,
+ status="open",
+ created_at=now,
+ expires_at=expires,
+ )
+
+ # -- append chunk ------------------------------------------------------
+
+ async def append_chunk(
+ self,
+ session_id: str,
+ index: int,
+ data: bytes,
+ *,
+ chunk_sha256: str | None = None,
+ ) -> UploadSession:
+ async with self._lock:
+ sess = await self._require_open(session_id)
+ if index != sess.next_chunk:
+ raise UploadSessionError(
+ "CHUNK_ORDER",
+ f"Expected chunk index {sess.next_chunk}, got {index}.",
+ {"expected": sess.next_chunk, "got": index},
+ )
+ new_size = sess.received_bytes + len(data)
+ if new_size > sess.total_bytes:
+ raise UploadSessionError(
+ "CHUNK_OVERFLOW",
+ f"Chunk would exceed declared total_bytes "
+ f"({new_size} > {sess.total_bytes}).",
+ {"declared": sess.total_bytes, "would_be": new_size},
+ )
+ if chunk_sha256 is not None:
+ actual = hashlib.sha256(data).hexdigest()
+ if actual.lower() != chunk_sha256.lower():
+ raise UploadSessionError(
+ "CHUNK_CHECKSUM",
+ "Chunk sha256 does not match supplied value.",
+ {"expected": chunk_sha256, "actual": actual, "index": index},
+ )
+
+ with open(sess.spill_path, "ab") as f:
+ f.write(data)
+ sess.received_bytes = new_size
+ sess.next_chunk = index + 1
+ await self.db.execute(
+ "UPDATE upload_sessions SET received_bytes = ?, next_chunk = ? WHERE id = ?",
+ (sess.received_bytes, sess.next_chunk, session_id),
+ )
+ return sess
+
+ # -- finalize ----------------------------------------------------------
+
+ async def finalize(self, session_id: str) -> tuple[UploadSession, bytes]:
+ """Read the full spill file and verify. Returns (session, bytes).
+
+ On checksum mismatch the session is kept (status remains 'open') so
+ the caller can retry; on success, the row and spill file are removed.
+ """
+ async with self._lock:
+ sess = await self._require_open(session_id)
+ if sess.received_bytes != sess.total_bytes:
+ raise UploadSessionError(
+ "INCOMPLETE",
+ f"Received {sess.received_bytes}/{sess.total_bytes} bytes.",
+ {
+ "received": sess.received_bytes,
+ "total": sess.total_bytes,
+ },
+ )
+ with open(sess.spill_path, "rb") as f:
+ data = f.read()
+ if sess.sha256:
+ actual = hashlib.sha256(data).hexdigest()
+ if actual.lower() != sess.sha256.lower():
+ raise UploadSessionError(
+ "CHECKSUM_MISMATCH",
+ "Assembled sha256 does not match value supplied at start.",
+ {"expected": sess.sha256, "actual": actual},
+ )
+ # Success — drop the session
+ await self._delete_row(session_id)
+ _unlink_silent(sess.spill_path)
+ return sess, data
+
+ # -- abort -------------------------------------------------------------
+
+ async def abort(self, session_id: str) -> bool:
+ async with self._lock:
+ row = await self._get_row(session_id)
+ if row is None:
+ return False
+ sess = UploadSession.from_row(row)
+ await self._delete_row(session_id)
+ _unlink_silent(sess.spill_path)
+ return True
+
+ # -- get ---------------------------------------------------------------
+
+ async def get(self, session_id: str) -> UploadSession | None:
+ row = await self._get_row(session_id)
+ return UploadSession.from_row(row) if row else None
+
+ # -- cleanup -----------------------------------------------------------
+
+ async def cleanup_expired(self, *, now: datetime | None = None) -> int:
+ now = now or _utc_now()
+ rows = await self.db.fetchall(
+ "SELECT * FROM upload_sessions WHERE expires_at < ?", (_iso(now),)
+ )
+ reaped = 0
+ for row in rows:
+ sess = UploadSession.from_row(row)
+ _unlink_silent(sess.spill_path)
+ await self._delete_row(sess.id)
+ reaped += 1
+ if reaped:
+ logger.info("Reaped %d expired upload session(s)", reaped)
+ return reaped
+
+ # -- internals ---------------------------------------------------------
+
+ async def _get_row(self, session_id: str) -> dict[str, Any] | None:
+ return await self.db.fetchone("SELECT * FROM upload_sessions WHERE id = ?", (session_id,))
+
+ async def _delete_row(self, session_id: str) -> None:
+ await self.db.execute("DELETE FROM upload_sessions WHERE id = ?", (session_id,))
+
+ async def _count_open_for_user(self, user_id: str) -> int:
+ row = await self.db.fetchone(
+ "SELECT COUNT(*) AS c FROM upload_sessions "
+ "WHERE user_id = ? AND status = 'open' AND expires_at > ?",
+ (user_id, _iso(_utc_now())),
+ )
+ return int(row["c"]) if row else 0
+
+ async def _require_open(self, session_id: str) -> UploadSession:
+ row = await self._get_row(session_id)
+ if row is None:
+ raise UploadSessionError(
+ "NO_SESSION", f"Session {session_id} not found.", {"session_id": session_id}
+ )
+ sess = UploadSession.from_row(row)
+ if sess.status != "open":
+ raise UploadSessionError(
+ "BAD_STATE",
+ f"Session {session_id} is in state '{sess.status}'.",
+ {"state": sess.status},
+ )
+ if sess.expires_at <= _utc_now():
+ raise UploadSessionError(
+ "EXPIRED", f"Session {session_id} has expired.", {"session_id": session_id}
+ )
+ return sess
+
+
+def _unlink_silent(path: Path) -> None:
+ try:
+ path.unlink(missing_ok=True)
+ except OSError as e:
+ logger.debug("Could not remove spill file %s: %s", path, e)
+
+
+# --- Singleton + background cleanup ---------------------------------------
+
+_store: UploadSessionStore | None = None
+
+
+def get_upload_session_store() -> UploadSessionStore:
+ global _store
+ if _store is None:
+ _store = UploadSessionStore()
+ return _store
+
+
+def set_upload_session_store(store: UploadSessionStore | None) -> None:
+ """Override the singleton (used by tests)."""
+ global _store
+ _store = store
+
+
+class CleanupTask:
+ """Periodically reaps expired sessions. Register in server lifespan."""
+
+ def __init__(
+ self,
+ store: UploadSessionStore | None = None,
+ interval_seconds: int = 300,
+ ) -> None:
+ self._store = store
+ self.interval = interval_seconds
+ self._task: asyncio.Task | None = None
+ self._stop = asyncio.Event()
+ self.id = uuid.uuid4().hex[:8]
+
+ @property
+ def store(self) -> UploadSessionStore:
+ return self._store or get_upload_session_store()
+
+ async def start(self) -> None:
+ if self._task is not None:
+ return
+ self._stop.clear()
+ self._task = asyncio.create_task(self._run(), name=f"upload-cleanup-{self.id}")
+
+ async def stop(self) -> None:
+ if self._task is None:
+ return
+ self._stop.set()
+ self._task.cancel()
+ try:
+ await self._task
+ except (asyncio.CancelledError, Exception):
+ pass
+ self._task = None
+
+ async def _run(self) -> None:
+ while not self._stop.is_set():
+ try:
+ await self.store.cleanup_expired()
+ except Exception as e: # noqa: BLE001
+ logger.warning("Upload-session cleanup error: %s", e)
+ try:
+ await asyncio.wait_for(self._stop.wait(), timeout=self.interval)
+ except TimeoutError:
+ continue
diff --git a/core/user_endpoints.py b/core/user_endpoints.py
index f1ae4df..21d4e7d 100644
--- a/core/user_endpoints.py
+++ b/core/user_endpoints.py
@@ -70,12 +70,27 @@ def _check_user_rate_limit(user_id: str) -> tuple[bool, str]:
return True, ""
-def _tools_to_mcp_schema(tools: list[ToolDefinition]) -> list[dict[str, Any]]:
+def _tools_to_mcp_schema(
+ tools: list[ToolDefinition],
+ *,
+ configured_providers: list[str] | None = None,
+) -> list[dict[str, Any]]:
"""Convert ToolDefinition objects into MCP ``tools/list`` response shape.
Strips the auto-injected ``site`` parameter, since user endpoints bind a
single site per alias.
+
+ F.X.fix-pass6 — when ``configured_providers`` is given (i.e. the
+ site has at least one AI provider key configured), narrow the
+ ``provider`` enum on the AI image tool to that subset. The model
+ only sees providers that will actually succeed, instead of trying
+ OpenAI / Stability / Replicate and getting NO_PROVIDER_KEY when
+ only OpenRouter is configured.
"""
+ ai_image_tools = {
+ "wordpress_generate_and_upload_image",
+ "woocommerce_generate_and_upload_image",
+ }
result = []
for tool_def in tools:
schema = deepcopy(tool_def.input_schema)
@@ -84,6 +99,22 @@ def _tools_to_mcp_schema(tools: list[ToolDefinition]) -> list[dict[str, Any]]:
if "required" in schema and "site" in schema["required"]:
schema["required"] = [r for r in schema["required"] if r != "site"]
+ if (
+ configured_providers
+ and tool_def.name in ai_image_tools
+ and "properties" in schema
+ and "provider" in schema["properties"]
+ ):
+ schema["properties"]["provider"] = {
+ **schema["properties"]["provider"],
+ "enum": list(configured_providers),
+ "description": (
+ "AI provider to use. This site has the following providers "
+ f"configured: {', '.join(configured_providers)}. Add more "
+ "in Connection Settings → AI Image Generation."
+ ),
+ }
+
result.append(
{
"name": tool_def.name,
@@ -99,14 +130,41 @@ async def _get_visible_tools_for_site(
key_scopes: list[str],
plugin_type: str,
) -> list[dict[str, Any]]:
- """Return tools/list payload filtered by key scope + site scope + toggles (F.7b)."""
+ """Return tools/list payload filtered by key scope + site scope + toggles (F.7b).
+
+ F.5a.9.x: additionally hide ``wordpress_generate_and_upload_image`` when
+ the site has no provider API key configured — the tool would fail at
+ call-time with ``NO_PROVIDER_KEY`` anyway, so hiding it keeps the
+ surface honest for AI clients.
+ """
from core.tool_access import get_tool_access_manager
access = get_tool_access_manager()
tools = await access.get_visible_tools(
site_id=site_id, key_scopes=key_scopes, plugin_type=plugin_type
)
- return _tools_to_mcp_schema(tools)
+
+ configured_providers: list[str] = []
+ if plugin_type in {"wordpress", "woocommerce"}:
+ from core.site_api import list_site_providers_set
+
+ configured = await list_site_providers_set(site_id)
+ if not configured:
+ tools = [
+ t
+ for t in tools
+ if t.name
+ not in {
+ "wordpress_generate_and_upload_image",
+ "woocommerce_generate_and_upload_image",
+ }
+ ]
+ else:
+ # F.X.fix-pass6 — pass the configured set so the AI image
+ # tool's `provider` enum can be narrowed at /tools/list time.
+ configured_providers = sorted(configured)
+
+ return _tools_to_mcp_schema(tools, configured_providers=configured_providers)
async def _execute_tool(
@@ -466,10 +524,16 @@ async def user_mcp_handler(request: Request) -> Response:
)
# Build config dict for plugin instantiation
+ # F.5a.4: pass user_id so plugins can look up per-user secrets.
+ # F.5a.9.x: pass site_id so the WP AI-media handler can resolve
+ # per-site provider API keys (replaces per-user keys for
+ # wordpress_generate_and_upload_image).
config_dict = {
"site_url": site["url"],
"url": site["url"],
"alias": alias,
+ "user_id": user_id,
+ "site_id": site["id"],
**credentials,
}
diff --git a/docker-compose.coolify.mirror.yaml b/docker-compose.coolify.mirror.yaml
new file mode 100644
index 0000000..1614af8
--- /dev/null
+++ b/docker-compose.coolify.mirror.yaml
@@ -0,0 +1,84 @@
+# ===================================
+# 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
diff --git a/docker-compose.coolify.slim.yaml b/docker-compose.coolify.slim.yaml
new file mode 100644
index 0000000..7144139
--- /dev/null
+++ b/docker-compose.coolify.slim.yaml
@@ -0,0 +1,94 @@
+# ===================================
+# MCP Hub — Docker Compose (Coolify, Debian-slim Plan-B variant)
+# ===================================
+#
+# Plan B for restrictive networks. Use this when even
+# docker-compose.coolify.mirror.yaml hangs at `apk add` because the build
+# host cannot reach Alpine's package CDN (dl-cdn.alpinelinux.org). This
+# variant uses Debian slim, whose apt mirrors are CDN-fronted and usually
+# reachable from networks that block Alpine's CDN.
+#
+# WHEN TO USE:
+# - registry-1.docker.io fails AND
+# - Alpine apk repos (even via a Yandex mirror swap) hang or fail
+#
+# WHEN TO STOP USING:
+# - Once one of the smaller images works reliably (Alpine = smaller)
+# - Image bytes change (slim ≠ alpine), so the persistent volumes
+# (mcp-data, mcp-logs) carry over but the build cache resets.
+#
+# OPTIONAL HTTP PROXY (Coolify build-args): same as the .mirror variant —
+# BUILD_HTTP_PROXY=http://proxy.host:port
+# BUILD_HTTPS_PROXY=http://proxy.host:port
+# BUILD_NO_PROXY=localhost,127.0.0.1,mirror.gcr.io
+# Used only inside RUN (apt + pip), never baked into the runtime image.
+#
+# 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)
+#
+# ===================================
+
+services:
+ mcp-server:
+ build:
+ context: .
+ dockerfile: Dockerfile.slim
+ 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
diff --git a/docker-compose.coolify.yaml b/docker-compose.coolify.yaml
index f67d180..f52aaaf 100644
--- a/docker-compose.coolify.yaml
+++ b/docker-compose.coolify.yaml
@@ -10,6 +10,15 @@
# 2. Fill environment variables in Coolify UI
# 3. Set domain (e.g., mcp.yourdomain.com)
# 4. Deploy
+#
+# 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:
diff --git a/docs/getting-started.md b/docs/getting-started.md
index de3fb26..a28694f 100644
--- a/docs/getting-started.md
+++ b/docs/getting-started.md
@@ -57,7 +57,7 @@ Your personal MCP endpoint:
https://mcp.example.com/u/{your-user-id}/{alias}/mcp
```
-**WordPress users:** Install the [Airano MCP SEO Bridge](https://wordpress.org/plugins/airano-mcp-seo-bridge/) plugin for SEO tools, and create an [Application Password](https://make.wordpress.org/core/2020/11/05/application-passwords-integration-guide/) (Users → Profile) for authentication.
+**WordPress users:** Install the [Airano MCP SEO Bridge](https://wordpress.org/plugins/airano-mcp-bridge/) plugin for SEO tools, and create an [Application Password](https://make.wordpress.org/core/2020/11/05/application-passwords-integration-guide/) (Users → Profile) for authentication.
> If you prefer self-hosting, continue with the options below.
diff --git a/docs/media-error-codes.md b/docs/media-error-codes.md
new file mode 100644
index 0000000..5061f59
--- /dev/null
+++ b/docs/media-error-codes.md
@@ -0,0 +1,97 @@
+# Media Upload Error Codes (F.5a.6.2)
+
+All media-upload tools return structured JSON errors shaped as:
+
+```json
+{
+ "error_code": "TOO_LARGE",
+ "message": "File is 12345678 bytes; limit is 10485760 bytes ...",
+ "details": { "size": 12345678, "max": 10485760 }
+}
+```
+
+The `error_code` values below are **stable**: renaming or removing one is
+a breaking change. The source of truth is
+[`core/media_error_codes.py`](../core/media_error_codes.py) (the set
+`MEDIA_ERROR_CODES`) and the stability test in
+[`tests/plugins/wordpress/test_media_error_taxonomy.py`](../tests/plugins/wordpress/test_media_error_taxonomy.py).
+
+## Input / validation
+
+| Code | When it fires |
+| ----------------- | ------------------------------------------------------------------------------------- |
+| `BAD_BASE64` | The supplied base64 payload (full upload or single chunk) fails to decode. |
+| `BAD_MODE` | `mode` argument to attach tools is not `append` or `replace`. |
+| `BAD_ROLE` | `role` argument to attach tools is not `main` or `gallery`. |
+| `BAD_SIZE` | Chunked `total_bytes` is `<= 0`. |
+| `BAD_SOURCE` | Attach-upload helper given a `source` other than `base64` / `url`. |
+| `EMPTY_FILE` | Decoded payload is zero bytes. |
+| `MEDIA_NOT_FOUND` | A supplied `media_id` does not exist in the WP media library. |
+| `MIME_REJECTED` | Sniffed MIME is not in the allow-list (`ALLOWED_MIMES`). |
+| `MISSING_FIELD` | A required field is missing (e.g. `data` for base64, `url` for URL sideload). |
+| `SSRF` | URL resolves to private/loopback/link-local/metadata IP or is on the host blocklist. |
+| `TOO_LARGE` | Payload exceeds `WP_MEDIA_MAX_MB` / streamed download exceeds the byte cap. |
+| `URL_FETCH_FAILED`| Remote URL returned `>= 400` while downloading. |
+
+## WordPress REST upstream
+
+| Code | When it fires |
+| ----------------- | ------------------------------------------------------------------------------------- |
+| `WP_413` | WordPress rejected the upload with HTTP 413 (server `upload_max_filesize` too low). |
+| `WP_AUTH` | WordPress rejected auth (401/403). Application Password likely invalid/expired. |
+| `WP_BAD_RESPONSE` | Upload accepted but WP returned a non-JSON body. |
+| `WP_CREDENTIALS_MISSING` | (WC sites only) Tool needs a WP Application Password to hit `/wp/v2/media`. WC Consumer Key + Secret do not authenticate the WP core REST. Add `wp_username` + `wp_app_password` in Connection Settings → advanced. |
+| `WP_` | Any other non-2xx status from WP — e.g. `WP_400`, `WP_500`. Dynamic. |
+
+## Companion plugin `upload-chunk` route (F.5a.7)
+
+These only fire when MCPHub chose the companion `/airano-mcp/v1/upload-chunk` route
+(because the probe advertises the helper and the payload exceeds `upload_max_filesize`).
+A companion failure is **non-fatal**: MCPHub falls back to the standard `/wp/v2/media`
+route on any error here, so these codes usually surface in logs, not to end users.
+
+| Code | When it fires |
+| ------------------------ | --------------------------------------------------------------------- |
+| `COMPANION_BAD_RESPONSE` | Companion route returned 2xx but the body was not parseable JSON. |
+| `COMPANION_` | Any non-2xx status from the companion route — e.g. `COMPANION_500`. |
+
+## Chunked upload session
+
+| Code | When it fires |
+| ------------------- | ---------------------------------------------------------------------------- |
+| `BAD_STATE` | Session exists but is not in `open` state (already finalized/aborted). |
+| `CHECKSUM_MISMATCH` | Assembled sha256 does not match value supplied at `start`. |
+| `CHUNK_CHECKSUM` | Per-chunk sha256 does not match the value supplied with the chunk. |
+| `CHUNK_ORDER` | Chunk index does not match `next_chunk`. |
+| `CHUNK_OVERFLOW` | Appending this chunk would exceed declared `total_bytes`. |
+| `EXPIRED` | Session's TTL has elapsed. |
+| `INCOMPLETE` | Finalize called before all declared bytes arrived. |
+| `NO_SESSION` | `session_id` is unknown. |
+| `QUOTA_EXCEEDED` | User already has `MCPHUB_UPLOAD_MAX_CONCURRENT` open sessions. |
+| `SESSION_TOO_LARGE` | Declared `total_bytes` exceeds the hard session cap (default 500 MB). |
+
+## AI generation providers
+
+| Code | When it fires |
+| ----------------------- | ---------------------------------------------------------------------- |
+| `GENERATION_FAILED` | Generic provider failure not covered by a more specific code. |
+| `NO_PROVIDER_KEY` | No per-user key stored and no env fallback for the selected provider. |
+| `PROVIDER_AUTH` | Provider rejected the supplied API key. |
+| `PROVIDER_BAD_REQUEST` | Provider returned 4xx — prompt or size was invalid. |
+| `PROVIDER_BAD_RESPONSE` | Provider returned a 2xx with an unexpected shape. |
+| `PROVIDER_QUOTA` | Provider returned 429 / quota error. |
+| `PROVIDER_TIMEOUT` | Provider timed out. |
+| `PROVIDER_UNAVAILABLE` | Provider returned 5xx / circuit open. |
+| `PROVIDER_UNKNOWN` | `provider` argument is not one of the registered providers. |
+
+## Rate / policy
+
+| Code | When it fires |
+| -------------------- | --------------------------------------------------------------------- |
+| `TOOL_RATE_LIMITED` | Per-tool, per-user cap exceeded (see `core/tool_rate_limiter.py`). |
+
+## Catchall
+
+| Code | When it fires |
+| ---------- | ---------------------------------------------------------------------------------- |
+| `INTERNAL` | Unexpected exception reached the top of a tool handler. Details in `message`. |
diff --git a/docs/prompts/F16-gitea-public-release.md b/docs/prompts/F16-gitea-public-release.md
deleted file mode 100644
index f98f8c9..0000000
--- a/docs/prompts/F16-gitea-public-release.md
+++ /dev/null
@@ -1,90 +0,0 @@
-# F.16 — Gitea Plugin Review, Test & Public Release
-
-> Session prompt. Copy and paste into a new Claude Code conversation.
-
----
-
-## Prompt:
-
-```
-از مهارت project-ops برای آشنایی با محیط استفاده کن. پروژه mcphub-internal روی branch Phase-1 است.
-فایلهای مرجع:
-- docs/plans/2026-03-25-v4-development-cycle.md
-- CLAUDE.md
-- CHANGELOG.md
-
-## وضعیت فعلی
-- نسخه: v3.5.0
-- 565 ابزار در 9 پلاگین (4 پلاگین عمومی: WordPress, WooCommerce, Supabase, OpenPanel)
-- FastMCP: `>=3.0.0,<4.0.0`
-- CI سبز
-- F.15 تکمیل شده — FastMCP 3.x upgrade + legacy cleanup
-
-## هدف: فاز F.16 — Gitea Plugin Review, Test & Public Enablement
-
-مشابه F.10 (OpenPanel) — بررسی، تست و فعالسازی عمومی پلاگین Gitea.
-
-### بخش 1: بررسی و تحلیل
-1. **ساختار پلاگین**: `plugins/gitea/` شامل 56 ابزار در 5 هندلر:
- - `handlers/repositories.py` — 16 tools (CRUD repos, branches, tags, files)
- - `handlers/issues.py` — 12 tools (issues, comments, labels, milestones)
- - `handlers/pull_requests.py` — 15 tools (PRs, reviews, merge, diff)
- - `handlers/users.py` — 8 tools (users, orgs, teams)
- - `handlers/webhooks.py` — 5 tools (CRUD webhooks, test)
-2. **مشکلات شناساییشده**:
- - `update_webhook` در client.py هست ولی به عنوان tool expose نشده
- - هیچ تست اختصاصیای ندارد (0 تست!)
- - فعلاً admin-only هست (نه در ENABLED_PLUGINS)
-3. **تست واقعی**: سایت Gitea از MCPHub MCP endpoint:
- - از ابزارهای Supabase MCP (`mcp.example.com`) استفاده کنید تا سایت Gitea موجود را پیدا کنید
- - یا یک Gitea instance جدید از طریق داشبورد اضافه کنید
- - هر دسته tool را با API واقعی تست کنید
-
-### بخش 2: تست نوشتن
-مشابه `tests/test_openpanel_plugin.py` (الگو — 767 خط, 62 تست):
-1. `tests/test_gitea_plugin.py` بنویسید:
- - Client initialization + health check
- - Tool spec validation (نامها، پارامترها، type ها)
- - Handler delegation (mock client)
- - Error handling
- - Pagination
-2. هدف: حداقل 80 تست (56 tool + edge cases)
-
-### بخش 3: رفع مشکلات
-1. `update_webhook` را به عنوان tool expose کنید
-2. هر tool که در تست واقعی مشکل دارد را fix کنید
-3. توضیحات service page برای Gitea بنویسید
-4. health check endpoint را بررسی کنید
-
-### بخش 4: فعالسازی عمومی
-1. `core/plugin_visibility.py`: اضافه کردن `gitea` به `DEFAULT_PUBLIC_PLUGINS`
-2. `env.example`: آپدیت ENABLED_PLUGINS default
-3. `glama.json`: آپدیت description اگر لازمه
-
-### بخش 5: Release
-1. Version bump: `3.5.0` → `3.6.0`
-2. CHANGELOG update
-3. Lint: `uvx --python 3.12 black .` && `uvx ruff check --fix .`
-4. Commit + push Phase-1
-5. Sync: `python3.11 scripts/community-build/sync.py --output ../mcphub/`
-6. بعد از sync حتماً `uvx --python 3.12 black .` در public repo هم بزنید!
-7. Commit + push public repo
-
-### مراحل پیشنهادی
-1. **تحلیل**: خواندن کامل هر handler + client + schemas
-2. **تست واقعی**: اتصال به Gitea instance و تست ابزارها
-3. **پلن اجرایی**: ارائه پلن و تایید قبل از شروع
-4. **تست نوشتن**: test_gitea_plugin.py
-5. **رفع مشکلات**: fix هر tool مشکلدار
-6. **فعالسازی**: ENABLED_PLUGINS update
-7. **Release**: v3.6.0
-
-### نکات فنی
-- Private repo: /config/workspace/mcphub-internal (branch Phase-1)
-- Public repo: /config/workspace/mcphub (branch main)
-- Lint: `uvx --python 3.12 black .` && `uvx ruff check --fix .`
-- Sync: `python3.11 scripts/community-build/sync.py --output ../mcphub/`
-- ایمیل git عمومی: hi.airano@gmail.com
-- **مهم**: اول پلن بده، بدون تایید شروع نکن
-- **الگوی مرجع**: F.10 (OpenPanel) — بخش "Phase F.10" در plan و `tests/test_openpanel_plugin.py`
-```
diff --git a/docs/prompts/next-session-coolify-f17.md b/docs/prompts/next-session-coolify-f17.md
deleted file mode 100644
index 17fdffe..0000000
--- a/docs/prompts/next-session-coolify-f17.md
+++ /dev/null
@@ -1,95 +0,0 @@
-# Next Session — F.17 Coolify MCP Plugin (MVP)
-
-> Session prompt. Copy and paste into a new Claude Code conversation.
-
----
-
-## Prompt:
-
-```
-از مهارت project-ops برای آشنایی با محیط استفاده کن. حافظه و فایلهای مرجع را بررسی کن.
-
-## فایلهای مرجع
-- docs/plans/2026-04-02-coolify-mcp-plugin-design.md (mcphub-internal) ← طرح کامل پلاگین
-- docs/plans/2026-03-25-v4-development-cycle.md (mcphub-internal, branch Phase-1) ← فاز F.17
-- CLAUDE.md (mcphub-internal)
-- plugins/gitea/ (mcphub-internal) ← الگوی مرجع (آخرین پلاگین ساختهشده)
-- plugins/base.py (mcphub-internal) ← BasePlugin interface
-
-## وضعیت فعلی
-- MCPHub: v3.6.0 — 566 ابزار، 5 پلاگین عمومی
-- FastMCP: >=3.0.0,<4.0.0
-- CI سبز
-- طرح Coolify: نوشته شده — ~68 ابزار در 6 handler
-
-## ریپازیتوری
-- MCPHub (internal): `/config/workspace/mcphub-internal` (branch Phase-1)
-
-## هدف session: F.17 Phase 1 — Coolify MVP
-
-### پیشنیاز اول: Coolify API Token
-- [ ] در داشبورد Coolify، بخش Keys & Tokens، یک API token بساز
-- [ ] تست اتصال: `curl -s https://COOLIFY_URL/api/v1/version -H "Authorization: Bearer TOKEN"`
-- [ ] اگر URL و TOKEN مشخص نبود، از کاربر بپرس
-
-### مرحله ۱: ساختار اولیه پلاگین
-- [ ] `plugins/coolify/__init__.py` (خالی)
-- [ ] `plugins/coolify/client.py` — CoolifyClient با Bearer Token auth
- - الگو از `plugins/gitea/client.py` بگیر
- - متدها: `request()`, `get()`, `post()`, `patch()`, `delete()`
- - Error handling + retry مشابه Gitea client
-- [ ] `plugins/coolify/plugin.py` — CoolifyPlugin(BasePlugin)
- - الگو از `plugins/gitea/plugin.py` بگیر
- - `get_tool_specifications()` باید ابزارهای handler ها رو جمع کنه
-- [ ] `plugins/coolify/handlers/__init__.py`
-- [ ] `plugins/coolify/schemas/__init__.py`
-- [ ] `plugins/coolify/schemas/common.py` — مدلهای مشترک (UUID, pagination)
-
-### مرحله ۲: Handler — Applications (18 ابزار)
-- [ ] `plugins/coolify/handlers/applications.py`
-- [ ] ابزارها (از طرح):
- - list_applications, get_application
- - create_application_public, create_application_dockerfile, create_application_docker_image, create_application_compose
- - update_application, delete_application
- - start_application, stop_application, restart_application
- - get_application_logs
- - list_application_envs, create_application_env, update_application_env, update_application_envs_bulk, delete_application_env
-- [ ] schemas/application.py — Pydantic models
-
-### مرحله ۳: Handler — Deployments (5 ابزار)
-- [ ] `plugins/coolify/handlers/deployments.py`
-- [ ] ابزارها: list_deployments, get_deployment, cancel_deployment, deploy, list_app_deployments
-
-### مرحله ۴: Handler — Servers (8 ابزار)
-- [ ] `plugins/coolify/handlers/servers.py`
-- [ ] ابزارها: list_servers, get_server, create_server, update_server, delete_server, get_server_resources, get_server_domains, validate_server
-- [ ] schemas/server.py
-
-### مرحله ۵: ثبت پلاگین و تنظیمات
-- [ ] در `plugins/__init__.py` اضافه کن: `from plugins.coolify.plugin import CoolifyPlugin` + `registry.register("coolify", CoolifyPlugin)`
-- [ ] در `env.example` اضافه کن: `COOLIFY_URL`, `COOLIFY_TOKEN`
-- [ ] پلاگین فعلا admin-only باشد (به ENABLED_PLUGINS اضافه نشود)
-
-### مرحله ۶: تست
-- [ ] `tests/test_coolify.py` — Unit tests با mocked HTTP
- - الگو از `tests/test_gitea*.py` بگیر
- - حداقل: test_list_applications, test_get_application, test_deploy, test_list_servers
-- [ ] `pytest tests/test_coolify.py -v`
-- [ ] اگر API Token موجود بود: integration test با Coolify واقعی
-
-### مرحله ۷: تست نهایی و commit
-- [ ] `uvx --python 3.12 black .`
-- [ ] `uvx ruff check --fix .`
-- [ ] `pytest` (همه تستها سبز)
-- [ ] Commit: `feat(F.17): add Coolify MCP plugin — Phase 1 MVP (~31 tools)`
-- [ ] Push to Phase-1
-
-## قوانین
-- اول پلن بده، بدون تایید شروع نکن
-- از Gitea plugin به عنوان الگوی اصلی استفاده کن (آخرین و تمیزترین پلاگین)
-- هر مرحله commit شود
-- tool specifications باید دقیقا مطابق فرمت BasePlugin باشند (name, method_name, description, schema, scope)
-- scope ها: read, write, admin — مطابق جدول در طرح
-- حافظه آپدیت شود بعد از اتمام
-- ایمیل git داخلی: mcphub.dev@gmail.com
-```
diff --git a/docs/prompts/next-session-coolify-test.md b/docs/prompts/next-session-coolify-test.md
deleted file mode 100644
index 1fe6e3b..0000000
--- a/docs/prompts/next-session-coolify-test.md
+++ /dev/null
@@ -1,77 +0,0 @@
-# Next Session — Coolify MCP Plugin Testing & Next Steps
-
-> Session prompt. Copy and paste into a new Claude Code conversation.
-
----
-
-## Prompt:
-
-```
-از مهارت project-ops برای آشنایی با محیط استفاده کن. حافظه و فایلهای مرجع را بررسی کن.
-
-## هدف: تست پلاگین Coolify MCP و مراحل بعدی
-
-### پیشنیاز
-- MCPHub redeploy شده و سایت Coolify اضافه شده
-- MCP endpoint `mcphub-coolify` در `.claude.json` تنظیم شده
-- پلاگین 30 ابزار دارد (17 application + 5 deployment + 8 server)
-
-### مرحله ۱: تأیید اتصال MCP
-- [ ] لیست ابزارهای coolify را از MCP بگیر (باید 30 ابزار باشد)
-- [ ] اگر ابزار coolify در لیست نبود، `.claude.json` و `settings.json` را بررسی کن
-
-### مرحله ۲: تست ابزارهای read
-- [ ] `coolify_list_servers` — لیست سرورها
-- [ ] `coolify_list_applications` — لیست اپلیکیشنها
-- [ ] `coolify_list_deployments` — لیست دیپلویمنتهای در حال اجرا
-- [ ] `coolify_get_server_resources(uuid=SERVER_UUID)` — منابع سرور
-- [ ] `coolify_get_server_domains(uuid=SERVER_UUID)` — دامنههای سرور
-- [ ] یک اپلیکیشن انتخاب کن و:
- - [ ] `coolify_get_application(uuid=APP_UUID)` — جزئیات
- - [ ] `coolify_get_application_logs(uuid=APP_UUID, lines=50)` — لاگها
- - [ ] `coolify_list_application_envs(uuid=APP_UUID)` — متغیرهای محیطی
-- [ ] `coolify_list_app_deployments(uuid=APP_UUID)` — تاریخچه دیپلوی
-
-### مرحله ۳: تست ابزارهای write (با احتیاط)
-- [ ] از کاربر بپرس آیا مجاز است یک env var تست ایجاد/حذف کند
-- [ ] اگر بله:
- - [ ] `coolify_create_application_env(uuid=APP_UUID, key="TEST_VAR", value="test123")`
- - [ ] `coolify_delete_application_env(uuid=APP_UUID, env_uuid=...)`
-
-### مرحله ۴: گزارش نتایج
-- [ ] خلاصه نتایج تست (چند ابزار کار کرد، مشکلات)
-- [ ] مقایسه با ابزارهای Gitea و Supabase MCP (کیفیت پاسخها)
-
-### مرحله ۵: اگر تست موفق بود — Sync به نسخه عمومی
-- [ ] `python3.11 scripts/community-build/sync.py --output ../mcphub/`
-- [ ] `cd /config/workspace/mcphub && uvx --python 3.12 black . && uvx ruff check --fix .`
-- [ ] تستها: `python3.11 -m pytest tests/ -q`
-- [ ] Commit و push نسخه عمومی
-
-### مرحله ۶: آپدیتها
-- [ ] mcp-skills/skills/coolify/SKILL.md — از planned به active تغییر کرده (بررسی شود)
-- [ ] حافظه آپدیت شود
-
-## پیشنهاد مراحل بعدی (بعد از تست)
-
-### فوری
-1. **F.17 Phase 2**: اضافه کردن databases (16 ابزار) + services (13 ابزار) → ~59 ابزار کل
-2. **F.17 Phase 3**: projects (8 ابزار) → ~67 ابزار کل — تکمیل طرح اصلی
-
-### میانمدت
-3. **Coolify Workflow Skill**: مهارت اختصاصی برای عملیات متداول (deploy all, backup all, health check all)
-4. **Integration Test**: تست خودکار با Coolify واقعی (pytest mark integration)
-5. **F.14a**: ثبت MCPHub در Smithery + Official MCP Registry
-
-### بلندمدت
-6. **Blog Post**: نوشتن مقاله درباره "Self-hosted MCP Hub with Coolify Integration"
-7. **F.5a**: Base64 media upload برای WordPress
-8. **F.6**: Claude Code skills بومی
-
-## قوانین
-- اول ابزارهای read تست شوند، بعد write
-- قبل از هر عملیات write از کاربر تأیید بگیر
-- نتایج تست دقیق گزارش شود (UUID ها، خطاها، زمان پاسخ)
-- حافظه آپدیت شود بعد از اتمام
-- ایمیل git داخلی: mcphub.dev@gmail.com
-```
diff --git a/docs/prompts/next-session-f17-phase3.md b/docs/prompts/next-session-f17-phase3.md
deleted file mode 100644
index f54a5e9..0000000
--- a/docs/prompts/next-session-f17-phase3.md
+++ /dev/null
@@ -1,115 +0,0 @@
-# Next Session — F.17 Phase 3: Projects + Phase 2: Databases & Services
-
-> Session prompt. Copy and paste into a new Claude Code conversation.
-
----
-
-## Prompt:
-
-```
-از مهارت project-ops برای آشنایی با محیط استفاده کن. حافظه و فایلهای مرجع را بررسی کن.
-
-## فایلهای مرجع
-- docs/plans/2026-04-02-coolify-mcp-plugin-design.md (mcphub-internal) ← طرح کامل پلاگین
-- docs/plans/2026-03-25-v4-development-cycle.md (mcphub-internal) ← Phase F.17 آپدیت شده
-- plugins/coolify/ (mcphub-internal) ← پلاگین Phase 1 (الگوی اصلی)
-- plugins/gitea/ (mcphub-internal) ← الگوی مرجع معماری
-- CLAUDE.md (mcphub-internal)
-
-## وضعیت فعلی
-- MCPHub: v3.7.0 — 596 ابزار، 10 پلاگین
-- Coolify Phase 1: ✅ 30 ابزار (17 app + 5 deploy + 8 server) — deployed, tested, synced
-- MCP endpoint فعال: mcphub-coolify در Claude Code (30 ابزار لود شده)
-- CI سبز، 718 تست (internal), 686 تست (public)
-
-## ریپازیتوری
-- MCPHub (internal): `/config/workspace/mcphub-internal` (branch Phase-1)
-
-## هدف session: F.17 Phase 3 + Phase 2
-
-### بخش اول: Phase 3 — Projects & Environments (8 ابزار)
-> اولویت بالا — بدون project_uuid ساخت app/db/service بلاک است
-
-#### مرحله ۱: Projects Handler
-- [ ] `plugins/coolify/handlers/projects.py`
-- [ ] ابزارها:
- - list_projects (GET /projects) — read
- - get_project (GET /projects/{uuid}) — read
- - create_project (POST /projects) — write
- - update_project (PATCH /projects/{uuid}) — write
- - delete_project (DELETE /projects/{uuid}) — admin
- - list_environments (GET /projects/{uuid}/environments) — read
- - get_environment (GET /projects/{uuid}/environments/{name}) — read
- - create_environment (POST /projects/{uuid}/environments) — write
-- [ ] متدهای client در `client.py` اضافه شود
-- [ ] در `handlers/__init__.py` ایمپورت projects اضافه شود
-- [ ] در `plugin.py` — specs و __getattr__ آپدیت شود
-
-#### مرحله ۲: تست و دیپلوی Phase 3
-- [ ] `tests/test_coolify_projects.py` — Unit tests با mocked HTTP
-- [ ] `pytest tests/test_coolify*.py -v`
-- [ ] Commit: `feat(F.17): add projects handler — Phase 3 (8 tools)`
-- [ ] Push و درخواست redeploy
-- [ ] تست live: list_projects → پیدا کردن project_uuid
-- [ ] تست ساخت container: create_application_docker_image با project_uuid واقعی → دیپلوی nginx → تأیید → حذف
-
-### بخش دوم: Phase 2 — Databases (16 ابزار)
-- [ ] `plugins/coolify/handlers/databases.py`
-- [ ] ابزارها:
- - list_databases, get_database — read
- - update_database — write
- - delete_database — admin
- - start_database, stop_database, restart_database — write
- - create_postgresql, create_mysql, create_mariadb — write
- - create_mongodb, create_redis, create_clickhouse — write
- - get_database_backups — read
- - create_database_backup — write
- - list_backup_executions — read
-- [ ] متدهای client اضافه شود
-- [ ] تست: `tests/test_coolify_databases.py`
-
-### بخش سوم: Phase 2 — Services (13 ابزار)
-- [ ] `plugins/coolify/handlers/services.py`
-- [ ] ابزارها:
- - list_services, get_service — read
- - create_service — write
- - update_service — write
- - delete_service — admin
- - start_service, stop_service, restart_service — write
- - list_service_envs — read
- - create_service_env — write
- - update_service_env — write
- - update_service_envs_bulk — write
- - delete_service_env — write
-- [ ] متدهای client اضافه شود
-- [ ] تست: `tests/test_coolify_services.py`
-
-### بخش چهارم: ثبت و تست نهایی
-- [ ] handlers/__init__.py آپدیت (projects, databases, services)
-- [ ] plugin.py آپدیت (specs + __getattr__)
-- [ ] server.py — نیازی به تغییر ندارد (coolify قبلا ثبت شده)
-- [ ] `uvx --python 3.12 black .`
-- [ ] `uvx ruff check --fix .`
-- [ ] `pytest` (همه تستها سبز)
-- [ ] Commit: `feat(F.17): add databases + services handlers — Phase 2 (29 tools)`
-- [ ] Push و درخواست redeploy
-- [ ] تست live: list_databases, list_services
-- [ ] آپدیت ورژن به v3.8.0 (اگر تأیید شد)
-
-### بخش پنجم: Sync و داکیومنت
-- [ ] Sync به نسخه عمومی: `python3.11 scripts/community-build/sync.py --output ../mcphub/`
-- [ ] black + ruff + pytest در نسخه عمومی
-- [ ] README آپدیت (تعداد ابزار، جدول Coolify)
-- [ ] Commit و push نسخه عمومی
-- [ ] mcp-skills/skills/coolify/SKILL.md آپدیت (67 ابزار)
-- [ ] حافظه آپدیت شود
-- [ ] پلن آپدیت شود (Phase 2+3 complete)
-
-## نکات مهم
-- server.py نیازی به تغییر ندارد — coolify قبلا register شده و generate_tools خودکار specs جدید رو میخونه
-- site_api.py نیازی به تغییر ندارد — credential fields و display name قبلا اضافه شده
-- الگوی handler: از applications.py کپی کن (آخرین و تمیزترین)
-- هر بخش (Phase 3, databases, services) جداگانه commit شود
-- قبل از هر عملیات write در تست live از کاربر تأیید بگیر
-- ایمیل git داخلی: mcphub.dev@gmail.com
-```
diff --git a/docs/prompts/next-session-f7-tool-access.md b/docs/prompts/next-session-f7-tool-access.md
deleted file mode 100644
index 3cec449..0000000
--- a/docs/prompts/next-session-f7-tool-access.md
+++ /dev/null
@@ -1,136 +0,0 @@
-از مهارت project-ops برای آشنایی با محیط استفاده کن. حافظه و فایلهای مرجع را بررسی کن.
-
-## فایلهای مرجع
-- docs/plans/2026-03-25-v4-development-cycle.md (mcphub-internal) ← Phase F.7 طراحی کامل
-- core/tool_generator.py (mcphub-internal) ← تولید ابزار فعلی
-- core/tool_registry.py (mcphub-internal) ← رجیستری ابزار
-- core/user_endpoints.py (mcphub-internal) ← فیلتر ابزار در endpoint کاربر
-- core/user_keys.py (mcphub-internal) ← سیستم API key کاربر
-- core/database.py (mcphub-internal) ← دیتابیس و مایگریشن
-- core/plugin_visibility.py (mcphub-internal) ← فیلتر پلاگین فعلی
-- core/dashboard/routes.py (mcphub-internal) ← روتهای داشبورد
-- server.py (mcphub-internal) ← middleware و scope enforcement
-- CLAUDE.md (mcphub-internal)
-
-## وضعیت فعلی
-- MCPHub: v3.8.0 — 633 ابزار، 10 پلاگین، 67 ابزار Coolify
-- تستها: 766 (internal), 734 (public)
-- CI سبز
-- Scope فعلی: read/write/admin (سه سطح ساده)
-- فیلتر فعلی: فقط plugin-level (ENABLED_PLUGINS) — بدون per-tool toggle
-
-## ریپازیتوری
-- MCPHub (internal): `/config/workspace/mcphub-internal` (branch Phase-1)
-
-## هدف session: F.7 — Smart Tool Visibility & Scope-Based Access Control
-
-### مشکلاتی که حل میشوند
-1. همه ابزارهای یک پلاگین فعال به همه کاربران نشان داده میشوند — کنترل per-tool نداریم
-2. وقتی کاربر API key با scope خاص (مثلا read) میسازد، باز هم همه ابزارها در tools/list نمایش داده میشوند
-3. ابزارهای وردپرس که نیاز به افزونههای کمکی دارند (SEO Bridge, WP-CLI) بدون بررسی prerequisite نشان داده میشوند
-4. کاربران نمیتوانند ابزارهایی که نیاز ندارند را غیرفعال کنند
-
-### مدل scope پیشنهادی (گسترشیافته)
-فعلی: `read`, `write`, `admin`
-جدید:
-- `deploy` — عملیات lifecycle (start/stop/restart/deploy) + read
-- `read:sensitive` — read + لاگ، env var، بکاپ، connection string
-
-**نگاشت scope → دستهبندی ابزار:**
-| Scope | ابزارها |
-|-------|---------|
-| `read` | list_*, get_* (بدون sensitive) |
-| `read:sensitive` | read + *_logs, *_envs, *_backups |
-| `deploy` | read + start_*, stop_*, restart_*, deploy |
-| `write` | deploy + create_*, update_*, delete_*_env |
-| `admin` | write + delete_* (منابع)، create_server |
-
-### بخش اول: Core — ساختار داده و مدیریت دسترسی (بدون UI)
-
-#### مرحله ۱: دیتابیس
-- [ ] جدول `user_tool_toggles` در `core/database.py`
- ```sql
- CREATE TABLE user_tool_toggles (
- id TEXT PRIMARY KEY, user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
- tool_name TEXT NOT NULL, enabled INTEGER NOT NULL DEFAULT 1,
- reason TEXT, updated_at TEXT NOT NULL, UNIQUE(user_id, tool_name)
- );
- ```
-- [ ] جدول `scope_presets` — پریستهای scope سیستمی + سفارشی
-- [ ] Migration اجرا شود
-
-#### مرحله ۲: ماژول tool_access.py
-- [ ] `core/tool_access.py` — کلاس `ToolAccessManager`
-- [ ] `get_visible_tools(user_id, scopes, plugin_type)` → لیست فیلتر شده
-- [ ] `apply_scope_filter(tools, scopes)` → فقط ابزارهای مجاز بر اساس scope
-- [ ] `apply_user_toggles(tools, user_id)` → اعمال toggleهای کاربر
-- [ ] `toggle_tool(user_id, tool_name, enabled)` → ذخیره تنظیم
-- [ ] `bulk_toggle_by_scope(user_id, scope_name)` → فعال/غیرفعال دستهجمعی
-
-#### مرحله ۳: Tool metadata enhancement
-- [ ] اضافه کردن `sensitivity` و `category` به tool specs در handlerها:
- - `sensitivity`: "normal" | "sensitive" (لاگ، env، بکاپ)
- - `category`: "read" | "lifecycle" | "crud" | "env" | "backup" | "system"
-- [ ] شروع از Coolify (آخرین و تمیزترین) سپس سایر پلاگینها
-- [ ] ToolDefinition در tool_registry.py آپدیت شود
-
-#### مرحله ۴: فیلتر در user_endpoints.py
-- [ ] `_get_tools_for_plugin()` از ToolAccessManager استفاده کند
-- [ ] Pipeline فیلتر:
- 1. plugin_visibility (موجود)
- 2. scope-to-tool mapping (جدید)
- 3. user toggles (جدید)
-- [ ] Scope enforcement در middleware آپدیت شود (server.py)
-
-#### مرحله ۵: تست
-- [ ] `tests/test_tool_access.py` — unit tests
-- [ ] تستهای scope mapping: key با scope "read" → فقط ابزارهای read
-- [ ] تستهای toggle: کاربر disable کرده → ابزار در tools/list نیست
-- [ ] تستهای integration: API key scope → فیلتر واقعی
-
-### بخش دوم: API — روتهای مدیریت toggle
-
-- [ ] `GET /api/user/tools` — لیست ابزارها با وضعیت toggle
-- [ ] `PATCH /api/user/tools/{tool_name}` — تغییر toggle
-- [ ] `POST /api/user/tools/bulk-toggle` — toggle دستهجمعی بر اساس scope
-- [ ] `GET /api/user/scope-presets` — لیست presetها
-- [ ] تست: روتها کار کنند
-
-### بخش سوم: Prerequisites (وردپرس/ووکامرس)
-
-- [ ] `check_prerequisites(tools, site_config)` در tool_access.py
-- [ ] تشخیص SEO Bridge: `wp-json/airano-mcp-seo-bridge/v1/status`
-- [ ] تشخیص WP-CLI: بررسی `container` field در credentials
-- [ ] تشخیص WooCommerce: `wp-json/wc/v3/system_status`
-- [ ] ابزارهای وابسته علامتگذاری شوند (نه حذف — فقط annotation)
-
-### بخش چهارم: UI — صفحه مدیریت ابزار
-
-- [ ] `core/templates/dashboard/tool-preferences.html`
-- [ ] لیست ابزارها گروهبندی شده بر اساس category
-- [ ] Toggle switch برای هر ابزار
-- [ ] Badge برای prerequisite (نصب نشده / نیاز به Docker)
-- [ ] Dropdown برای اعمال scope preset
-- [ ] در صفحه Connect: پیشنمایش ابزارها هنگام ساخت API key
-
-### بخش پنجم: ثبت و تست نهایی
-
-- [ ] `uvx --python 3.12 black .`
-- [ ] `uvx ruff check --fix .`
-- [ ] `pytest` — همه تستها سبز
-- [ ] Commit: `feat(F.7): add smart tool visibility and scope-based access control`
-- [ ] Push و درخواست redeploy
-- [ ] تست live: ساخت API key با scope "read" → بررسی tools/list
-- [ ] Sync به نسخه عمومی
-- [ ] آپدیت ورژن به v3.9.0 (اگر تأیید شد)
-- [ ] حافظه آپدیت شود
-- [ ] پلن آپدیت شود (F.7 complete)
-
-## نکات مهم
-- فیلتر scope باید backward-compatible باشد — keyهای موجود بدون تغییر کار کنند
-- Default: همه ابزارها فعال — فقط explicit disable ذخیره شود
-- `user_tool_toggles` فقط overrideها رو ذخیره میکنه، نه همه ابزارها
-- Prerequisite check باید non-blocking باشه — ابزار حذف نشه، فقط annotate بشه
-- server.py نیازی به تغییر زیاد ندارد — فقط middleware scope check آپدیت شود
-- ایمیل git داخلی: mcphub.dev@gmail.com
-- بخش اول و دوم اولویت اصلی هستند — بخش سوم و چهارم اگر وقت شد
diff --git a/docs/prompts/next-session-f7b-ui.md b/docs/prompts/next-session-f7b-ui.md
deleted file mode 100644
index de93184..0000000
--- a/docs/prompts/next-session-f7b-ui.md
+++ /dev/null
@@ -1,98 +0,0 @@
-از مهارت project-ops برای آشنایی با محیط استفاده کن. حافظه و فایلهای مرجع را بررسی کن.
-
-## فایلهای مرجع
-- docs/plans/2026-04-04-f7b-site-scoped-tool-access.md ← پلن کامل F.7b
-- core/tool_access.py ← ToolAccessManager (سایتمحور — session 1)
-- core/dashboard/routes.py ← روتهای API site tools (بخش F.7b)
-- core/templates/dashboard/sites/edit.html ← صفحه edit سایت (بدون بخش tools)
-- core/templates/dashboard/connect.html ← صفحه connect فعلی (config snippets + keys)
-- core/templates/dashboard/api-keys/list.html ← صفحه admin کلیدها (UI بهتر)
-- core/dashboard/routes.py::dashboard_connect_page / dashboard_api_keys_list
-- CLAUDE.md (mcphub-internal)
-
-## وضعیت فعلی
-- MCPHub: v3.8.0 + F.7b session 1 (commit روی Phase-1)
-- Tests: 813 passed، CI سبز
-- Backend F.7b کامل است: per-site tool_scope + site_tool_toggles + 4 روت جدید تحت `/api/sites/{site_id}/...` + `/api/scope-presets`
-- فقط UI باقی مانده — هدف این session
-
-## ریپازیتوری
-- MCPHub (internal): `/config/workspace/mcphub-internal` (branch Phase-1)
-
-## هدف session: F.7b — UI + page merge
-
-### ۱. بخش "Tool Access" در صفحه edit سایت
-فایل: `core/templates/dashboard/sites/edit.html`
-
-- [ ] اضافه کردن یک کارت جدید "Tool Access" بعد از فرم credentials
-- [ ] Dropdown برای `tool_scope` (values: read / read:sensitive / deploy / write / admin / custom)
- - PATCH روی `/api/sites/{site_id}/tool-scope` با body `{scope: "..."}`
- - توضیح کوتاه کنار هر گزینه: "Read (X tools)" — شمارش زنده از `/api/sites/{site_id}/tools`
-- [ ] Collapsible "Advanced — per-tool overrides":
- - گرید/لیست گروهبندی شده بر اساس `category` (read / read_sensitive / lifecycle / crud / env / backup / system)
- - Toggle switch برای هر ابزار → PATCH `/api/sites/{site_id}/tools/{tool_name}` با `{enabled: bool}`
- - Badge قرمز برای `sensitivity=sensitive`
- - نام کوتاه از `name`، tooltip با `description`
-- [ ] استفاده از HTMX (در پروژه موجود است) برای updates بدون full reload
-- [ ] CSRF token از cookie `dashboard_csrf` به header `X-CSRF-Token`
-
-### ۲. انتقال config snippets از connect به صفحه سایت
-فایل: `core/templates/dashboard/sites/view.html` (یا ایجاد اگر وجود ندارد)
-
-- [ ] هر سایت در `/dashboard/sites/{id}` نمایش دهد:
- - URL MCP مخصوص آن سایت: `{PUBLIC_URL}/u/{user_id}/{alias}/mcp`
- - Tabs یا accordion با snippets برای Claude Desktop / Cursor / Zed / کلاینتهای دیگر
- - استفاده از `core/config_snippets.py::get_supported_clients` (موجود)
-- [ ] از صفحه `/dashboard/sites` (list) دکمه "Connect" به این صفحه لینک بزند
-
-### ۳. ادغام `/dashboard/connect` و `/dashboard/api-keys` → `/dashboard/keys` (گزینه A)
-UI مبنا: `core/templates/dashboard/api-keys/list.html` (قشنگتر و کاملتر است طبق تأیید کاربر)
-
-- [ ] ساخت handler `dashboard_keys_unified(request)` که بر اساس session type branch میزند:
- - OAuth user → نمایش `user_api_keys` برای آن کاربر
- - Admin/master → نمایش کامل `api_keys` (همان view فعلی)
-- [ ] template جدید `core/templates/dashboard/keys/list.html` با ادغام design از `api-keys/list.html`
- - User view: سادهتر، scope selector در create dialog، لیست کلیدهای خود کاربر
- - Admin view: فیلترهای کامل (project, status, search, pagination) — بدون تغییر
-- [ ] **Scope selector در create-key dialog** — این بخش حیاتی است:
- - Radio/select: read / read:sensitive / deploy / write / admin
- - Helper text: "Per-site tool filters are set in Site Settings"
- - POST به `/api/keys` (همان endpoint فعلی) با `scopes: ""`
-- [ ] Redirect های قدیمی:
- - `/dashboard/connect` → `/dashboard/keys` (301)
- - `/dashboard/api-keys` → `/dashboard/keys` (301)
-- [ ] حذف handler های قدیمی `dashboard_connect_page` و `dashboard_api_keys_list` و یا تبدیل به thin wrapper redirect
-- [ ] منوی navigation sidebar را update کن — فقط یک entry "API Keys"
-
-### ۴. گزینه B (ادغام عمیق DB) — deferred
-در پلن session 1 ذکر شده اما اجرا نمیکنیم مگر کاربر صراحتاً درخواست کند. کامنت در code اضافه کنید به `api_create_key` که "dual-table model is intentional — see F.7b plan".
-
-### ۵. تست
-- [ ] `tests/test_dashboard_keys_unified.py` — تست منوی unified، scope selector، 301 redirect از URL های قدیمی
-- [ ] `tests/test_sites_tool_access_ui.py` — smoke test که edit page با tool_scope=read درست render شود (میتوان با TestClient چک کرد که template بدون 500 میآید)
-- [ ] بهروزرسانی `tests/test_dashboard.py::test_dashboard_connect_page` → به `/dashboard/keys` منتقل شود یا به پذیرش redirect
-- [ ] pytest کامل سبز
-
-### ۶. ورژن، sync، commit
-- [ ] `uvx --python 3.12 black . && uvx --python 3.12 ruff check --fix .`
-- [ ] bump version به `v3.9.0` در pyproject.toml + `__version__` در server.py (اگر وجود دارد)
-- [ ] Commit: `feat(F.7b): tool access UI + unified keys page (v3.9.0)`
-- [ ] Push به Phase-1
-- [ ] `python3.11 scripts/community-build/sync.py --output ../mcphub/` سپس در repo عمومی `black` + `ruff`
-- [ ] Commit عمومی با ایمیل `hi.airano@gmail.com` و push
-- [ ] درخواست deploy از کاربر
-
-### ۷. تست live پس از deploy
-- [ ] ورود به `/dashboard/sites/{id}/edit` → بخش Tool Access → تغییر scope به `read` → save
-- [ ] بدون ساخت کلید جدید، MCP client (همان کلید admin موجود) روی آن alias → `tools/list` باید فقط ابزارهای read را نشان دهد
-- [ ] تغییر به `custom` → Advanced → disable یک ابزار خاص (مثلاً `coolify_delete_server`) → تست
-- [ ] ساخت کلید جدید از صفحه unified با scope=`read` → بررسی در لیست
-
-## نکات مهم
-- **Backward compatibility:** سایتهای موجود `tool_scope='admin'` دارند (default migration v7) → هیچ تغییر رفتاری روی سایتهای قدیمی
-- **فیلترها:** key scope و site scope **intersect** میشوند. admin key + site=read → فقط read. write key + site=deploy → فقط read + lifecycle.
-- **CSRF:** middleware روی `/api/sites/*` فعال است. UI باید header `X-CSRF-Token` از cookie `dashboard_csrf` بفرستد. HTMX این را با `hx-headers` هندل میکند.
-- **CSS:** پروژه Tailwind دارد. از همان کلاسهای موجود در `api-keys/list.html` استفاده کن برای consistency.
-- **i18n:** پروژه EN/FA است. متنهای جدید را به `core/i18n.py` اضافه کن.
-- **ایمیل git داخلی:** mcphub.dev@gmail.com | ایمیل عمومی: hi.airano@gmail.com
-- **نباید:** توابع F.7 v1 (با `user_` prefix) را بازگردانی کنی. همه سایتمحور است.
diff --git a/docs/prompts/next-session.md b/docs/prompts/next-session.md
deleted file mode 100644
index 88882d0..0000000
--- a/docs/prompts/next-session.md
+++ /dev/null
@@ -1,93 +0,0 @@
-# Next Session — Registry + MCP Skills + Infrastructure
-
-> Session prompt. Copy and paste into a new Claude Code conversation.
-
----
-
-## Prompt:
-
-```
-از مهارت project-ops برای آشنایی با محیط استفاده کن. حافظه و فایلهای مرجع را بررسی کن.
-
-## فایلهای مرجع
-- docs/plans/2026-03-25-v4-development-cycle.md (mcphub-internal, branch Phase-1)
-- CLAUDE.md (mcphub-internal)
-- CHANGELOG.md (mcphub-internal)
-
-## وضعیت فعلی
-- MCPHub: v3.6.0 — 567 ابزار، 5 پلاگین عمومی (WordPress, WooCommerce, Supabase, OpenPanel, Gitea)
-- FastMCP: >=3.0.0,<4.0.0
-- CI سبز
-
-## MCP Endpoints فعال
-- mcphub-supabase: 70 ابزار Supabase (DB, auth, storage)
-- mcphub-gitea: 58 ابزار Gitea (repos, issues, PRs, webhooks)
-
-## ریپازیتوریها
-### GitHub (airano-ir)
-- mcphub (public) → /config/workspace/mcphub
-- mcphub (private, Phase-1) → /config/workspace/mcphub-internal
-- mcp-skills (private, خالی) → /config/workspace/mcp-skills
-- skillhub-internal → /config/workspace/skillhub-internal
-- skillhub (public) → /config/workspace/skillhub
-
-### Gitea (atlatl @ gitea.example.com)
-- polymarket (private) → /config/workspace/polymarket
-- polymarket-skill (private) → /config/workspace/polymarket-skill
-- project-ops (private) → مهارت در /config/.claude/skills/project-ops/
-
-## اهداف این session (به ترتیب اولویت)
-
-### 1. Registry Submissions (F.14a)
-وضعیت فعلی:
-- Glama: ثبت شده (Score: A)، glama.json اضافه شده
-- awesome-mcp-servers: PR #2147 باز — badge SVG + tool count اصلاح شده، منتظر merge
-- Smithery.ai: ثبت نشده → از smithery.ai/new ثبت کن (نیاز به public HTTPS URL: mcp.example.com)
-- Official MCP Registry: ثبت نشده → بررسی `mcp-publisher` CLI
-کارها:
-- [ ] وضعیت PR #2147 چک شود — اگر feedback جدید دارد رفع شود
-- [ ] ثبت در Smithery.ai
-- [ ] بررسی Official MCP Registry submission process
-
-### 2. MCP Skills — اولین مهارتها (github:airano-ir/mcp-skills)
-ریپازیتوری خالی ساخته شده. ساختار:
-```
-mcp-skills/skills/
-├── wordpress/ ← اولین مهارت
-├── supabase/
-├── gitea/
-├── woocommerce/
-├── openpanel/
-└── coolify/ ← آینده
-```
-کارها:
-- [ ] از SkillHub بهترین مهارتهای مرتبط را جستجو کن: `npx skillhub search "wordpress mcp" --sort aiScore`
-- [ ] اگر مهارت با کیفیت بالا (aiScore > 70) پیدا نشد، با skill-creator مهارت جدید بساز
-- [ ] اولین مهارت: WordPress content workflow (استفاده از MCP endpoint مستقیم)
-- [ ] هر مهارت باید SKILL.md + اسکریپتهای عملی داشته باشد
-- [ ] بعد از تست، commit و push به github:airano-ir/mcp-skills
-
-### 3. Project-Ops Sync با Gitea
-- [ ] محتوای `/config/.claude/skills/project-ops/SKILL.md` را به `gitea:atlatl/project-ops` push کن
-- [ ] مطمئن شو backup در Gitea up-to-date است
-
-### 4. Blog Workspace Setup
-- [ ] WordPress MCP endpoint اضافه کن از داشبورد mcp.example.com (blog.example.com)
-- [ ] یک پست تست با MCP WordPress tools بنویس
-- [ ] اگر API مشکلی داشت، در mcphub-internal fix کن
-
-### 5. بررسی Coolify MCP (F.17)
-- [ ] API documentation کولیفای را بررسی کن
-- [ ] بررسی آیا Coolify API در دسترس است از این محیط
-- [ ] یک طرح اولیه از tools مورد نیاز بنویس
-- [ ] نتیجه را در docs/plans/ ذخیره کن
-
-## قوانین
-- اول پلن بده، بدون تایید شروع نکن
-- هر مرحله commit شود
-- حافظه و project-ops در صورت نیاز آپدیت شود
-- از SkillHub برای پیدا کردن بهترین مهارتها استفاده کن (aiScore بالاترین)
-- اگر مهارتی بررسی نشده، با auto-review بررسی کن
-- ایمیل git عمومی: hi.airano@gmail.com
-- ایمیل git داخلی: mcphub.dev@gmail.com
-```
diff --git a/plugins/ai_image/__init__.py b/plugins/ai_image/__init__.py
new file mode 100644
index 0000000..22deca4
--- /dev/null
+++ b/plugins/ai_image/__init__.py
@@ -0,0 +1,33 @@
+"""AI image generation provider library (F.5a.4).
+
+Not a registered MCP plugin — this package exposes provider
+implementations and a lookup registry consumed by media-upload tools in
+other plugins (currently ``wordpress_generate_and_upload_image``).
+
+Typical usage::
+
+ from plugins.ai_image.registry import get_provider
+
+ provider = get_provider("openai")
+ result = await provider.generate(
+ api_key=key, prompt="a red cube", size="1024x1024"
+ )
+ image_bytes, mime, meta = result.bytes, result.mime, result.meta
+"""
+
+from plugins.ai_image.providers.base import (
+ BaseImageProvider,
+ GenerationRequest,
+ GenerationResult,
+ ProviderError,
+)
+from plugins.ai_image.registry import get_provider, list_providers
+
+__all__ = [
+ "BaseImageProvider",
+ "GenerationRequest",
+ "GenerationResult",
+ "ProviderError",
+ "get_provider",
+ "list_providers",
+]
diff --git a/plugins/ai_image/providers/__init__.py b/plugins/ai_image/providers/__init__.py
new file mode 100644
index 0000000..bd923b2
--- /dev/null
+++ b/plugins/ai_image/providers/__init__.py
@@ -0,0 +1 @@
+"""AI image provider implementations (F.5a.4)."""
diff --git a/plugins/ai_image/providers/base.py b/plugins/ai_image/providers/base.py
new file mode 100644
index 0000000..b14f7ab
--- /dev/null
+++ b/plugins/ai_image/providers/base.py
@@ -0,0 +1,72 @@
+"""Base types for AI image providers (F.5a.4).
+
+Each concrete provider subclasses :class:`BaseImageProvider` and implements
+``generate()``. Providers return raw image bytes + MIME so the caller (e.g.
+the WordPress upload pipeline) can reuse the existing raw-upload path.
+"""
+
+from __future__ import annotations
+
+from abc import ABC, abstractmethod
+from dataclasses import dataclass, field
+from typing import Any
+
+
+class ProviderError(Exception):
+ """Stable-coded error for provider failures.
+
+ Codes follow the F.5a error taxonomy (``PROVIDER_QUOTA``,
+ ``PROVIDER_AUTH``, ``PROVIDER_BAD_REQUEST``, ``PROVIDER_UNAVAILABLE``,
+ ``PROVIDER_TIMEOUT``). ``details`` is JSON-serialisable.
+ """
+
+ def __init__(self, code: str, message: str, details: dict[str, Any] | None = None):
+ super().__init__(message)
+ self.code = code
+ self.message = message
+ self.details = details or {}
+
+ def to_dict(self) -> dict[str, Any]:
+ return {"error_code": self.code, "message": self.message, "details": self.details}
+
+
+@dataclass
+class GenerationRequest:
+ """Normalised generation parameters.
+
+ Not every provider uses every field — unknown fields are ignored.
+ """
+
+ prompt: str
+ size: str = "1024x1024"
+ quality: str = "standard"
+ model: str | None = None
+ negative_prompt: str | None = None
+ extras: dict[str, Any] = field(default_factory=dict)
+
+
+@dataclass
+class GenerationResult:
+ """Raw bytes + metadata returned by a provider."""
+
+ data: bytes
+ mime: str
+ filename: str
+ meta: dict[str, Any] = field(default_factory=dict)
+ cost_usd: float | None = None
+
+
+class BaseImageProvider(ABC):
+ """Abstract provider: turns ``(api_key, request)`` into image bytes."""
+
+ name: str = "base"
+
+ @abstractmethod
+ async def generate(self, api_key: str, request: GenerationRequest) -> GenerationResult:
+ """Call the provider API and return image bytes + metadata.
+
+ Implementations should raise :class:`ProviderError` with a stable
+ code for predictable client handling. Network retries for transient
+ failures (429 / 5xx) should happen inside the implementation.
+ """
+ raise NotImplementedError
diff --git a/plugins/ai_image/providers/openai.py b/plugins/ai_image/providers/openai.py
new file mode 100644
index 0000000..65cd03f
--- /dev/null
+++ b/plugins/ai_image/providers/openai.py
@@ -0,0 +1,184 @@
+"""OpenAI image-generation provider (DALL-E 3 + gpt-image-1).
+
+DALL-E 3 returns a time-limited URL (~1h TTL). gpt-image-1 can return
+``b64_json`` directly. In both cases we return the raw bytes immediately
+to the caller so the 1h URL expiry is never a concern downstream.
+"""
+
+from __future__ import annotations
+
+import asyncio
+import base64
+import logging
+from typing import Any
+
+import aiohttp
+
+from plugins.ai_image.providers.base import (
+ BaseImageProvider,
+ GenerationRequest,
+ GenerationResult,
+ ProviderError,
+)
+
+_logger = logging.getLogger("mcphub.ai_image.openai")
+
+_API_URL = "https://api.openai.com/v1/images/generations"
+_DEFAULT_MODEL = "dall-e-3"
+_RETRY_STATUS = {429, 500, 502, 503, 504}
+_MAX_RETRIES = 3
+_REQUEST_TIMEOUT = 90
+
+# Rough per-image pricing (USD) for audit logging. These are documented
+# public list prices at the time of writing and may drift — treat as
+# ballpark for cost dashboards, not billing.
+_COST_TABLE: dict[tuple[str, str, str], float] = {
+ ("dall-e-3", "1024x1024", "standard"): 0.040,
+ ("dall-e-3", "1024x1024", "hd"): 0.080,
+ ("dall-e-3", "1792x1024", "standard"): 0.080,
+ ("dall-e-3", "1024x1792", "standard"): 0.080,
+ ("dall-e-3", "1792x1024", "hd"): 0.120,
+ ("dall-e-3", "1024x1792", "hd"): 0.120,
+ ("gpt-image-1", "1024x1024", "low"): 0.011,
+ ("gpt-image-1", "1024x1024", "medium"): 0.042,
+ ("gpt-image-1", "1024x1024", "high"): 0.167,
+}
+
+
+def _estimate_cost(model: str, size: str, quality: str) -> float | None:
+ return _COST_TABLE.get((model, size, quality))
+
+
+class OpenAIProvider(BaseImageProvider):
+ name = "openai"
+
+ async def generate(self, api_key: str, request: GenerationRequest) -> GenerationResult:
+ if not api_key:
+ raise ProviderError("PROVIDER_AUTH", "OpenAI API key is missing.")
+
+ model = request.model or _DEFAULT_MODEL
+ payload: dict[str, Any] = {
+ "model": model,
+ "prompt": request.prompt,
+ "size": request.size,
+ "n": 1,
+ }
+ if model == "dall-e-3":
+ payload["quality"] = request.quality or "standard"
+ payload["response_format"] = "url"
+ else:
+ payload["response_format"] = "b64_json"
+ if request.quality:
+ payload["quality"] = request.quality
+
+ data, meta = await self._post_with_retry(api_key, payload)
+
+ items = data.get("data") or []
+ if not items:
+ raise ProviderError("PROVIDER_BAD_RESPONSE", "OpenAI returned no images.")
+ first = items[0]
+
+ if first.get("b64_json"):
+ image_bytes = base64.b64decode(first["b64_json"])
+ elif first.get("url"):
+ image_bytes = await _fetch_url(first["url"])
+ else:
+ raise ProviderError(
+ "PROVIDER_BAD_RESPONSE",
+ "OpenAI response contained no b64_json or url.",
+ )
+
+ meta = dict(meta)
+ if first.get("revised_prompt"):
+ meta["revised_prompt"] = first["revised_prompt"]
+ meta["model"] = model
+ meta["size"] = request.size
+
+ return GenerationResult(
+ data=image_bytes,
+ mime="image/png",
+ filename=f"openai-{model}.png",
+ meta=meta,
+ cost_usd=_estimate_cost(model, request.size, request.quality or "standard"),
+ )
+
+ async def _post_with_retry(
+ self, api_key: str, payload: dict[str, Any]
+ ) -> tuple[dict[str, Any], dict[str, Any]]:
+ headers = {
+ "Authorization": f"Bearer {api_key}",
+ "Content-Type": "application/json",
+ }
+ last_error: str = ""
+ delay = 1.0
+ timeout = aiohttp.ClientTimeout(total=_REQUEST_TIMEOUT)
+ async with aiohttp.ClientSession(timeout=timeout) as session:
+ for attempt in range(1, _MAX_RETRIES + 1):
+ try:
+ async with session.post(_API_URL, json=payload, headers=headers) as resp:
+ if resp.status == 200:
+ body = await resp.json()
+ return body, {"attempt": attempt, "status": 200}
+ text = await resp.text()
+ last_error = text[:500]
+ if resp.status == 401:
+ raise ProviderError(
+ "PROVIDER_AUTH",
+ "OpenAI rejected the API key (401).",
+ {"body": last_error},
+ )
+ if resp.status == 400:
+ raise ProviderError(
+ "PROVIDER_BAD_REQUEST",
+ f"OpenAI rejected request (400): {last_error}",
+ {"body": last_error},
+ )
+ if resp.status == 429 and attempt < _MAX_RETRIES:
+ _logger.warning("OpenAI 429, retry %d", attempt)
+ await asyncio.sleep(delay)
+ delay *= 2
+ continue
+ if resp.status in _RETRY_STATUS and attempt < _MAX_RETRIES:
+ _logger.warning("OpenAI %d, retry %d", resp.status, attempt)
+ await asyncio.sleep(delay)
+ delay *= 2
+ continue
+ if resp.status == 429:
+ raise ProviderError(
+ "PROVIDER_QUOTA",
+ "OpenAI quota/rate-limit hit after retries.",
+ {"status": 429, "body": last_error},
+ )
+ raise ProviderError(
+ "PROVIDER_UNAVAILABLE",
+ f"OpenAI upstream error HTTP {resp.status}.",
+ {"status": resp.status, "body": last_error},
+ )
+ except TimeoutError as exc:
+ last_error = f"timeout: {exc}"
+ if attempt < _MAX_RETRIES:
+ await asyncio.sleep(delay)
+ delay *= 2
+ continue
+ raise ProviderError(
+ "PROVIDER_TIMEOUT",
+ "OpenAI request timed out after retries.",
+ ) from exc
+ raise ProviderError(
+ "PROVIDER_UNAVAILABLE",
+ f"OpenAI call failed after {_MAX_RETRIES} attempts: {last_error}",
+ )
+
+
+async def _fetch_url(url: str) -> bytes:
+ """Fetch a DALL-E URL immediately. URLs expire in ~1h, so no caching."""
+ timeout = aiohttp.ClientTimeout(total=_REQUEST_TIMEOUT)
+ async with aiohttp.ClientSession(timeout=timeout) as session:
+ async with session.get(url) as resp:
+ if resp.status != 200:
+ raise ProviderError(
+ "PROVIDER_BAD_RESPONSE",
+ f"Failed to fetch OpenAI image URL (HTTP {resp.status}).",
+ {"status": resp.status},
+ )
+ return await resp.read()
diff --git a/plugins/ai_image/providers/openrouter.py b/plugins/ai_image/providers/openrouter.py
new file mode 100644
index 0000000..82edc27
--- /dev/null
+++ b/plugins/ai_image/providers/openrouter.py
@@ -0,0 +1,474 @@
+"""OpenRouter image-generation provider.
+
+OpenRouter is an aggregator that routes requests to many model vendors.
+For image generation, the usable path is their chat-completions endpoint
+with ``modalities=["image","text"]`` — models like
+``google/gemini-2.5-flash-image`` (a.k.a. "Nano Banana") return the
+generated image as a ``data:``-prefixed base64 URL inside the message
+``images`` array; other models may return ``image_url``-style pointers.
+
+Why this provider is worth adding:
+
+* Unlocks Gemini image models without needing a Google Cloud project.
+* Lets users concentrate AI spend on a single OpenRouter key rather
+ than managing separate OpenAI / Stability / Replicate accounts.
+* Supported models (as of F.X.fix 2026-04-18):
+ - ``google/gemini-2.5-flash-image`` (default, GA)
+ - ``google/gemini-2.5-flash-image-preview`` (DEPRECATED; 404 on
+ fresh OpenRouter accounts — kept as a recognised alias so we can
+ emit ``PROVIDER_MODEL_DEPRECATED`` with a clear hint)
+ - Any other OpenRouter model that returns image parts in
+ ``message.images[]`` — the parser is tolerant of newer entries.
+
+The default model is picked to cover the most common use case (hero
+images for WordPress / WooCommerce posts); callers can override via
+``GenerationRequest.model``.
+"""
+
+from __future__ import annotations
+
+import asyncio
+import base64
+import logging
+import os
+import time
+from typing import Any
+
+import aiohttp
+
+from plugins.ai_image.providers.base import (
+ BaseImageProvider,
+ GenerationRequest,
+ GenerationResult,
+ ProviderError,
+)
+
+_logger = logging.getLogger("mcphub.ai_image.openrouter")
+
+_API_URL = "https://openrouter.ai/api/v1/chat/completions"
+_MODELS_URL = "https://openrouter.ai/api/v1/models"
+# GA endpoint. The preview alias (...-image-preview) was deprecated after
+# Google promoted the model to GA — new OpenRouter accounts get 404 on
+# the preview ID.
+_DEFAULT_MODEL = "google/gemini-2.5-flash-image"
+_DEPRECATED_MODELS: dict[str, str] = {
+ "google/gemini-2.5-flash-image-preview": "google/gemini-2.5-flash-image",
+}
+_RETRY_STATUS = {429, 500, 502, 503, 504}
+_MAX_RETRIES = 3
+_REQUEST_TIMEOUT = 120
+
+# USD-per-image price table. Figures are conservative upper bounds from
+# public OpenRouter pricing (2026-04); operators who negotiate custom
+# rates can override via ``OPENROUTER_PRICING_OVERRIDE`` (JSON map of
+# model_id -> price USD).
+_MODEL_PRICING: dict[str, float] = {
+ "google/gemini-2.5-flash-image": 0.00039,
+ "google/gemini-2.5-flash-image-preview": 0.00039,
+ "google/imagen-3.0-generate": 0.04,
+ "google/imagen-3.0-fast": 0.02,
+ "openai/dall-e-3": 0.04,
+ "openai/dall-e-3-hd": 0.08,
+ "openai/gpt-image-1": 0.017,
+ "black-forest-labs/flux-1.1-pro": 0.04,
+ "black-forest-labs/flux-pro": 0.055,
+ "black-forest-labs/flux-schnell": 0.003,
+ "stability-ai/stable-diffusion-3.5-large": 0.065,
+}
+
+
+def _pricing_table() -> dict[str, float]:
+ """Return the effective price table, merging env override if present."""
+ import json as _json
+
+ override = os.environ.get("OPENROUTER_PRICING_OVERRIDE", "").strip()
+ if not override:
+ return _MODEL_PRICING
+ try:
+ parsed = _json.loads(override)
+ except Exception as exc: # noqa: BLE001
+ _logger.warning("OPENROUTER_PRICING_OVERRIDE is not valid JSON: %s", exc)
+ return _MODEL_PRICING
+ if not isinstance(parsed, dict):
+ return _MODEL_PRICING
+ merged = dict(_MODEL_PRICING)
+ for k, v in parsed.items():
+ try:
+ merged[str(k)] = float(v)
+ except (TypeError, ValueError):
+ continue
+ return merged
+
+
+def _cost_for(model: str) -> float | None:
+ """Return USD cost for a known model, else None (logs at debug)."""
+ price = _pricing_table().get(model)
+ if price is None:
+ _logger.debug("openrouter: no pricing entry for model %r", model)
+ return price
+
+
+# Module-level cache for list_image_models() — 1h TTL is plenty; model
+# catalog drift is measured in days, not minutes.
+_MODELS_CACHE_TTL_SECONDS = 3600
+_models_cache: dict[str, Any] = {"fetched_at": 0.0, "payload": None}
+
+
+class OpenRouterProvider(BaseImageProvider):
+ name = "openrouter"
+
+ async def generate(self, api_key: str, request: GenerationRequest) -> GenerationResult:
+ if not api_key:
+ raise ProviderError("PROVIDER_AUTH", "OpenRouter API key is missing.")
+
+ requested_model = request.model or _DEFAULT_MODEL
+ replacement = _DEPRECATED_MODELS.get(requested_model)
+ if replacement is not None:
+ raise ProviderError(
+ "PROVIDER_MODEL_DEPRECATED",
+ (
+ f"OpenRouter model '{requested_model}' is deprecated. "
+ f"Use '{replacement}' instead."
+ ),
+ {"requested_model": requested_model, "replacement_model": replacement},
+ )
+ model = requested_model
+
+ # Chat-completions shape with image modality declared. The
+ # prompt is sent as the single user message; size hints are
+ # advisory — Gemini picks an output size internally.
+ user_content: str = request.prompt
+ if request.negative_prompt:
+ user_content = f"{user_content}\n\nAvoid: {request.negative_prompt}"
+
+ payload: dict[str, Any] = {
+ "model": model,
+ "messages": [{"role": "user", "content": user_content}],
+ "modalities": ["image", "text"],
+ }
+
+ body = await self._post_with_retry(api_key, payload)
+
+ image_url = _extract_image_url(body)
+ if not image_url:
+ raise ProviderError(
+ "PROVIDER_BAD_RESPONSE",
+ "OpenRouter response contained no image data. Is the "
+ f"model '{model}' configured for image output?",
+ {"model": model},
+ )
+
+ image_bytes, mime = await _materialise_image(image_url)
+ if not image_bytes:
+ raise ProviderError(
+ "PROVIDER_BAD_RESPONSE",
+ "OpenRouter image URL could not be fetched / decoded.",
+ {"model": model},
+ )
+
+ # Derive a useful filename suffix from the mime type.
+ ext = {"image/png": "png", "image/jpeg": "jpg", "image/webp": "webp"}.get(mime, "png")
+ meta: dict[str, Any] = {"model": model, "size": request.size}
+ # Attach OpenRouter usage if present so audit logs can correlate.
+ usage = body.get("usage") or {}
+ if isinstance(usage, dict):
+ for k in ("prompt_tokens", "completion_tokens", "total_tokens"):
+ if k in usage:
+ meta[k] = usage[k]
+
+ return GenerationResult(
+ data=image_bytes,
+ mime=mime,
+ filename=f"openrouter-{model.replace('/', '-')}.{ext}",
+ meta=meta,
+ cost_usd=_cost_for(model),
+ )
+
+ async def _post_with_retry(self, api_key: str, payload: dict[str, Any]) -> dict[str, Any]:
+ headers = {
+ "Authorization": f"Bearer {api_key}",
+ "Content-Type": "application/json",
+ # OpenRouter recommends identifying the calling app for
+ # observability. Using a stable string so admins can spot
+ # traffic from MCPHub in their OpenRouter dashboard.
+ "HTTP-Referer": "https://github.com/airano-ir/mcphub",
+ "X-Title": "MCPHub",
+ }
+ last_error: str = ""
+ delay = 1.0
+ timeout = aiohttp.ClientTimeout(total=_REQUEST_TIMEOUT)
+ async with aiohttp.ClientSession(timeout=timeout) as session:
+ for attempt in range(1, _MAX_RETRIES + 1):
+ try:
+ async with session.post(_API_URL, json=payload, headers=headers) as resp:
+ if resp.status == 200:
+ return await resp.json()
+ text = await resp.text()
+ last_error = text[:500]
+ if resp.status == 401:
+ raise ProviderError(
+ "PROVIDER_AUTH",
+ "OpenRouter rejected the API key (401).",
+ {"body": last_error},
+ )
+ if resp.status == 400:
+ raise ProviderError(
+ "PROVIDER_BAD_REQUEST",
+ f"OpenRouter rejected request (400): {last_error}",
+ {"body": last_error},
+ )
+ if resp.status in _RETRY_STATUS and attempt < _MAX_RETRIES:
+ _logger.warning(
+ "OpenRouter %d, retry %d/%d", resp.status, attempt, _MAX_RETRIES
+ )
+ await asyncio.sleep(delay)
+ delay *= 2
+ continue
+ if resp.status == 429:
+ raise ProviderError(
+ "PROVIDER_QUOTA",
+ "OpenRouter quota/rate-limit hit after retries.",
+ {"status": 429, "body": last_error},
+ )
+ raise ProviderError(
+ "PROVIDER_UNAVAILABLE",
+ f"OpenRouter upstream error HTTP {resp.status}.",
+ {"status": resp.status, "body": last_error},
+ )
+ except TimeoutError as exc:
+ last_error = f"timeout: {exc}"
+ if attempt < _MAX_RETRIES:
+ await asyncio.sleep(delay)
+ delay *= 2
+ continue
+ raise ProviderError(
+ "PROVIDER_TIMEOUT",
+ "OpenRouter request timed out after retries.",
+ ) from exc
+ raise ProviderError(
+ "PROVIDER_UNAVAILABLE",
+ f"OpenRouter call failed after {_MAX_RETRIES} attempts: {last_error}",
+ )
+
+ async def list_image_models(
+ self, api_key: str | None = None, *, force: bool = False
+ ) -> list[dict[str, Any]]:
+ """Return OpenRouter catalog entries whose modality includes image.
+
+ The catalog drifts slowly (days) so we cache the whole filtered
+ list at module scope for 1h. ``api_key`` is optional — the
+ ``/v1/models`` endpoint is public, but authenticated callers see
+ per-account availability flags. On any upstream error the
+ previous cache (if any) is returned; otherwise an empty list.
+ """
+ now = time.time()
+ payload = _models_cache.get("payload")
+ fetched_at = _models_cache.get("fetched_at", 0.0)
+ if not force and payload is not None and (now - fetched_at) < _MODELS_CACHE_TTL_SECONDS:
+ return list(payload)
+
+ headers = {
+ "HTTP-Referer": "https://github.com/airano-ir/mcphub",
+ "X-Title": "MCPHub",
+ }
+ if api_key:
+ headers["Authorization"] = f"Bearer {api_key}"
+ timeout = aiohttp.ClientTimeout(total=_REQUEST_TIMEOUT)
+ try:
+ async with aiohttp.ClientSession(timeout=timeout) as session:
+ async with session.get(_MODELS_URL, headers=headers) as resp:
+ if resp.status != 200:
+ _logger.warning("openrouter /v1/models HTTP %s", resp.status)
+ return list(payload or [])
+ body = await resp.json()
+ except Exception as exc: # noqa: BLE001
+ _logger.warning("openrouter /v1/models fetch failed: %s", exc)
+ return list(payload or [])
+
+ raw_models = body.get("data") or []
+ pricing = _pricing_table()
+ filtered: list[dict[str, Any]] = []
+ for item in raw_models:
+ if not isinstance(item, dict):
+ continue
+ if not _model_is_image(item):
+ continue
+ model_id = str(item.get("id") or "")
+ if not model_id or model_id in _DEPRECATED_MODELS:
+ continue
+ filtered.append(
+ {
+ "id": model_id,
+ "name": item.get("name") or model_id,
+ "description": (item.get("description") or "")[:400],
+ "context_length": item.get("context_length"),
+ "input_modalities": _modalities_of(item, "input"),
+ "output_modalities": _modalities_of(item, "output"),
+ "price_per_image_usd": pricing.get(model_id),
+ }
+ )
+
+ filtered.sort(key=lambda m: m["id"])
+ _models_cache["fetched_at"] = now
+ _models_cache["payload"] = filtered
+ return list(filtered)
+
+
+def _modalities_of(item: dict[str, Any], side: str) -> list[str]:
+ """Extract input/output modality list from an OpenRouter model entry."""
+ arch = item.get("architecture") or {}
+ if isinstance(arch, dict):
+ key = "input_modalities" if side == "input" else "output_modalities"
+ mods = arch.get(key)
+ if isinstance(mods, list):
+ return [str(m) for m in mods if isinstance(m, str)]
+ modality = arch.get("modality")
+ if isinstance(modality, str):
+ parts = modality.split("->")
+ if len(parts) == 2:
+ idx = 0 if side == "input" else 1
+ return [p.strip() for p in parts[idx].split("+") if p.strip()]
+ return []
+
+
+def _model_is_image(item: dict[str, Any]) -> bool:
+ """True when an OpenRouter model entry emits image output."""
+ out = _modalities_of(item, "output")
+ if any("image" in m.lower() for m in out):
+ return True
+ # Legacy catalog rows without structured modalities — fall back to a
+ # name/description heuristic so we don't silently drop valid models.
+ blob = " ".join(str(item.get(k, "") or "") for k in ("id", "name", "description")).lower()
+ return "image" in blob and "gen" in blob
+
+
+def _extract_image_url(body: dict[str, Any]) -> str | None:
+ """Pull the first image URL out of an OpenRouter chat response.
+
+ The wire format varies by model vendor. We recognise:
+
+ * ``choices[0].message.images[i].image_url.url`` — a string that
+ may be a ``data:image/...;base64,...`` URL or an https URL.
+ * ``choices[0].message.content`` when it is a list of parts, each
+ of shape ``{type: "image_url", image_url: {url: ...}}`` —
+ OpenAI-compatible multimodal reply shape.
+
+ Returns None if no usable reference was found.
+ """
+ choices = body.get("choices") or []
+ if not isinstance(choices, list) or not choices:
+ return None
+ message = (choices[0] or {}).get("message") or {}
+
+ # Shape 1: message.images (Gemini via OpenRouter).
+ for entry in message.get("images") or []:
+ url = _image_url_from_entry(entry)
+ if url:
+ return url
+
+ # Shape 2: message.content as list of parts.
+ content = message.get("content")
+ if isinstance(content, list):
+ for part in content:
+ if not isinstance(part, dict):
+ continue
+ if part.get("type") == "image_url":
+ url = _image_url_from_entry(part)
+ if url:
+ return url
+
+ return None
+
+
+def _image_url_from_entry(entry: Any) -> str | None:
+ """Handle both ``{image_url: {url: ...}}`` and ``{image_url: "..."}``."""
+ if not isinstance(entry, dict):
+ return None
+ iu = entry.get("image_url")
+ if isinstance(iu, str):
+ return iu
+ if isinstance(iu, dict):
+ u = iu.get("url")
+ return u if isinstance(u, str) else None
+ # Some providers emit a bare "url" key.
+ u = entry.get("url")
+ return u if isinstance(u, str) else None
+
+
+async def _materialise_image(url: str) -> tuple[bytes, str]:
+ """Turn an image URL (data: or https) into raw bytes + MIME.
+
+ Returns ``(b"", "")`` on any failure so the caller can emit a
+ uniform ``PROVIDER_BAD_RESPONSE`` error.
+ """
+ if url.startswith("data:"):
+ try:
+ header, payload = url.split(",", 1)
+ except ValueError:
+ return b"", ""
+ mime = "image/png"
+ rest = header[len("data:") :] if header.startswith("data:") else header
+ if ";" in rest:
+ mime = rest.split(";", 1)[0] or mime
+ elif rest:
+ mime = rest
+ if "base64" in header:
+ try:
+ return base64.b64decode(payload), mime
+ except Exception:
+ return b"", mime
+ return payload.encode("latin-1", errors="ignore"), mime
+
+ if url.startswith("http://") or url.startswith("https://"):
+ timeout = aiohttp.ClientTimeout(total=_REQUEST_TIMEOUT)
+ async with aiohttp.ClientSession(timeout=timeout) as session:
+ async with session.get(url) as resp:
+ if resp.status != 200:
+ return b"", ""
+ mime = resp.headers.get("Content-Type", "image/png").split(";", 1)[0].strip()
+ return await resp.read(), mime or "image/png"
+
+ return b"", ""
+
+
+# ---------------------------------------------------------------------------
+# Starlette handler — GET /api/providers/openrouter/models
+# ---------------------------------------------------------------------------
+
+
+async def api_openrouter_models(request: Any) -> Any:
+ """Return the cached OpenRouter image-model catalog.
+
+ Auth: same OAuth user session guard as the other /api/ endpoints.
+ Query params:
+ * ``force=1`` — bypass the 1h module cache.
+ * ``site_id=`` — if present, the user's OpenRouter key for
+ that site is used as bearer so availability flags reflect the
+ operator's account; otherwise the endpoint is fetched unauthed.
+ """
+ from starlette.responses import JSONResponse
+
+ from core.dashboard.routes import _require_user_session
+
+ user_session, redirect = _require_user_session(request)
+ if redirect or user_session is None:
+ return JSONResponse({"ok": False, "error": "unauthorized"}, status_code=401)
+
+ force = request.query_params.get("force") in {"1", "true", "True"}
+ api_key: str | None = None
+ site_id = (request.query_params.get("site_id") or "").strip()
+ if site_id:
+ try:
+ from core.database import get_database
+ from core.site_api import get_site_provider_key
+
+ db = get_database()
+ site = await db.get_site(site_id, user_session["user_id"])
+ if site is not None:
+ api_key = await get_site_provider_key(site_id, "openrouter")
+ except Exception as exc: # noqa: BLE001
+ _logger.debug("openrouter models: site key lookup skipped: %s", exc)
+
+ provider = OpenRouterProvider()
+ models = await provider.list_image_models(api_key=api_key, force=force)
+ return JSONResponse({"ok": True, "provider": "openrouter", "models": models})
diff --git a/plugins/ai_image/providers/replicate.py b/plugins/ai_image/providers/replicate.py
new file mode 100644
index 0000000..b109442
--- /dev/null
+++ b/plugins/ai_image/providers/replicate.py
@@ -0,0 +1,204 @@
+"""Replicate provider (Flux family + other image models).
+
+Replicate runs predictions asynchronously: POST to ``/v1/predictions``
+returns a job id, and we poll ``/v1/predictions/{id}`` until status is
+``succeeded`` or ``failed``. The final output is a URL (or list of URLs)
+that must be fetched to get bytes.
+"""
+
+from __future__ import annotations
+
+import asyncio
+import logging
+from typing import Any
+
+import aiohttp
+
+from plugins.ai_image.providers.base import (
+ BaseImageProvider,
+ GenerationRequest,
+ GenerationResult,
+ ProviderError,
+)
+
+_logger = logging.getLogger("mcphub.ai_image.replicate")
+
+_PREDICTIONS_URL = "https://api.replicate.com/v1/predictions"
+_DEFAULT_MODEL = "black-forest-labs/flux-schnell"
+_MAX_RETRIES = 3
+_POLL_INTERVAL = 2.0
+_POLL_TIMEOUT = 180
+_REQUEST_TIMEOUT = 60
+_RETRY_STATUS = {429, 500, 502, 503, 504}
+
+_COST_TABLE: dict[str, float] = {
+ "black-forest-labs/flux-schnell": 0.003,
+ "black-forest-labs/flux-dev": 0.025,
+ "black-forest-labs/flux-pro": 0.055,
+}
+
+
+def _aspect_from_size(size: str) -> str:
+ """Replicate's Flux models accept an aspect_ratio enum, not WxH."""
+ mapping = {
+ "1024x1024": "1:1",
+ "1344x768": "16:9",
+ "768x1344": "9:16",
+ "1152x896": "3:2",
+ "896x1152": "2:3",
+ }
+ return mapping.get(size, "1:1")
+
+
+class ReplicateProvider(BaseImageProvider):
+ name = "replicate"
+
+ async def generate(self, api_key: str, request: GenerationRequest) -> GenerationResult:
+ if not api_key:
+ raise ProviderError("PROVIDER_AUTH", "Replicate API token is missing.")
+
+ model = request.model or _DEFAULT_MODEL
+ payload: dict[str, Any] = {
+ "model": model,
+ "input": {
+ "prompt": request.prompt,
+ "aspect_ratio": _aspect_from_size(request.size),
+ **(request.extras or {}),
+ },
+ }
+ if request.negative_prompt:
+ payload["input"]["negative_prompt"] = request.negative_prompt
+
+ prediction = await self._create_prediction(api_key, payload)
+ final = await self._poll_until_done(api_key, prediction)
+
+ output = final.get("output")
+ url = _first_url(output)
+ if not url:
+ raise ProviderError(
+ "PROVIDER_BAD_RESPONSE",
+ "Replicate prediction finished without an image URL.",
+ {"prediction": final},
+ )
+ image_bytes = await _fetch_url(url)
+
+ meta = {
+ "model": model,
+ "prediction_id": final.get("id"),
+ "status": final.get("status"),
+ "metrics": final.get("metrics", {}),
+ }
+ return GenerationResult(
+ data=image_bytes,
+ mime="image/webp" if url.endswith(".webp") else "image/png",
+ filename=f"replicate-{model.split('/')[-1]}.png",
+ meta=meta,
+ cost_usd=_COST_TABLE.get(model),
+ )
+
+ async def _create_prediction(self, api_key: str, payload: dict[str, Any]) -> dict[str, Any]:
+ headers = {
+ "Authorization": f"Bearer {api_key}",
+ "Content-Type": "application/json",
+ "Prefer": "wait=0",
+ }
+ delay = 1.0
+ last_error = ""
+ timeout = aiohttp.ClientTimeout(total=_REQUEST_TIMEOUT)
+ async with aiohttp.ClientSession(timeout=timeout) as session:
+ for attempt in range(1, _MAX_RETRIES + 1):
+ async with session.post(_PREDICTIONS_URL, json=payload, headers=headers) as resp:
+ if resp.status in (200, 201):
+ return await resp.json()
+ text = await resp.text()
+ last_error = text[:500]
+ if resp.status in (401, 403):
+ raise ProviderError(
+ "PROVIDER_AUTH",
+ f"Replicate rejected auth ({resp.status}).",
+ {"body": last_error},
+ )
+ if resp.status == 422:
+ raise ProviderError(
+ "PROVIDER_BAD_REQUEST",
+ f"Replicate rejected request: {last_error}",
+ {"body": last_error},
+ )
+ if resp.status in _RETRY_STATUS and attempt < _MAX_RETRIES:
+ _logger.warning("Replicate %d, retry %d", resp.status, attempt)
+ await asyncio.sleep(delay)
+ delay *= 2
+ continue
+ if resp.status == 429:
+ raise ProviderError(
+ "PROVIDER_QUOTA",
+ "Replicate rate-limit hit.",
+ {"status": 429, "body": last_error},
+ )
+ raise ProviderError(
+ "PROVIDER_UNAVAILABLE",
+ f"Replicate HTTP {resp.status}.",
+ {"status": resp.status, "body": last_error},
+ )
+ raise ProviderError(
+ "PROVIDER_UNAVAILABLE",
+ f"Replicate create_prediction failed after retries: {last_error}",
+ )
+
+ async def _poll_until_done(self, api_key: str, prediction: dict[str, Any]) -> dict[str, Any]:
+ headers = {"Authorization": f"Bearer {api_key}"}
+ poll_url = (prediction.get("urls") or {}).get("get") or (
+ f"{_PREDICTIONS_URL}/{prediction.get('id')}"
+ )
+ timeout = aiohttp.ClientTimeout(total=_REQUEST_TIMEOUT)
+ deadline = asyncio.get_event_loop().time() + _POLL_TIMEOUT
+ async with aiohttp.ClientSession(timeout=timeout) as session:
+ while True:
+ if asyncio.get_event_loop().time() > deadline:
+ raise ProviderError(
+ "PROVIDER_TIMEOUT",
+ "Replicate prediction did not finish within timeout.",
+ {"prediction_id": prediction.get("id")},
+ )
+ async with session.get(poll_url, headers=headers) as resp:
+ if resp.status != 200:
+ text = await resp.text()
+ raise ProviderError(
+ "PROVIDER_UNAVAILABLE",
+ f"Replicate poll HTTP {resp.status}.",
+ {"body": text[:500]},
+ )
+ body = await resp.json()
+ status = body.get("status")
+ if status in ("succeeded",):
+ return body
+ if status in ("failed", "canceled"):
+ raise ProviderError(
+ "PROVIDER_BAD_RESPONSE",
+ f"Replicate prediction {status}: {body.get('error')}",
+ {"prediction": body},
+ )
+ await asyncio.sleep(_POLL_INTERVAL)
+
+
+def _first_url(output: Any) -> str | None:
+ if isinstance(output, str):
+ return output
+ if isinstance(output, list) and output:
+ first = output[0]
+ if isinstance(first, str):
+ return first
+ return None
+
+
+async def _fetch_url(url: str) -> bytes:
+ timeout = aiohttp.ClientTimeout(total=_REQUEST_TIMEOUT)
+ async with aiohttp.ClientSession(timeout=timeout) as session:
+ async with session.get(url) as resp:
+ if resp.status != 200:
+ raise ProviderError(
+ "PROVIDER_BAD_RESPONSE",
+ f"Failed to fetch Replicate output (HTTP {resp.status}).",
+ {"status": resp.status},
+ )
+ return await resp.read()
diff --git a/plugins/ai_image/providers/stability.py b/plugins/ai_image/providers/stability.py
new file mode 100644
index 0000000..bc2be89
--- /dev/null
+++ b/plugins/ai_image/providers/stability.py
@@ -0,0 +1,154 @@
+"""Stability AI image generation provider (Stable Image Core / Ultra).
+
+Uses the v2beta generate endpoint with ``Accept: image/*`` to get raw
+bytes back directly (no JSON indirection).
+"""
+
+from __future__ import annotations
+
+import asyncio
+import logging
+from typing import Any
+
+import aiohttp
+
+from plugins.ai_image.providers.base import (
+ BaseImageProvider,
+ GenerationRequest,
+ GenerationResult,
+ ProviderError,
+)
+
+_logger = logging.getLogger("mcphub.ai_image.stability")
+
+_MODEL_ENDPOINTS = {
+ "core": "https://api.stability.ai/v2beta/stable-image/generate/core",
+ "ultra": "https://api.stability.ai/v2beta/stable-image/generate/ultra",
+ "sd3": "https://api.stability.ai/v2beta/stable-image/generate/sd3",
+}
+_DEFAULT_MODEL = "core"
+_MAX_RETRIES = 3
+_REQUEST_TIMEOUT = 120
+_RETRY_STATUS = {429, 500, 502, 503, 504}
+
+_COST_TABLE: dict[str, float] = {
+ "core": 0.03,
+ "ultra": 0.08,
+ "sd3": 0.065,
+}
+
+
+def _size_to_aspect(size: str) -> str:
+ """Map a WxH size string to the Stability aspect_ratio enum."""
+ mapping = {
+ "1024x1024": "1:1",
+ "1152x896": "21:9",
+ "1216x832": "3:2",
+ "1344x768": "16:9",
+ "768x1344": "9:16",
+ "832x1216": "2:3",
+ "1024x576": "16:9",
+ "512x512": "1:1",
+ }
+ return mapping.get(size, "1:1")
+
+
+class StabilityProvider(BaseImageProvider):
+ name = "stability"
+
+ async def generate(self, api_key: str, request: GenerationRequest) -> GenerationResult:
+ if not api_key:
+ raise ProviderError("PROVIDER_AUTH", "Stability API key is missing.")
+
+ model = (request.model or _DEFAULT_MODEL).lower()
+ endpoint = _MODEL_ENDPOINTS.get(model)
+ if endpoint is None:
+ raise ProviderError(
+ "PROVIDER_BAD_REQUEST",
+ f"Unknown Stability model '{model}'. " f"Allowed: {', '.join(_MODEL_ENDPOINTS)}.",
+ )
+
+ form = aiohttp.FormData()
+ form.add_field("prompt", request.prompt)
+ form.add_field("output_format", "png")
+ form.add_field("aspect_ratio", _size_to_aspect(request.size))
+ if request.negative_prompt:
+ form.add_field("negative_prompt", request.negative_prompt)
+
+ headers = {
+ "Authorization": f"Bearer {api_key}",
+ "Accept": "image/*",
+ }
+ data, meta = await self._post_with_retry(endpoint, form, headers)
+
+ meta = dict(meta)
+ meta["model"] = model
+ meta["aspect_ratio"] = _size_to_aspect(request.size)
+ return GenerationResult(
+ data=data,
+ mime="image/png",
+ filename=f"stability-{model}.png",
+ meta=meta,
+ cost_usd=_COST_TABLE.get(model),
+ )
+
+ async def _post_with_retry(
+ self,
+ endpoint: str,
+ form: aiohttp.FormData,
+ headers: dict[str, str],
+ ) -> tuple[bytes, dict[str, Any]]:
+ last_error: str = ""
+ delay = 1.0
+ timeout = aiohttp.ClientTimeout(total=_REQUEST_TIMEOUT)
+ async with aiohttp.ClientSession(timeout=timeout) as session:
+ for attempt in range(1, _MAX_RETRIES + 1):
+ try:
+ async with session.post(endpoint, data=form, headers=headers) as resp:
+ if resp.status == 200:
+ body = await resp.read()
+ return body, {"attempt": attempt, "status": 200}
+ text = await resp.text()
+ last_error = text[:500]
+ if resp.status == 401 or resp.status == 403:
+ raise ProviderError(
+ "PROVIDER_AUTH",
+ f"Stability rejected auth ({resp.status}).",
+ {"body": last_error},
+ )
+ if resp.status == 400:
+ raise ProviderError(
+ "PROVIDER_BAD_REQUEST",
+ f"Stability rejected request: {last_error}",
+ {"body": last_error},
+ )
+ if resp.status in _RETRY_STATUS and attempt < _MAX_RETRIES:
+ _logger.warning("Stability %d, retry %d", resp.status, attempt)
+ await asyncio.sleep(delay)
+ delay *= 2
+ continue
+ if resp.status == 429:
+ raise ProviderError(
+ "PROVIDER_QUOTA",
+ "Stability quota/rate-limit hit.",
+ {"status": 429, "body": last_error},
+ )
+ raise ProviderError(
+ "PROVIDER_UNAVAILABLE",
+ f"Stability upstream error HTTP {resp.status}.",
+ {"status": resp.status, "body": last_error},
+ )
+ except TimeoutError as exc:
+ last_error = f"timeout: {exc}"
+ if attempt < _MAX_RETRIES:
+ await asyncio.sleep(delay)
+ delay *= 2
+ continue
+ raise ProviderError(
+ "PROVIDER_TIMEOUT",
+ "Stability request timed out after retries.",
+ ) from exc
+ raise ProviderError(
+ "PROVIDER_UNAVAILABLE",
+ f"Stability call failed after {_MAX_RETRIES} attempts: {last_error}",
+ )
diff --git a/plugins/ai_image/registry.py b/plugins/ai_image/registry.py
new file mode 100644
index 0000000..ba2596f
--- /dev/null
+++ b/plugins/ai_image/registry.py
@@ -0,0 +1,36 @@
+"""Provider registry for AI image generation (F.5a.4).
+
+Lookup by name; raises :class:`ProviderError` with code
+``PROVIDER_UNKNOWN`` if the caller supplies a non-existent provider.
+"""
+
+from __future__ import annotations
+
+from plugins.ai_image.providers.base import BaseImageProvider, ProviderError
+from plugins.ai_image.providers.openai import OpenAIProvider
+from plugins.ai_image.providers.openrouter import OpenRouterProvider
+from plugins.ai_image.providers.replicate import ReplicateProvider
+from plugins.ai_image.providers.stability import StabilityProvider
+
+_PROVIDERS: dict[str, BaseImageProvider] = {
+ "openai": OpenAIProvider(),
+ "stability": StabilityProvider(),
+ "replicate": ReplicateProvider(),
+ "openrouter": OpenRouterProvider(),
+}
+
+
+def get_provider(name: str) -> BaseImageProvider:
+ """Return the registered provider singleton by name."""
+ try:
+ return _PROVIDERS[name]
+ except KeyError as exc:
+ raise ProviderError(
+ "PROVIDER_UNKNOWN",
+ f"Unknown provider '{name}'. Allowed: {', '.join(_PROVIDERS)}.",
+ ) from exc
+
+
+def list_providers() -> list[str]:
+ """Return all registered provider names in registration order."""
+ return list(_PROVIDERS.keys())
diff --git a/plugins/base.py b/plugins/base.py
index b679e03..e54f0a0 100644
--- a/plugins/base.py
+++ b/plugins/base.py
@@ -165,6 +165,40 @@ class BasePlugin(ABC):
"""
return {"healthy": True, "message": "Health check not implemented for this plugin"}
+ async def probe_credential_capabilities(self) -> dict[str, Any]:
+ """F.7e — report what the saved credential can actually do.
+
+ Plugins should override this to call the upstream service
+ (WordPress companion ``/capabilities``, WooCommerce
+ ``system_status``, Gitea ``/user`` header scopes, Coolify
+ ``/api/v1/teams/0``, etc.) and return the subset of capability
+ names the caller's token actually grants.
+
+ The base implementation returns a well-shaped "probe not
+ available" payload so callers can handle all plugin types
+ uniformly without special-casing the ones that haven't
+ implemented a probe yet.
+
+ Returns:
+ Dict with:
+ probe_available: bool — True if the plugin knows how to
+ query its upstream for capabilities.
+ granted: list[str] — the capabilities the credential
+ actually grants (subset of the universe the plugin
+ understands). Empty when ``probe_available=False``.
+ source: str — which endpoint / header the list came from;
+ ``"unavailable"`` when no probe is wired.
+ reason: str | None — optional human-readable diagnostic
+ when ``probe_available=False`` or when the call
+ failed in a way that still produced a usable answer.
+ """
+ return {
+ "probe_available": False,
+ "granted": [],
+ "source": "unavailable",
+ "reason": "probe_not_implemented",
+ }
+
def get_project_info(self) -> dict[str, Any]:
"""
Return basic information about this project instance.
diff --git a/plugins/gitea/client.py b/plugins/gitea/client.py
index b93be58..a11382a 100644
--- a/plugins/gitea/client.py
+++ b/plugins/gitea/client.py
@@ -206,25 +206,10 @@ class GiteaClient:
async def create_file(self, owner: str, repo: str, filepath: str, data: dict) -> dict:
"""Create a file"""
- # Encode content to base64 if not already encoded
if "content" in data:
- content = data["content"]
-
- # Check if content is already base64 encoded
- is_already_base64 = data.get("content_is_base64", False)
-
- if not is_already_base64:
- # Plain text content - encode to base64
- try:
- data["content"] = base64.b64encode(content.encode()).decode()
- except (AttributeError, UnicodeDecodeError):
- # If content is already bytes or has encoding issues, try direct encoding
- if isinstance(content, bytes):
- data["content"] = base64.b64encode(content).decode()
- else:
- raise ValueError("Content must be a string or bytes")
-
- # Remove the flag before sending to API
+ data["content"] = self._normalise_file_content(
+ data["content"], data.get("content_is_base64", False)
+ )
data.pop("content_is_base64", None)
return await self.request(
@@ -233,31 +218,63 @@ class GiteaClient:
async def update_file(self, owner: str, repo: str, filepath: str, data: dict) -> dict:
"""Update a file"""
- # Encode content to base64 if not already encoded
if "content" in data:
- content = data["content"]
-
- # Check if content is already base64 encoded
- is_already_base64 = data.get("content_is_base64", False)
-
- if not is_already_base64:
- # Plain text content - encode to base64
- try:
- data["content"] = base64.b64encode(content.encode()).decode()
- except (AttributeError, UnicodeDecodeError):
- # If content is already bytes or has encoding issues, try direct encoding
- if isinstance(content, bytes):
- data["content"] = base64.b64encode(content).decode()
- else:
- raise ValueError("Content must be a string or bytes")
-
- # Remove the flag before sending to API
+ data["content"] = self._normalise_file_content(
+ data["content"], data.get("content_is_base64", False)
+ )
data.pop("content_is_base64", None)
return await self.request(
"PUT", f"repos/{owner}/{repo}/contents/{filepath}", json_data=data
)
+ @staticmethod
+ def _normalise_file_content(content: Any, is_already_base64: bool) -> str:
+ """F.17 ergonomics: turn ``content`` into a base64 string suitable
+ for Gitea's contents endpoints, with actionable error messages.
+
+ ``is_already_base64=True`` validates that ``content`` decodes
+ cleanly and re-emits it stripped of whitespace; if the decode
+ fails, raise a message that tells the caller exactly how to
+ recover (drop ``data:`` prefix, use ``content_is_base64=False``
+ for raw text). ``is_already_base64=False`` UTF-8 + base64
+ encodes the string.
+ """
+ if is_already_base64:
+ if not isinstance(content, str):
+ raise ValueError(
+ "content_is_base64=True but content is not a string. "
+ "Pass the raw base64 text; do not wrap it in bytes / dicts."
+ )
+ stripped = content.strip()
+ if stripped.lower().startswith("data:"):
+ raise ValueError(
+ "content looks like a data: URL (starts with 'data:'). "
+ "Strip the 'data:;base64,' prefix before sending — "
+ "Gitea expects the base64 payload only."
+ )
+ try:
+ # Validate round-trip and normalise whitespace.
+ raw = base64.b64decode(stripped, validate=True)
+ return base64.b64encode(raw).decode()
+ except Exception as exc: # noqa: BLE001
+ raise ValueError(
+ "content_is_base64=True but content is not valid base64. "
+ "If you meant to send raw text, set content_is_base64=False "
+ "(the client will base64-encode it for you). Original "
+ f"decoder error: {exc}"
+ ) from exc
+
+ # Plain text / bytes → base64-encode for the caller.
+ if isinstance(content, bytes):
+ return base64.b64encode(content).decode()
+ if isinstance(content, str):
+ return base64.b64encode(content.encode("utf-8")).decode()
+ raise ValueError(
+ "content must be a string or bytes when content_is_base64=False. "
+ f"Got {type(content).__name__}."
+ )
+
async def delete_file(
self,
owner: str,
@@ -275,6 +292,186 @@ class GiteaClient:
"DELETE", f"repos/{owner}/{repo}/contents/{filepath}", json_data=data
)
+ # ------------------------------------------------------------------
+ # F.17 — ergonomics: batch file write, tree listing, search, compare,
+ # releases, fork.
+ # ------------------------------------------------------------------
+
+ async def change_files(
+ self,
+ owner: str,
+ repo: str,
+ data: dict,
+ ) -> dict:
+ """POST /repos/{owner}/{repo}/contents — apply a batch of file
+ create/update/delete operations in a single commit.
+
+ Gitea's ``files`` endpoint takes a list of ``{operation, path,
+ content, sha}`` entries. Operations: ``create`` | ``update`` |
+ ``delete``. ``content`` must be base64 for create/update.
+ """
+ return await self.request("POST", f"repos/{owner}/{repo}/contents", json_data=data)
+
+ async def get_tree(
+ self,
+ owner: str,
+ repo: str,
+ sha: str = "HEAD",
+ *,
+ recursive: bool = False,
+ page: int = 1,
+ per_page: int = 100,
+ ) -> dict:
+ """GET /repos/{owner}/{repo}/git/trees/{sha}
+
+ Returns ``{sha, url, tree: [{path, mode, type, size, sha, url}],
+ truncated}``. Set ``recursive=True`` to get the entire tree.
+ """
+ params: dict[str, Any] = {"page": page, "per_page": per_page}
+ if recursive:
+ params["recursive"] = "true"
+ return await self.request("GET", f"repos/{owner}/{repo}/git/trees/{sha}", params=params)
+
+ async def search_code(
+ self,
+ *,
+ keyword: str,
+ owner: str | None = None,
+ repo: str | None = None,
+ page: int = 1,
+ per_page: int = 30,
+ ) -> dict:
+ """Search code either across all repos (``/repos/search/code``)
+ or within a specific repo (``/repos/{owner}/{repo}/search/code``).
+
+ Returns the raw Gitea payload: ``{ok, data: [...]}``.
+ """
+ params: dict[str, Any] = {"q": keyword, "page": page, "per_page": per_page}
+ if owner and repo:
+ path = f"repos/{owner}/{repo}/search/code"
+ else:
+ path = "repos/search/code"
+ return await self.request("GET", path, params=params)
+
+ async def compare(
+ self,
+ owner: str,
+ repo: str,
+ base: str,
+ head: str,
+ ) -> dict:
+ """GET /repos/{owner}/{repo}/compare/{base}...{head}"""
+ # Gitea's compare endpoint uses ``...`` as the separator. The HTTP
+ # client URL-encodes path params, so we pre-join instead of
+ # passing them as separate path parameters.
+ spec = f"{base}...{head}"
+ return await self.request("GET", f"repos/{owner}/{repo}/compare/{spec}")
+
+ async def list_releases(
+ self,
+ owner: str,
+ repo: str,
+ *,
+ page: int = 1,
+ per_page: int = 30,
+ ) -> list[dict]:
+ """GET /repos/{owner}/{repo}/releases"""
+ return await self.request(
+ "GET",
+ f"repos/{owner}/{repo}/releases",
+ params={"page": page, "per_page": per_page},
+ )
+
+ async def create_release(
+ self,
+ owner: str,
+ repo: str,
+ data: dict,
+ ) -> dict:
+ """POST /repos/{owner}/{repo}/releases"""
+ return await self.request("POST", f"repos/{owner}/{repo}/releases", json_data=data)
+
+ async def get_release(self, owner: str, repo: str, release_id: int) -> dict:
+ return await self.request("GET", f"repos/{owner}/{repo}/releases/{release_id}")
+
+ async def delete_release(self, owner: str, repo: str, release_id: int) -> dict:
+ return await self.request("DELETE", f"repos/{owner}/{repo}/releases/{release_id}")
+
+ async def upload_release_asset(
+ self,
+ owner: str,
+ repo: str,
+ release_id: int,
+ *,
+ filename: str,
+ content_b64: str,
+ ) -> dict:
+ """Upload a release asset.
+
+ Gitea's API accepts multipart/form-data on
+ ``POST /repos/{owner}/{repo}/releases/{id}/assets?name=FILE``.
+ We accept base64 content so the tool schema stays JSON-friendly;
+ callers supply ``content_b64``. Decoded bytes go into the
+ multipart body. This bypasses ``self.request`` because that
+ helper only understands JSON bodies.
+ """
+ import aiohttp
+
+ try:
+ raw = base64.b64decode(content_b64, validate=True)
+ except Exception as exc: # noqa: BLE001
+ raise ValueError(
+ "content_b64 must be a valid base64 string. "
+ "For small text files, base64-encode the UTF-8 bytes; "
+ "do not include a 'data:' URL prefix."
+ ) from exc
+
+ url = f"{self.api_base}/repos/{owner}/{repo}/releases/{release_id}/assets"
+ headers = self._get_headers()
+ # Requests library-style: multipart/form-data with attachment field.
+ form = aiohttp.FormData()
+ form.add_field(
+ "attachment",
+ raw,
+ filename=filename,
+ content_type="application/octet-stream",
+ )
+ async with aiohttp.ClientSession() as session:
+ async with session.post(
+ url, params={"name": filename}, data=form, headers=headers
+ ) as resp:
+ text = await resp.text()
+ if resp.status >= 400:
+ raise Exception(
+ f"Gitea upload_release_asset failed ({resp.status}): {text[:500]}"
+ )
+ try:
+ import json as _json
+
+ return _json.loads(text) if text else {"success": True}
+ except Exception:
+ return {"success": True, "raw": text[:500]}
+
+ async def fork_repository(
+ self,
+ owner: str,
+ repo: str,
+ *,
+ organization: str | None = None,
+ name: str | None = None,
+ ) -> dict:
+ """POST /repos/{owner}/{repo}/forks
+
+ ``organization``: target org (omit to fork under the caller).
+ ``name``: custom name for the fork.
+ """
+ payload: dict[str, Any] = {}
+ if organization:
+ payload["organization"] = organization
+ if name:
+ payload["name"] = name
+ return await self.request("POST", f"repos/{owner}/{repo}/forks", json_data=payload)
+
# Issue endpoints
async def list_issues(self, owner: str, repo: str, params: dict) -> list[dict]:
"""List repository issues"""
diff --git a/plugins/gitea/handlers/repositories.py b/plugins/gitea/handlers/repositories.py
index 5eb657a..60d03a1 100644
--- a/plugins/gitea/handlers/repositories.py
+++ b/plugins/gitea/handlers/repositories.py
@@ -482,6 +482,202 @@ def get_tool_specifications() -> list[dict[str, Any]]:
},
"scope": "write",
},
+ # === F.17 ergonomics: batch files, tree, search, compare, releases, fork ===
+ {
+ "name": "create_files",
+ "method_name": "create_files",
+ "description": (
+ "Create or update multiple files in a single commit. Uses Gitea's "
+ "``/repos/{owner}/{repo}/contents`` batch endpoint. Per-file operations: "
+ "``create`` | ``update`` | ``delete``. For create/update, pass raw UTF-8 "
+ "``content`` (default) or a base64 string with ``content_is_base64=true`` "
+ "per file. Delete requires each file's current ``sha``."
+ ),
+ "schema": {
+ "type": "object",
+ "properties": {
+ "owner": {"type": "string", "minLength": 1},
+ "repo": {"type": "string", "minLength": 1},
+ "message": {
+ "type": "string",
+ "description": "Single commit message for the whole batch.",
+ "minLength": 1,
+ },
+ "branch": {"anyOf": [{"type": "string"}, {"type": "null"}]},
+ "new_branch": {
+ "anyOf": [{"type": "string"}, {"type": "null"}],
+ "description": "If set, commit on a new branch created from ``branch``.",
+ },
+ "files": {
+ "type": "array",
+ "minItems": 1,
+ "maxItems": 100,
+ "items": {
+ "type": "object",
+ "properties": {
+ "operation": {
+ "type": "string",
+ "enum": ["create", "update", "delete"],
+ },
+ "path": {"type": "string", "minLength": 1},
+ "content": {
+ "anyOf": [{"type": "string"}, {"type": "null"}],
+ "description": "Required for create/update.",
+ },
+ "sha": {
+ "anyOf": [{"type": "string"}, {"type": "null"}],
+ "description": "Required for update/delete.",
+ },
+ "content_is_base64": {"type": "boolean", "default": False},
+ },
+ "required": ["operation", "path"],
+ },
+ },
+ },
+ "required": ["owner", "repo", "files", "message"],
+ },
+ "scope": "write",
+ },
+ {
+ "name": "get_tree",
+ "method_name": "get_tree",
+ "description": (
+ "List the file tree of a Gitea repository. ``sha`` may be a branch "
+ "name, tag, or commit (default: HEAD). ``recursive=true`` returns "
+ "the entire tree in one payload. Use this instead of issuing many "
+ "``get_file`` calls when you don't know the paths ahead of time."
+ ),
+ "schema": {
+ "type": "object",
+ "properties": {
+ "owner": {"type": "string", "minLength": 1},
+ "repo": {"type": "string", "minLength": 1},
+ "sha": {"type": "string", "default": "HEAD"},
+ "recursive": {"type": "boolean", "default": False},
+ "page": {"type": "integer", "minimum": 1, "default": 1},
+ "per_page": {
+ "type": "integer",
+ "minimum": 1,
+ "maximum": 100,
+ "default": 100,
+ },
+ },
+ "required": ["owner", "repo"],
+ },
+ "scope": "read",
+ },
+ {
+ "name": "search_code",
+ "method_name": "search_code",
+ "description": (
+ "Search code inside Gitea. When ``owner`` and ``repo`` are set, the "
+ "search scopes to that repository; otherwise it's an instance-wide "
+ "code search. Returns Gitea's raw ``{ok, data: [...]}`` shape."
+ ),
+ "schema": {
+ "type": "object",
+ "properties": {
+ "keyword": {"type": "string", "minLength": 1},
+ "owner": {"anyOf": [{"type": "string"}, {"type": "null"}]},
+ "repo": {"anyOf": [{"type": "string"}, {"type": "null"}]},
+ "page": {"type": "integer", "minimum": 1, "default": 1},
+ "per_page": {
+ "type": "integer",
+ "minimum": 1,
+ "maximum": 50,
+ "default": 30,
+ },
+ },
+ "required": ["keyword"],
+ },
+ "scope": "read",
+ },
+ {
+ "name": "compare",
+ "method_name": "compare",
+ "description": (
+ "Compare two commits / branches / tags in a Gitea repository. Returns "
+ "commits + file diffs between ``base`` and ``head`` (the Gitea "
+ "``compare`` endpoint)."
+ ),
+ "schema": {
+ "type": "object",
+ "properties": {
+ "owner": {"type": "string", "minLength": 1},
+ "repo": {"type": "string", "minLength": 1},
+ "base": {"type": "string", "minLength": 1},
+ "head": {"type": "string", "minLength": 1},
+ },
+ "required": ["owner", "repo", "base", "head"],
+ },
+ "scope": "read",
+ },
+ {
+ "name": "list_releases",
+ "method_name": "list_releases",
+ "description": "List releases of a Gitea repository (paginated).",
+ "schema": {
+ "type": "object",
+ "properties": {
+ "owner": {"type": "string", "minLength": 1},
+ "repo": {"type": "string", "minLength": 1},
+ "page": {"type": "integer", "minimum": 1, "default": 1},
+ "per_page": {
+ "type": "integer",
+ "minimum": 1,
+ "maximum": 50,
+ "default": 30,
+ },
+ },
+ "required": ["owner", "repo"],
+ },
+ "scope": "read",
+ },
+ {
+ "name": "create_release",
+ "method_name": "create_release",
+ "description": (
+ "Create a release (tag + release metadata) on a Gitea repository. "
+ "Set ``draft`` to hide it from users until published."
+ ),
+ "schema": {
+ "type": "object",
+ "properties": {
+ "owner": {"type": "string", "minLength": 1},
+ "repo": {"type": "string", "minLength": 1},
+ "tag_name": {"type": "string", "minLength": 1},
+ "name": {"anyOf": [{"type": "string"}, {"type": "null"}]},
+ "body": {"anyOf": [{"type": "string"}, {"type": "null"}]},
+ "target_commitish": {
+ "anyOf": [{"type": "string"}, {"type": "null"}],
+ "description": "Branch or commit to tag (default: default branch).",
+ },
+ "draft": {"type": "boolean", "default": False},
+ "prerelease": {"type": "boolean", "default": False},
+ },
+ "required": ["owner", "repo", "tag_name"],
+ },
+ "scope": "write",
+ },
+ {
+ "name": "fork_repository",
+ "method_name": "fork_repository",
+ "description": (
+ "Fork a Gitea repository. Without ``organization``, forks under the "
+ "calling user. ``name`` optionally renames the fork."
+ ),
+ "schema": {
+ "type": "object",
+ "properties": {
+ "owner": {"type": "string", "minLength": 1},
+ "repo": {"type": "string", "minLength": 1},
+ "organization": {"anyOf": [{"type": "string"}, {"type": "null"}]},
+ "name": {"anyOf": [{"type": "string"}, {"type": "null"}]},
+ },
+ "required": ["owner", "repo"],
+ },
+ "scope": "write",
+ },
]
@@ -724,3 +920,180 @@ async def delete_file(
await client.delete_file(owner, repo, path, sha, message, branch)
result = {"success": True, "message": f"File '{path}' deleted successfully"}
return json.dumps(result, indent=2)
+
+
+# ---------------------------------------------------------------------------
+# F.17 ergonomics: batch files, tree, search, compare, releases, fork
+# ---------------------------------------------------------------------------
+
+
+async def create_files(
+ client: GiteaClient,
+ owner: str,
+ repo: str,
+ files: list[dict[str, Any]],
+ message: str,
+ branch: str | None = None,
+ new_branch: str | None = None,
+) -> str:
+ """Apply a batch of create / update / delete file operations in a single commit.
+
+ Each entry in ``files`` is normalised so Gitea's batch endpoint
+ receives base64-encoded content with the ``content_is_base64`` flag
+ stripped (the server expects base64). Validation:
+
+ * ``operation`` is ``create`` / ``update`` / ``delete``.
+ * ``path`` required on every entry.
+ * ``content`` required on create / update.
+ * ``sha`` required on update / delete (current file SHA).
+ """
+ prepared: list[dict[str, Any]] = []
+ errors: list[dict[str, Any]] = []
+
+ for idx, f in enumerate(files or []):
+ op = (f.get("operation") or "").strip().lower()
+ path = f.get("path")
+ if op not in {"create", "update", "delete"}:
+ errors.append({"index": idx, "path": path, "error": f"invalid_operation:{op!r}"})
+ continue
+ if not path:
+ errors.append({"index": idx, "error": "missing_path"})
+ continue
+ if op in {"update", "delete"} and not f.get("sha"):
+ errors.append({"index": idx, "path": path, "error": f"missing_sha_for_{op}"})
+ continue
+
+ entry: dict[str, Any] = {"operation": op, "path": path}
+ if op in {"create", "update"}:
+ content = f.get("content")
+ if content is None:
+ errors.append({"index": idx, "path": path, "error": "missing_content"})
+ continue
+ try:
+ entry["content"] = client._normalise_file_content(
+ content, bool(f.get("content_is_base64", False))
+ )
+ except ValueError as exc:
+ errors.append({"index": idx, "path": path, "error": str(exc)})
+ continue
+ if f.get("sha"):
+ entry["sha"] = f["sha"]
+ if f.get("from_path"): # Gitea also supports rename via from_path
+ entry["from_path"] = f["from_path"]
+ prepared.append(entry)
+
+ if errors:
+ return json.dumps(
+ {
+ "success": False,
+ "error": "validation_failed",
+ "errors": errors,
+ "total": len(files or []),
+ "prepared": len(prepared),
+ },
+ indent=2,
+ )
+
+ payload: dict[str, Any] = {"message": message, "files": prepared}
+ if branch:
+ payload["branch"] = branch
+ if new_branch:
+ payload["new_branch"] = new_branch
+
+ result = await client.change_files(owner, repo, payload)
+ return json.dumps(
+ {
+ "success": True,
+ "message": f"Batched {len(prepared)} file operation(s) into one commit",
+ "result": result,
+ },
+ indent=2,
+ )
+
+
+async def get_tree(
+ client: GiteaClient,
+ owner: str,
+ repo: str,
+ sha: str = "HEAD",
+ recursive: bool = False,
+ page: int = 1,
+ per_page: int = 100,
+) -> str:
+ tree = await client.get_tree(
+ owner, repo, sha, recursive=recursive, page=page, per_page=per_page
+ )
+ return json.dumps({"success": True, "tree": tree}, indent=2)
+
+
+async def search_code(
+ client: GiteaClient,
+ keyword: str,
+ owner: str | None = None,
+ repo: str | None = None,
+ page: int = 1,
+ per_page: int = 30,
+) -> str:
+ result = await client.search_code(
+ keyword=keyword, owner=owner, repo=repo, page=page, per_page=per_page
+ )
+ return json.dumps({"success": True, "result": result}, indent=2)
+
+
+async def compare(
+ client: GiteaClient,
+ owner: str,
+ repo: str,
+ base: str,
+ head: str,
+) -> str:
+ diff = await client.compare(owner, repo, base, head)
+ return json.dumps({"success": True, "compare": diff}, indent=2)
+
+
+async def list_releases(
+ client: GiteaClient,
+ owner: str,
+ repo: str,
+ page: int = 1,
+ per_page: int = 30,
+) -> str:
+ releases = await client.list_releases(owner, repo, page=page, per_page=per_page)
+ return json.dumps({"success": True, "releases": releases}, indent=2)
+
+
+async def create_release(
+ client: GiteaClient,
+ owner: str,
+ repo: str,
+ tag_name: str,
+ name: str | None = None,
+ body: str | None = None,
+ target_commitish: str | None = None,
+ draft: bool = False,
+ prerelease: bool = False,
+) -> str:
+ data: dict[str, Any] = {
+ "tag_name": tag_name,
+ "draft": draft,
+ "prerelease": prerelease,
+ }
+ if name is not None:
+ data["name"] = name
+ if body is not None:
+ data["body"] = body
+ if target_commitish is not None:
+ data["target_commitish"] = target_commitish
+ release = await client.create_release(owner, repo, data)
+ return json.dumps({"success": True, "release": release}, indent=2)
+
+
+async def fork_repository(
+ client: GiteaClient,
+ owner: str,
+ repo: str,
+ organization: str | None = None,
+ name: str | None = None,
+) -> str:
+ fork = await client.fork_repository(owner, repo, organization=organization, name=name)
+ return json.dumps({"success": True, "fork": fork}, indent=2)
diff --git a/plugins/gitea/plugin.py b/plugins/gitea/plugin.py
index c227418..58098e6 100644
--- a/plugins/gitea/plugin.py
+++ b/plugins/gitea/plugin.py
@@ -7,6 +7,8 @@ Modular handlers for better organization and maintainability.
from typing import Any
+import aiohttp
+
from plugins.base import BasePlugin
from plugins.gitea import handlers
from plugins.gitea.client import GiteaClient
@@ -160,3 +162,61 @@ class GiteaPlugin(BasePlugin):
return {"healthy": True, "message": "Gitea instance is accessible"}
except Exception as e:
return {"healthy": False, "message": f"Gitea health check failed: {str(e)}"}
+
+ async def probe_credential_capabilities(self) -> dict[str, Any]:
+ """F.7e — report what the Gitea access token grants.
+
+ Gitea tokens carry OAuth-style scope labels. Scopes are surfaced
+ two ways (different Gitea versions disagree) and we accept both:
+
+ * ``X-OAuth-Scopes`` header on the response of any authenticated
+ endpoint — the format used by GitHub-compatible clients.
+ * ``capabilities`` key in ``meta`` (rare; ignored here — the
+ header is the universal path).
+
+ We hit ``GET /api/v1/user`` which every token that can do
+ anything useful on Gitea is allowed to call. Failures return
+ ``probe_available=False`` with a reason rather than raising,
+ so the badge falls back to "probe unavailable" cleanly.
+ """
+ url = f"{self.client.api_base}/user"
+ headers = self.client._get_headers()
+ try:
+ timeout = aiohttp.ClientTimeout(total=15)
+ async with aiohttp.ClientSession(timeout=timeout) as session:
+ async with session.get(url, headers=headers) as resp:
+ if resp.status >= 400:
+ body = (await resp.text())[:200]
+ return {
+ "probe_available": False,
+ "granted": [],
+ "source": "gitea_oauth_scopes",
+ "reason": f"user_endpoint_http_{resp.status}: {body}",
+ }
+ scopes_header = resp.headers.get("X-OAuth-Scopes", "")
+ except Exception as exc: # noqa: BLE001
+ return {
+ "probe_available": False,
+ "granted": [],
+ "source": "gitea_oauth_scopes",
+ "reason": f"probe_failed: {exc}",
+ }
+
+ granted = [s.strip() for s in scopes_header.split(",") if s.strip()]
+
+ if not granted:
+ # Gitea instances without per-token scopes (unset header)
+ # grant full access to whatever the user account itself
+ # can do. Report that honestly rather than claiming nothing.
+ return {
+ "probe_available": False,
+ "granted": [],
+ "source": "gitea_oauth_scopes",
+ "reason": "scopes_header_absent_or_empty",
+ }
+
+ return {
+ "probe_available": True,
+ "granted": sorted(granted),
+ "source": "gitea_oauth_scopes",
+ }
diff --git a/plugins/n8n/client.py b/plugins/n8n/client.py
index 990d523..b7c5194 100644
--- a/plugins/n8n/client.py
+++ b/plugins/n8n/client.py
@@ -1,5 +1,4 @@
-"""
-n8n REST API Client
+"""n8n REST API Client.
Handles all HTTP communication with n8n REST API.
Separates API communication from business logic.
@@ -11,6 +10,64 @@ from typing import Any
import aiohttp
+class N8nApiError(Exception):
+ """Base exception for n8n API errors with structured error info."""
+
+ def __init__(
+ self,
+ message: str,
+ *,
+ error_code: str = "API_ERROR",
+ status_code: int = 0,
+ hint: str = "",
+ ) -> None:
+ super().__init__(message)
+ self.error_code = error_code
+ self.status_code = status_code
+ self.hint = hint
+
+ def to_dict(self) -> dict[str, Any]:
+ d: dict[str, Any] = {"error_code": self.error_code, "message": str(self)}
+ if self.status_code:
+ d["status_code"] = self.status_code
+ if self.hint:
+ d["hint"] = self.hint
+ return d
+
+
+class N8nAuthError(N8nApiError):
+ """Raised on 401/403 — invalid or under-scoped API key."""
+
+ def __init__(self, message: str, *, status_code: int = 401, hint: str = "") -> None:
+ super().__init__(
+ message,
+ error_code="AUTH_FAILED",
+ status_code=status_code,
+ hint=hint or "Check the API key in n8n → Settings → API → API Keys.",
+ )
+
+
+class N8nNotFoundError(N8nApiError):
+ """Raised on 404 — resource doesn't exist."""
+
+ def __init__(self, message: str) -> None:
+ super().__init__(message, error_code="NOT_FOUND", status_code=404)
+
+
+class N8nValidationError(N8nApiError):
+ """Raised on 400/422 — bad payload."""
+
+ def __init__(self, message: str, *, status_code: int = 400) -> None:
+ super().__init__(message, error_code="VALIDATION_ERROR", status_code=status_code)
+
+
+class N8nConnectionError(N8nApiError):
+ """Raised on network-level failures."""
+
+ def __init__(self, message: str) -> None:
+ super().__init__(message, error_code="CONNECTION_ERROR")
+
+
class N8nClient:
"""
n8n REST API client for HTTP communication.
@@ -66,69 +123,63 @@ class N8nClient:
json_data: dict | None = None,
headers_override: dict | None = None,
) -> Any:
- """
- Make authenticated request to n8n REST API.
-
- Args:
- method: HTTP method (GET, POST, PUT, DELETE, PATCH)
- endpoint: API endpoint (without base URL)
- params: Query parameters
- json_data: JSON body data
- headers_override: Override default headers
-
- Returns:
- API response (dict, list, or None)
+ """Make authenticated request to n8n REST API.
Raises:
- Exception: On API errors with status code and message
+ N8nAuthError: On 401/403.
+ N8nNotFoundError: On 404.
+ N8nValidationError: On 400/422.
+ N8nApiError: On other 4xx/5xx.
+ N8nConnectionError: On network-level failure.
"""
- # Build full URL
url = f"{self.api_base}/{endpoint.lstrip('/')}"
-
- # Setup headers
headers = self._get_headers(headers_override)
- # Filter out None values from params
if params:
params = {k: v for k, v in params.items() if v is not None}
-
- # Filter None values from JSON data
if json_data:
json_data = {k: v for k, v in json_data.items() if v is not None}
- # Make request
- self.logger.debug(f"{method} {url}")
- self.logger.debug(f"Params: {params}")
- self.logger.debug(f"Data: {json_data}")
+ self.logger.debug("%s %s", method, url)
- async with (
- aiohttp.ClientSession() as session,
- session.request(
- method=method, url=url, params=params, json=json_data, headers=headers
- ) as response,
- ):
- # Log response
- self.logger.debug(f"Response status: {response.status}")
+ try:
+ async with (
+ aiohttp.ClientSession() as session,
+ session.request(
+ method=method, url=url, params=params, json=json_data, headers=headers
+ ) as response,
+ ):
+ self.logger.debug("Response status: %d", response.status)
- # Handle empty responses (e.g., 204 No Content)
- if response.status == 204:
- return {"success": True, "message": "Operation completed successfully"}
+ if response.status == 204:
+ return {"success": True, "message": "Operation completed successfully"}
+
+ try:
+ response_data = await response.json()
+ except Exception:
+ response_text = await response.text()
+ if response.status >= 400:
+ self._raise_for_status(response.status, response_text)
+ return {"success": True, "message": response_text}
- # Try to parse JSON response
- try:
- response_data = await response.json()
- except Exception:
- response_text = await response.text()
if response.status >= 400:
- raise Exception(f"n8n API error (status {response.status}): {response_text}")
- return {"success": True, "message": response_text}
+ error_msg = response_data.get("message", str(response_data))
+ self._raise_for_status(response.status, error_msg)
- # Check for errors
- if response.status >= 400:
- error_msg = response_data.get("message", str(response_data))
- raise Exception(f"n8n API error (status {response.status}): {error_msg}")
+ return response_data
- return response_data
+ except (aiohttp.ClientError, OSError) as exc:
+ raise N8nConnectionError(f"Cannot reach n8n at {self.site_url}: {exc}") from exc
+
+ @staticmethod
+ def _raise_for_status(status: int, message: str) -> None:
+ if status in (401, 403):
+ raise N8nAuthError(message, status_code=status)
+ if status == 404:
+ raise N8nNotFoundError(message)
+ if status in (400, 422):
+ raise N8nValidationError(message, status_code=status)
+ raise N8nApiError(message, error_code="API_ERROR", status_code=status)
# =====================
# WORKFLOW ENDPOINTS
@@ -176,8 +227,18 @@ class N8nClient:
"""Execute a workflow manually"""
return await self.request("POST", f"workflows/{workflow_id}/run", json_data=data or {})
+ async def transfer_workflow(
+ self, workflow_id: str, destination_project_id: str
+ ) -> dict[str, Any]:
+ """Transfer workflow to another project (Enterprise)."""
+ return await self.request(
+ "PUT",
+ f"workflows/{workflow_id}/transfer",
+ json_data={"destinationProjectId": destination_project_id},
+ )
+
async def get_workflow_tags(self, workflow_id: str) -> list[dict[str, Any]]:
- """Get tags assigned to a workflow"""
+ """Get tags assigned to a workflow."""
workflow = await self.get_workflow(workflow_id)
return workflow.get("tags", [])
@@ -189,14 +250,16 @@ class N8nClient:
self,
workflow_id: str | None = None,
status: str | None = None,
+ project_id: str | None = None,
include_data: bool = False,
limit: int = 20,
cursor: str | None = None,
) -> dict[str, Any]:
- """List workflow executions with filters"""
+ """List workflow executions with filters."""
params = {
"workflowId": workflow_id,
"status": status,
+ "projectId": project_id,
"includeData": str(include_data).lower(),
"limit": limit,
"cursor": cursor,
@@ -391,12 +454,17 @@ class N8nClient:
data["variables"] = variables
return await self.request("POST", "source-control/pull", json_data=data)
+ async def get_current_user(self) -> dict[str, Any]:
+ """Get current authenticated user info (for capability probe)."""
+ return await self.request("GET", "user")
+
async def health_check(self) -> dict[str, Any]:
- """Check n8n instance health"""
- # Use direct URL, not API base
+ """Check n8n instance health."""
url = f"{self.site_url}/healthz"
- async with aiohttp.ClientSession() as session, session.get(url) as response:
- if response.status == 200:
- return {"healthy": True, "status": "ok"}
- else:
+ try:
+ async with aiohttp.ClientSession() as session, session.get(url) as response:
+ if response.status == 200:
+ return {"healthy": True, "status": "ok"}
return {"healthy": False, "status": f"unhealthy (status {response.status})"}
+ except (aiohttp.ClientError, OSError) as exc:
+ raise N8nConnectionError(f"Cannot reach n8n health endpoint at {url}: {exc}") from exc
diff --git a/plugins/n8n/handlers/credentials.py b/plugins/n8n/handlers/credentials.py
index 8c17571..e3c6cde 100644
--- a/plugins/n8n/handlers/credentials.py
+++ b/plugins/n8n/handlers/credentials.py
@@ -1,9 +1,15 @@
-"""Credentials Handler - manages n8n credentials"""
+"""Credentials Handler - manages n8n credentials."""
import json
from typing import Any
-from plugins.n8n.client import N8nClient
+from plugins.n8n.client import N8nApiError, N8nClient
+
+
+def _error_json(exc: Exception) -> str:
+ if isinstance(exc, N8nApiError):
+ return json.dumps({"success": False, **exc.to_dict()}, indent=2)
+ return json.dumps({"success": False, "error": str(exc)}, indent=2)
def get_tool_specifications() -> list[dict[str, Any]]:
@@ -107,7 +113,7 @@ async def get_credential(client: N8nClient, credential_id: str) -> str:
}
return json.dumps(result, indent=2)
except Exception as e:
- return json.dumps({"success": False, "error": str(e)}, indent=2)
+ return _error_json(e)
async def create_credential(client: N8nClient, name: str, type: str, data: dict[str, Any]) -> str:
@@ -126,7 +132,7 @@ async def create_credential(client: N8nClient, name: str, type: str, data: dict[
}
return json.dumps(result, indent=2)
except Exception as e:
- return json.dumps({"success": False, "error": str(e)}, indent=2)
+ return _error_json(e)
async def delete_credential(client: N8nClient, credential_id: str) -> str:
@@ -137,7 +143,7 @@ async def delete_credential(client: N8nClient, credential_id: str) -> str:
{"success": True, "message": f"Credential {credential_id} deleted"}, indent=2
)
except Exception as e:
- return json.dumps({"success": False, "error": str(e)}, indent=2)
+ return _error_json(e)
async def get_credential_schema(client: N8nClient, credential_type: str) -> str:
@@ -148,7 +154,7 @@ async def get_credential_schema(client: N8nClient, credential_type: str) -> str:
{"success": True, "credential_type": credential_type, "schema": schema}, indent=2
)
except Exception as e:
- return json.dumps({"success": False, "error": str(e)}, indent=2)
+ return _error_json(e)
async def transfer_credential(
@@ -165,4 +171,4 @@ async def transfer_credential(
indent=2,
)
except Exception as e:
- return json.dumps({"success": False, "error": str(e)}, indent=2)
+ return _error_json(e)
diff --git a/plugins/n8n/handlers/executions.py b/plugins/n8n/handlers/executions.py
index 302fb0b..b90cad2 100644
--- a/plugins/n8n/handlers/executions.py
+++ b/plugins/n8n/handlers/executions.py
@@ -1,10 +1,16 @@
-"""Execution Handler - manages n8n workflow executions"""
+"""Execution Handler - manages n8n workflow executions."""
import asyncio
import json
from typing import Any
-from plugins.n8n.client import N8nClient
+from plugins.n8n.client import N8nApiError, N8nClient
+
+
+def _error_json(exc: Exception) -> str:
+ if isinstance(exc, N8nApiError):
+ return json.dumps({"success": False, **exc.to_dict()}, indent=2)
+ return json.dumps({"success": False, "error": str(exc)}, indent=2)
def get_tool_specifications() -> list[dict[str, Any]]:
@@ -27,6 +33,10 @@ def get_tool_specifications() -> list[dict[str, Any]]:
"enum": ["success", "error", "waiting", "running", "new"],
"description": "OPTIONAL: Filter by execution status. Omit for all statuses.",
},
+ "project_id": {
+ "type": "string",
+ "description": "OPTIONAL: Filter by project ID (Enterprise). Omit for all projects.",
+ },
"include_data": {
"type": "boolean",
"description": "Include full execution data",
@@ -200,15 +210,17 @@ async def list_executions(
client: N8nClient,
workflow_id: str | None = None,
status: str | None = None,
+ project_id: str | None = None,
include_data: bool = False,
limit: int = 20,
cursor: str | None = None,
) -> str:
- """List workflow executions"""
+ """List workflow executions."""
try:
response = await client.list_executions(
workflow_id=workflow_id,
status=status,
+ project_id=project_id,
include_data=include_data,
limit=limit,
cursor=cursor,
@@ -239,7 +251,7 @@ async def list_executions(
return json.dumps(result, indent=2)
except Exception as e:
- return json.dumps({"success": False, "error": str(e)}, indent=2)
+ return _error_json(e)
async def get_execution(client: N8nClient, execution_id: str, include_data: bool = True) -> str:
@@ -270,7 +282,7 @@ async def get_execution(client: N8nClient, execution_id: str, include_data: bool
return json.dumps(result, indent=2)
except Exception as e:
- return json.dumps({"success": False, "error": str(e)}, indent=2)
+ return _error_json(e)
async def delete_execution(client: N8nClient, execution_id: str) -> str:
@@ -283,7 +295,7 @@ async def delete_execution(client: N8nClient, execution_id: str) -> str:
return json.dumps(result, indent=2)
except Exception as e:
- return json.dumps({"success": False, "error": str(e)}, indent=2)
+ return _error_json(e)
async def delete_executions(client: N8nClient, execution_ids: list[str]) -> str:
@@ -309,7 +321,7 @@ async def delete_executions(client: N8nClient, execution_ids: list[str]) -> str:
return json.dumps(result, indent=2)
except Exception as e:
- return json.dumps({"success": False, "error": str(e)}, indent=2)
+ return _error_json(e)
async def stop_execution(client: N8nClient, execution_id: str) -> str:
@@ -322,7 +334,7 @@ async def stop_execution(client: N8nClient, execution_id: str) -> str:
return json.dumps(result, indent=2)
except Exception as e:
- return json.dumps({"success": False, "error": str(e)}, indent=2)
+ return _error_json(e)
async def retry_execution(client: N8nClient, execution_id: str) -> str:
@@ -366,7 +378,7 @@ async def retry_execution(client: N8nClient, execution_id: str) -> str:
return json.dumps(result, indent=2)
except Exception as e:
- return json.dumps({"success": False, "error": str(e)}, indent=2)
+ return _error_json(e)
async def get_execution_data(client: N8nClient, execution_id: str) -> str:
@@ -390,7 +402,7 @@ async def get_execution_data(client: N8nClient, execution_id: str) -> str:
return json.dumps(result, indent=2)
except Exception as e:
- return json.dumps({"success": False, "error": str(e)}, indent=2)
+ return _error_json(e)
async def wait_for_execution(
@@ -444,4 +456,4 @@ async def wait_for_execution(
return json.dumps(result, indent=2)
except Exception as e:
- return json.dumps({"success": False, "error": str(e)}, indent=2)
+ return _error_json(e)
diff --git a/plugins/n8n/handlers/projects.py b/plugins/n8n/handlers/projects.py
index 5008ec0..338d5df 100644
--- a/plugins/n8n/handlers/projects.py
+++ b/plugins/n8n/handlers/projects.py
@@ -1,9 +1,15 @@
-"""Projects Handler - manages n8n projects (Enterprise/Pro)"""
+"""Projects Handler - manages n8n projects (Enterprise/Pro)."""
import json
from typing import Any
-from plugins.n8n.client import N8nClient
+from plugins.n8n.client import N8nApiError, N8nClient
+
+
+def _error_json(exc: Exception) -> str:
+ if isinstance(exc, N8nApiError):
+ return json.dumps({"success": False, **exc.to_dict()}, indent=2)
+ return json.dumps({"success": False, "error": str(exc)}, indent=2)
def get_tool_specifications() -> list[dict[str, Any]]:
@@ -146,7 +152,7 @@ async def list_projects(client: N8nClient, limit: int = 100, cursor: str | None
}
return json.dumps(result, indent=2)
except Exception as e:
- return json.dumps({"success": False, "error": str(e)}, indent=2)
+ return _error_json(e)
async def get_project(client: N8nClient, project_id: str) -> str:
@@ -157,7 +163,7 @@ async def get_project(client: N8nClient, project_id: str) -> str:
indent=2,
)
except Exception as e:
- return json.dumps({"success": False, "error": str(e)}, indent=2)
+ return _error_json(e)
async def create_project(client: N8nClient, name: str) -> str:
@@ -172,7 +178,7 @@ async def create_project(client: N8nClient, name: str) -> str:
indent=2,
)
except Exception as e:
- return json.dumps({"success": False, "error": str(e)}, indent=2)
+ return _error_json(e)
async def update_project(client: N8nClient, project_id: str, name: str) -> str:
@@ -187,7 +193,7 @@ async def update_project(client: N8nClient, project_id: str, name: str) -> str:
indent=2,
)
except Exception as e:
- return json.dumps({"success": False, "error": str(e)}, indent=2)
+ return _error_json(e)
async def delete_project(client: N8nClient, project_id: str) -> str:
@@ -195,7 +201,7 @@ async def delete_project(client: N8nClient, project_id: str) -> str:
await client.delete_project(project_id)
return json.dumps({"success": True, "message": f"Project {project_id} deleted"}, indent=2)
except Exception as e:
- return json.dumps({"success": False, "error": str(e)}, indent=2)
+ return _error_json(e)
async def add_project_users(client: N8nClient, project_id: str, users: list[dict[str, str]]) -> str:
@@ -207,7 +213,7 @@ async def add_project_users(client: N8nClient, project_id: str, users: list[dict
indent=2,
)
except Exception as e:
- return json.dumps({"success": False, "error": str(e)}, indent=2)
+ return _error_json(e)
async def change_project_user_role(
@@ -223,7 +229,7 @@ async def change_project_user_role(
indent=2,
)
except Exception as e:
- return json.dumps({"success": False, "error": str(e)}, indent=2)
+ return _error_json(e)
async def remove_project_user(client: N8nClient, project_id: str, user_id: str) -> str:
@@ -234,4 +240,4 @@ async def remove_project_user(client: N8nClient, project_id: str, user_id: str)
indent=2,
)
except Exception as e:
- return json.dumps({"success": False, "error": str(e)}, indent=2)
+ return _error_json(e)
diff --git a/plugins/n8n/handlers/system.py b/plugins/n8n/handlers/system.py
index 91932fa..6be1954 100644
--- a/plugins/n8n/handlers/system.py
+++ b/plugins/n8n/handlers/system.py
@@ -1,9 +1,15 @@
-"""System Handler - manages n8n system operations (audit, source control, health)"""
+"""System Handler - manages n8n system operations (audit, source control, health)."""
import json
from typing import Any
-from plugins.n8n.client import N8nClient
+from plugins.n8n.client import N8nApiError, N8nClient
+
+
+def _error_json(exc: Exception) -> str:
+ if isinstance(exc, N8nApiError):
+ return json.dumps({"success": False, **exc.to_dict()}, indent=2)
+ return json.dumps({"success": False, "error": str(exc)}, indent=2)
def get_tool_specifications() -> list[dict[str, Any]]:
@@ -84,7 +90,7 @@ async def run_security_audit(client: N8nClient, categories: list[str] | None = N
return json.dumps(audit_data, indent=2)
except Exception as e:
- return json.dumps({"success": False, "error": str(e)}, indent=2)
+ return _error_json(e)
async def source_control_pull(
@@ -99,7 +105,7 @@ async def source_control_pull(
indent=2,
)
except Exception as e:
- return json.dumps({"success": False, "error": str(e)}, indent=2)
+ return _error_json(e)
async def get_instance_info(client: N8nClient) -> str:
@@ -115,13 +121,12 @@ async def get_instance_info(client: N8nClient) -> str:
except Exception:
pass
- # Get current user to verify connectivity
try:
- user = await client.request("GET", "user")
+ user = await client.get_current_user()
info["current_user"] = {
"id": user.get("id"),
"email": user.get("email"),
- "role": user.get("role"),
+ "role": user.get("role") or user.get("globalRole"),
}
except Exception:
pass
@@ -131,7 +136,7 @@ async def get_instance_info(client: N8nClient) -> str:
return json.dumps({"success": True, "instance_info": info}, indent=2)
except Exception as e:
- return json.dumps({"success": False, "error": str(e)}, indent=2)
+ return _error_json(e)
async def health_check(client: N8nClient) -> str:
diff --git a/plugins/n8n/handlers/tags.py b/plugins/n8n/handlers/tags.py
index b1ed212..77bfb8e 100644
--- a/plugins/n8n/handlers/tags.py
+++ b/plugins/n8n/handlers/tags.py
@@ -1,9 +1,15 @@
-"""Tags Handler - manages n8n tags"""
+"""Tags Handler - manages n8n tags."""
import json
from typing import Any
-from plugins.n8n.client import N8nClient
+from plugins.n8n.client import N8nApiError, N8nClient
+
+
+def _error_json(exc: Exception) -> str:
+ if isinstance(exc, N8nApiError):
+ return json.dumps({"success": False, **exc.to_dict()}, indent=2)
+ return json.dumps({"success": False, "error": str(exc)}, indent=2)
def get_tool_specifications() -> list[dict[str, Any]]:
@@ -99,7 +105,7 @@ async def list_tags(client: N8nClient, limit: int = 100, cursor: str | None = No
}
return json.dumps(result, indent=2)
except Exception as e:
- return json.dumps({"success": False, "error": str(e)}, indent=2)
+ return _error_json(e)
async def get_tag(client: N8nClient, tag_id: str) -> str:
@@ -109,7 +115,7 @@ async def get_tag(client: N8nClient, tag_id: str) -> str:
{"success": True, "tag": {"id": tag.get("id"), "name": tag.get("name")}}, indent=2
)
except Exception as e:
- return json.dumps({"success": False, "error": str(e)}, indent=2)
+ return _error_json(e)
async def create_tag(client: N8nClient, name: str) -> str:
@@ -124,7 +130,7 @@ async def create_tag(client: N8nClient, name: str) -> str:
indent=2,
)
except Exception as e:
- return json.dumps({"success": False, "error": str(e)}, indent=2)
+ return _error_json(e)
async def update_tag(client: N8nClient, tag_id: str, name: str) -> str:
@@ -139,7 +145,7 @@ async def update_tag(client: N8nClient, tag_id: str, name: str) -> str:
indent=2,
)
except Exception as e:
- return json.dumps({"success": False, "error": str(e)}, indent=2)
+ return _error_json(e)
async def delete_tag(client: N8nClient, tag_id: str) -> str:
@@ -147,7 +153,7 @@ async def delete_tag(client: N8nClient, tag_id: str) -> str:
await client.delete_tag(tag_id)
return json.dumps({"success": True, "message": f"Tag {tag_id} deleted"}, indent=2)
except Exception as e:
- return json.dumps({"success": False, "error": str(e)}, indent=2)
+ return _error_json(e)
async def delete_tags(client: N8nClient, tag_ids: list[str]) -> str:
@@ -164,4 +170,4 @@ async def delete_tags(client: N8nClient, tag_ids: list[str]) -> str:
indent=2,
)
except Exception as e:
- return json.dumps({"success": False, "error": str(e)}, indent=2)
+ return _error_json(e)
diff --git a/plugins/n8n/handlers/users.py b/plugins/n8n/handlers/users.py
index 4bac1e2..a4eac3e 100644
--- a/plugins/n8n/handlers/users.py
+++ b/plugins/n8n/handlers/users.py
@@ -1,9 +1,15 @@
-"""Users Handler - manages n8n users"""
+"""Users Handler - manages n8n users."""
import json
from typing import Any
-from plugins.n8n.client import N8nClient
+from plugins.n8n.client import N8nApiError, N8nClient
+
+
+def _error_json(exc: Exception) -> str:
+ if isinstance(exc, N8nApiError):
+ return json.dumps({"success": False, **exc.to_dict()}, indent=2)
+ return json.dumps({"success": False, "error": str(exc)}, indent=2)
def get_tool_specifications() -> list[dict[str, Any]]:
@@ -115,7 +121,7 @@ async def list_users(
}
return json.dumps(result, indent=2)
except Exception as e:
- return json.dumps({"success": False, "error": str(e)}, indent=2)
+ return _error_json(e)
async def get_user(client: N8nClient, user_id: str, include_role: bool = True) -> str:
@@ -136,7 +142,7 @@ async def get_user(client: N8nClient, user_id: str, include_role: bool = True) -
indent=2,
)
except Exception as e:
- return json.dumps({"success": False, "error": str(e)}, indent=2)
+ return _error_json(e)
async def create_user(client: N8nClient, email: str, role: str = "global:member") -> str:
@@ -152,7 +158,7 @@ async def create_user(client: N8nClient, email: str, role: str = "global:member"
indent=2,
)
except Exception as e:
- return json.dumps({"success": False, "error": str(e)}, indent=2)
+ return _error_json(e)
async def delete_user(client: N8nClient, user_id: str) -> str:
@@ -160,7 +166,7 @@ async def delete_user(client: N8nClient, user_id: str) -> str:
await client.delete_user(user_id)
return json.dumps({"success": True, "message": f"User {user_id} deleted"}, indent=2)
except Exception as e:
- return json.dumps({"success": False, "error": str(e)}, indent=2)
+ return _error_json(e)
async def change_user_role(client: N8nClient, user_id: str, new_role: str) -> str:
@@ -170,4 +176,4 @@ async def change_user_role(client: N8nClient, user_id: str, new_role: str) -> st
{"success": True, "message": f"User {user_id} role changed to {new_role}"}, indent=2
)
except Exception as e:
- return json.dumps({"success": False, "error": str(e)}, indent=2)
+ return _error_json(e)
diff --git a/plugins/n8n/handlers/variables.py b/plugins/n8n/handlers/variables.py
index e86c42f..069ab4a 100644
--- a/plugins/n8n/handlers/variables.py
+++ b/plugins/n8n/handlers/variables.py
@@ -1,9 +1,15 @@
-"""Variables Handler - manages n8n environment variables"""
+"""Variables Handler - manages n8n environment variables."""
import json
from typing import Any
-from plugins.n8n.client import N8nClient
+from plugins.n8n.client import N8nApiError, N8nClient
+
+
+def _error_json(exc: Exception) -> str:
+ if isinstance(exc, N8nApiError):
+ return json.dumps({"success": False, **exc.to_dict()}, indent=2)
+ return json.dumps({"success": False, "error": str(exc)}, indent=2)
def get_tool_specifications() -> list[dict[str, Any]]:
@@ -106,7 +112,7 @@ async def list_variables(client: N8nClient, limit: int = 100, cursor: str | None
}
return json.dumps(result, indent=2)
except Exception as e:
- return json.dumps({"success": False, "error": str(e)}, indent=2)
+ return _error_json(e)
async def get_variable(client: N8nClient, key: str) -> str:
@@ -120,7 +126,7 @@ async def get_variable(client: N8nClient, key: str) -> str:
indent=2,
)
except Exception as e:
- return json.dumps({"success": False, "error": str(e)}, indent=2)
+ return _error_json(e)
async def create_variable(client: N8nClient, key: str, value: str) -> str:
@@ -135,7 +141,7 @@ async def create_variable(client: N8nClient, key: str, value: str) -> str:
indent=2,
)
except Exception as e:
- return json.dumps({"success": False, "error": str(e)}, indent=2)
+ return _error_json(e)
async def update_variable(client: N8nClient, key: str, value: str) -> str:
@@ -150,7 +156,7 @@ async def update_variable(client: N8nClient, key: str, value: str) -> str:
indent=2,
)
except Exception as e:
- return json.dumps({"success": False, "error": str(e)}, indent=2)
+ return _error_json(e)
async def delete_variable(client: N8nClient, key: str) -> str:
@@ -158,7 +164,7 @@ async def delete_variable(client: N8nClient, key: str) -> str:
await client.delete_variable(key)
return json.dumps({"success": True, "message": f"Variable '{key}' deleted"}, indent=2)
except Exception as e:
- return json.dumps({"success": False, "error": str(e)}, indent=2)
+ return _error_json(e)
async def set_variables(client: N8nClient, variables: dict[str, str]) -> str:
@@ -190,4 +196,4 @@ async def set_variables(client: N8nClient, variables: dict[str, str]) -> str:
indent=2,
)
except Exception as e:
- return json.dumps({"success": False, "error": str(e)}, indent=2)
+ return _error_json(e)
diff --git a/plugins/n8n/handlers/workflows.py b/plugins/n8n/handlers/workflows.py
index 06b41db..77ed9df 100644
--- a/plugins/n8n/handlers/workflows.py
+++ b/plugins/n8n/handlers/workflows.py
@@ -1,9 +1,9 @@
-"""Workflow Handler - manages n8n workflows"""
+"""Workflow Handler - manages n8n workflows."""
import json
from typing import Any
-from plugins.n8n.client import N8nClient
+from plugins.n8n.client import N8nApiError, N8nClient
def get_tool_specifications() -> list[dict[str, Any]]:
@@ -318,12 +318,45 @@ def get_tool_specifications() -> list[dict[str, Any]]:
},
"scope": "write",
},
+ # === TRANSFER WORKFLOW TO PROJECT (Enterprise) ===
+ {
+ "name": "transfer_workflow_to_project",
+ "method_name": "transfer_workflow_to_project",
+ "description": (
+ "Transfer a workflow to a different project (Enterprise/Pro). "
+ "The destination project must exist and the API key must have "
+ "access to both the source and destination projects."
+ ),
+ "schema": {
+ "type": "object",
+ "properties": {
+ "workflow_id": {
+ "type": "string",
+ "description": "Workflow ID to transfer",
+ "minLength": 1,
+ },
+ "destination_project_id": {
+ "type": "string",
+ "description": "Target project ID",
+ "minLength": 1,
+ },
+ },
+ "required": ["workflow_id", "destination_project_id"],
+ },
+ "scope": "write",
+ },
]
# === HANDLER FUNCTIONS ===
+def _error_json(exc: Exception) -> str:
+ if isinstance(exc, N8nApiError):
+ return json.dumps({"success": False, **exc.to_dict()}, indent=2)
+ return json.dumps({"success": False, "error": str(exc)}, indent=2)
+
+
async def list_workflows(
client: N8nClient,
active: bool | None = None,
@@ -361,7 +394,7 @@ async def list_workflows(
return json.dumps(result, indent=2)
except Exception as e:
- return json.dumps({"success": False, "error": str(e)}, indent=2)
+ return _error_json(e)
async def get_workflow(client: N8nClient, workflow_id: str) -> str:
@@ -388,7 +421,7 @@ async def get_workflow(client: N8nClient, workflow_id: str) -> str:
return json.dumps(result, indent=2)
except Exception as e:
- return json.dumps({"success": False, "error": str(e)}, indent=2)
+ return _error_json(e)
async def create_workflow(
@@ -429,7 +462,7 @@ async def create_workflow(
return json.dumps(result, indent=2)
except Exception as e:
- return json.dumps({"success": False, "error": str(e)}, indent=2)
+ return _error_json(e)
async def update_workflow(
@@ -470,7 +503,7 @@ async def update_workflow(
return json.dumps(result, indent=2)
except Exception as e:
- return json.dumps({"success": False, "error": str(e)}, indent=2)
+ return _error_json(e)
async def delete_workflow(client: N8nClient, workflow_id: str) -> str:
@@ -483,7 +516,7 @@ async def delete_workflow(client: N8nClient, workflow_id: str) -> str:
return json.dumps(result, indent=2)
except Exception as e:
- return json.dumps({"success": False, "error": str(e)}, indent=2)
+ return _error_json(e)
async def activate_workflow(client: N8nClient, workflow_id: str) -> str:
@@ -504,7 +537,7 @@ async def activate_workflow(client: N8nClient, workflow_id: str) -> str:
return json.dumps(result, indent=2)
except Exception as e:
- return json.dumps({"success": False, "error": str(e)}, indent=2)
+ return _error_json(e)
async def deactivate_workflow(client: N8nClient, workflow_id: str) -> str:
@@ -525,7 +558,7 @@ async def deactivate_workflow(client: N8nClient, workflow_id: str) -> str:
return json.dumps(result, indent=2)
except Exception as e:
- return json.dumps({"success": False, "error": str(e)}, indent=2)
+ return _error_json(e)
async def execute_workflow(client: N8nClient, workflow_id: str) -> str:
@@ -546,7 +579,7 @@ async def execute_workflow(client: N8nClient, workflow_id: str) -> str:
return json.dumps(result, indent=2)
except Exception as e:
- return json.dumps({"success": False, "error": str(e)}, indent=2)
+ return _error_json(e)
async def execute_workflow_with_data(
@@ -570,7 +603,7 @@ async def execute_workflow_with_data(
return json.dumps(result, indent=2)
except Exception as e:
- return json.dumps({"success": False, "error": str(e)}, indent=2)
+ return _error_json(e)
async def duplicate_workflow(client: N8nClient, workflow_id: str, new_name: str) -> str:
@@ -599,7 +632,7 @@ async def duplicate_workflow(client: N8nClient, workflow_id: str, new_name: str)
return json.dumps(result, indent=2)
except Exception as e:
- return json.dumps({"success": False, "error": str(e)}, indent=2)
+ return _error_json(e)
async def export_workflow(client: N8nClient, workflow_id: str) -> str:
@@ -613,7 +646,7 @@ async def export_workflow(client: N8nClient, workflow_id: str) -> str:
return json.dumps(result, indent=2)
except Exception as e:
- return json.dumps({"success": False, "error": str(e)}, indent=2)
+ return _error_json(e)
async def import_workflow(
@@ -646,7 +679,7 @@ async def import_workflow(
return json.dumps(result, indent=2)
except Exception as e:
- return json.dumps({"success": False, "error": str(e)}, indent=2)
+ return _error_json(e)
async def get_workflow_tags(client: N8nClient, workflow_id: str) -> str:
@@ -659,7 +692,7 @@ async def get_workflow_tags(client: N8nClient, workflow_id: str) -> str:
return json.dumps(result, indent=2)
except Exception as e:
- return json.dumps({"success": False, "error": str(e)}, indent=2)
+ return _error_json(e)
async def set_workflow_tags(
@@ -693,4 +726,23 @@ async def set_workflow_tags(
return json.dumps(result, indent=2)
except Exception as e:
- return json.dumps({"success": False, "error": str(e)}, indent=2)
+ return _error_json(e)
+
+
+async def transfer_workflow_to_project(
+ client: N8nClient, workflow_id: str, destination_project_id: str
+) -> str:
+ """Transfer a workflow to another project (Enterprise)."""
+ try:
+ await client.transfer_workflow(workflow_id, destination_project_id)
+ return json.dumps(
+ {
+ "success": True,
+ "message": f"Workflow {workflow_id} transferred to project {destination_project_id}",
+ "workflow_id": workflow_id,
+ "destination_project_id": destination_project_id,
+ },
+ indent=2,
+ )
+ except Exception as e:
+ return _error_json(e)
diff --git a/plugins/n8n/plugin.py b/plugins/n8n/plugin.py
index dbb2225..4f63a0b 100644
--- a/plugins/n8n/plugin.py
+++ b/plugins/n8n/plugin.py
@@ -18,8 +18,8 @@ class N8nPlugin(BasePlugin):
n8n Automation Plugin - Comprehensive workflow management.
Provides complete n8n management capabilities including:
- - Workflow management (CRUD, activate, deactivate, execute)
- - Execution monitoring (list, get, delete, retry, wait)
+ - Workflow management (CRUD, activate, deactivate, execute, transfer)
+ - Execution monitoring (list, get, delete, retry, wait, project filter)
- Credential management (get, create, delete, schema, transfer)
- Tag management (CRUD, bulk delete)
- User management (CRUD, roles)
@@ -27,7 +27,7 @@ class N8nPlugin(BasePlugin):
- Variable management (CRUD, bulk set) - Enterprise/Pro
- System operations (audit, source control, health)
- Total: 56 tools
+ Total: 57 tools
"""
@staticmethod
@@ -69,7 +69,7 @@ class N8nPlugin(BasePlugin):
specs = []
# Collect specifications from all handlers
- specs.extend(handlers.workflows.get_tool_specifications()) # 14 tools
+ specs.extend(handlers.workflows.get_tool_specifications()) # 15 tools
specs.extend(handlers.executions.get_tool_specifications()) # 8 tools
specs.extend(handlers.credentials.get_tool_specifications()) # 5 tools
specs.extend(handlers.tags.get_tool_specifications()) # 6 tools
@@ -118,24 +118,62 @@ class N8nPlugin(BasePlugin):
# Method not found in any handler
raise AttributeError(f"'{self.__class__.__name__}' object has no attribute '{name}'")
+ async def probe_credential_capabilities(self) -> dict[str, Any]:
+ """F.7e — probe the n8n API key's effective permissions.
+
+ Calls ``GET /api/v1/user`` which returns the current user's
+ ``globalRole`` (and ``globalScopes`` on enterprise). If the key
+ is invalid or the endpoint is unreachable we return a graceful
+ fallback so the dashboard can still render a status badge.
+ """
+ from plugins.n8n.client import N8nAuthError, N8nConnectionError
+
+ try:
+ user = await self.client.get_current_user()
+ except N8nAuthError as exc:
+ return {
+ "probe_available": False,
+ "granted": [],
+ "source": "n8n_api",
+ "reason": f"auth_failed: {exc}",
+ }
+ except (N8nConnectionError, Exception) as exc: # noqa: BLE001
+ return {
+ "probe_available": False,
+ "granted": [],
+ "source": "n8n_api",
+ "reason": f"probe_failed: {exc}",
+ }
+
+ role_name = ""
+ role_obj = user.get("role") or user.get("globalRole") or ""
+ if isinstance(role_obj, dict):
+ role_name = role_obj.get("name", "")
+ elif isinstance(role_obj, str):
+ role_name = role_obj
+ scopes = user.get("globalScopes") or []
+
+ return {
+ "probe_available": True,
+ "granted": sorted(scopes) if scopes else [role_name],
+ "source": "n8n_api",
+ "role": role_name,
+ "email": user.get("email", ""),
+ }
+
async def check_health(self) -> dict[str, Any]:
- """
- Check if n8n instance is accessible (internal use).
-
- Note: This is named check_health to avoid shadowing the handler's
- health_check function which is exposed as an MCP tool.
-
- Returns:
- Dict containing health check result
- """
+ """Check if n8n instance is accessible (internal use)."""
try:
result = await self.client.health_check()
return {
"healthy": result.get("healthy", False),
- "message": f"n8n instance at {self.client.site_url} is {'accessible' if result.get('healthy') else 'not accessible'}",
+ "message": (
+ f"n8n instance at {self.client.site_url} is "
+ f"{'accessible' if result.get('healthy') else 'not accessible'}"
+ ),
}
except Exception as e:
- return {"healthy": False, "message": f"n8n health check failed: {str(e)}"}
+ return {"healthy": False, "message": f"n8n health check failed: {e}"}
async def health_check(self, **kwargs) -> str:
"""
diff --git a/plugins/woocommerce/plugin.py b/plugins/woocommerce/plugin.py
index b7e6dbc..9f108a0 100644
--- a/plugins/woocommerce/plugin.py
+++ b/plugins/woocommerce/plugin.py
@@ -7,23 +7,30 @@ Provides 28 tools for WooCommerce store management.
Uses shared WordPress handlers for implementation.
"""
+import logging
from typing import Any
from plugins.base import BasePlugin
from plugins.wordpress.client import WordPressClient
from plugins.wordpress.handlers import (
+ AIMediaHandler,
CouponsHandler,
CustomersHandler,
+ MediaAttachHandler,
OrdersHandler,
ProductsHandler,
ReportsHandler,
+ get_ai_media_specs,
get_coupons_specs,
get_customers_specs,
+ get_media_attach_specs,
get_orders_specs,
get_products_specs,
get_reports_specs,
)
+logger = logging.getLogger(__name__)
+
class WooCommercePlugin(BasePlugin):
"""
@@ -58,6 +65,8 @@ class WooCommercePlugin(BasePlugin):
- url: WordPress/WooCommerce site URL
- consumer_key/consumer_secret: WooCommerce REST API keys (preferred)
- username/app_password: WordPress application password (fallback)
+ - wp_username/wp_app_password: optional WP Application Password
+ for /wp/v2/media calls (used by media tools — F.X.fix-pass4).
project_id: Optional project ID (auto-generated if not provided)
"""
super().__init__(config, project_id=project_id)
@@ -76,17 +85,68 @@ class WooCommercePlugin(BasePlugin):
"Please set either CONSUMER_KEY/CONSUMER_SECRET or USERNAME/APP_PASSWORD."
)
- # Create WordPress API client
+ # Create WordPress API client (used for /wc/v3/* WooCommerce REST).
self.client = WordPressClient(
site_url=config["url"], username=username, app_password=password
)
+ # F.X.fix-pass4 — derive a SECONDARY client for /wp/v2/* media
+ # uploads. WC's Consumer Key + Secret pair does NOT authenticate
+ # WP core REST endpoints; uploads to /wp/v2/media require an
+ # Application Password from the WP admin user. Three resolution
+ # paths, first that wins:
+ # 1. explicit wp_username + wp_app_password fields (recommended)
+ # 2. legacy username + app_password (single-credential mode)
+ # 3. None — media tools will surface a clear error at call time
+ wp_user = (config.get("wp_username") or "").strip()
+ wp_pw = (config.get("wp_app_password") or "").strip()
+ legacy_user = (config.get("username") or "").strip()
+ legacy_pw = (config.get("app_password") or "").strip()
+ # If consumer_key/consumer_secret are present, we're in WC-keys
+ # mode and the primary client cannot hit /wp/v2/*. We need
+ # explicit WP creds OR legacy app_password to enable media tools.
+ if wp_user and wp_pw:
+ self.wp_media_client: WordPressClient | None = WordPressClient(
+ site_url=config["url"], username=wp_user, app_password=wp_pw
+ )
+ elif (
+ legacy_user
+ and legacy_pw
+ and not (config.get("consumer_key") or config.get("consumer_secret"))
+ ):
+ # Legacy app_password mode (no ck/cs at all) — same client
+ # works for both /wp/v2/* and /wc/v3/* because it uses an
+ # Application Password.
+ self.wp_media_client = self.client
+ else:
+ self.wp_media_client = None
+
# Initialize WooCommerce handlers
self.products = ProductsHandler(self.client)
self.orders = OrdersHandler(self.client)
self.customers = CustomersHandler(self.client)
self.coupons = CouponsHandler(self.client)
self.reports = ReportsHandler(self.client)
+ self.media_attach = MediaAttachHandler(self.client, wp_media_client=self.wp_media_client)
+ # F.X.fix-pass5 — expose generate_and_upload_image on WC sites
+ # too (was WP-only). The handler reads the per-site provider
+ # key resolver and uploads via wp_media_client (so WC sites
+ # with consumer_key/consumer_secret as primary still work as
+ # long as wp_username + wp_app_password are configured).
+ # When wp_media_client is None we still register the tool so
+ # the user sees a clear NO_PROVIDER_KEY / WP_CREDENTIALS_MISSING
+ # error rather than a missing-tool 404.
+ self.ai_media = AIMediaHandler(
+ self.wp_media_client or self.client,
+ user_id=config.get("user_id"),
+ site_id=config.get("site_id"),
+ # F.X.fix-pass6 — pass the primary WC client so
+ # _apply_metadata_and_attach can detect "attach_to_post
+ # is a WC product" and route featured-image set through
+ # /wc/v3/products/{id} instead of the WP /posts endpoint
+ # which 404s for products.
+ wc_client=self.client,
+ )
@staticmethod
def get_tool_specifications() -> list[dict[str, Any]]:
@@ -116,6 +176,16 @@ class WooCommercePlugin(BasePlugin):
# Reports (3 tools)
specs.extend(get_reports_specs())
+ # F.5a.3: Media attachment (3 tools — attach_media_to_product,
+ # upload_and_attach_to_product, set_featured_image)
+ specs.extend(get_media_attach_specs())
+
+ # F.X.fix-pass5: AI image generation (1 tool — also surfaced on
+ # the WP plugin; same handler, same per-site provider key
+ # resolver). On a WC site this lets the operator chain
+ # generate → attach without two endpoints.
+ specs.extend(get_ai_media_specs())
+
return specs
async def health_check(self) -> dict[str, Any]:
@@ -147,6 +217,118 @@ class WooCommercePlugin(BasePlugin):
"plugin_type": "woocommerce",
}
+ async def probe_credential_capabilities(self) -> dict[str, Any]:
+ """F.7e — report what the WooCommerce consumer key grants.
+
+ Uses ``GET /wp-json/wc/v3/system_status`` which is readable by any
+ key with at least ``read`` permission and includes a
+ ``security.rest_api_keys[]`` array where each entry carries a
+ ``permissions`` field (``read`` / ``write`` / ``read_write``). We
+ match the caller's consumer_key against the ``truncated_key`` the
+ endpoint reports (last 7 chars) and derive the granted cap list.
+
+ Falls back gracefully on any network / shape failure — callers
+ still get the tools, they just don't get the early-warning badge.
+ """
+ try:
+ status = await self.client.get("system_status", use_woocommerce=True)
+ except Exception as exc: # noqa: BLE001
+ return {
+ "probe_available": False,
+ "granted": [],
+ "source": "woocommerce_system_status",
+ "reason": f"system_status_unreachable: {exc}",
+ }
+
+ if not isinstance(status, dict):
+ return {
+ "probe_available": False,
+ "granted": [],
+ "source": "woocommerce_system_status",
+ "reason": "non_dict_response",
+ }
+
+ keys = ((status.get("security") or {}).get("rest_api_keys")) or []
+ consumer_key = self.config.get("consumer_key") or self.config.get("username") or ""
+ # WC's system_status truncates to last 7 chars by default.
+ my_tail = consumer_key[-7:] if consumer_key else ""
+ match = None
+ for entry in keys:
+ if not isinstance(entry, dict):
+ continue
+ tail = str(entry.get("truncated_key") or "")
+ if my_tail and tail == my_tail:
+ match = entry
+ break
+
+ if match is None:
+ # Fallback: if there's exactly one key listed OR the endpoint
+ # doesn't expose truncated_key on this WC version, fall back
+ # to the top entry so we still show *something* useful.
+ if len(keys) == 1 and isinstance(keys[0], dict):
+ match = keys[0]
+
+ # F.X.fix-pass5 — flag whether the site has WP App Password
+ # credentials configured. Media tools (and AI image with
+ # attach) need these on a WC site whose primary credential is
+ # consumer_key + secret. The prerequisites resolver in
+ # core/tool_access reads this to gate the WC media tool list.
+ wp_credentials_present = bool(
+ (self.config.get("wp_username") and self.config.get("wp_app_password"))
+ or (
+ self.config.get("username")
+ and self.config.get("app_password")
+ and not (self.config.get("consumer_key") or self.config.get("consumer_secret"))
+ )
+ )
+
+ if match is not None:
+ permission = str(match.get("permissions") or "").lower()
+ granted: list[str] = []
+ if permission in {"read", "read_write", "write"}:
+ granted.append("read_products")
+ granted.append("read_orders")
+ if permission in {"write", "read_write"}:
+ granted.append("write_products")
+ granted.append("write_orders")
+
+ return {
+ "probe_available": True,
+ "granted": sorted(granted),
+ "source": "woocommerce_system_status",
+ "permissions": permission,
+ "wp_credentials_present": wp_credentials_present,
+ }
+
+ # F.X.fix-pass3 — match is None (consumer key not listed). This
+ # happens when:
+ # * system_status omits truncated_key on the running WC build
+ # * the key was created via WP-CLI / a custom path
+ # * the key is shadowed by another key with the same tail
+ # Don't surface a false-negative "probe unavailable" (which the
+ # badge labelled with a misleading "install companion plugin"
+ # hint, since WC has no companion).
+ #
+ # F.X.fix-pass5 — STAY CONSERVATIVE. The previous pass probed
+ # ``GET /wc/v3/settings`` and upgraded to write+admin on 200,
+ # but that signal mixes two unrelated facts (WP user has
+ # ``manage_woocommerce`` capability AND key has any read perm)
+ # and was over-granting on read-only keys whose backing user
+ # was an admin. Result: tier-fit "Read + Write" stayed green
+ # for read-only keys. Now we report read-only and let the
+ # tier-fit warning fire correctly. Operators with read+write
+ # keys still see their tools work; the badge just says
+ # "Currently granted: read_products, read_orders" — a soft
+ # signal, not a block.
+ return {
+ "probe_available": True,
+ "granted": sorted({"read_products", "read_orders"}),
+ "source": "woocommerce_system_status_inferred",
+ "permissions": "inferred",
+ "probe_inferred": True,
+ "wp_credentials_present": wp_credentials_present,
+ }
+
# ========================================
# Method Delegation to Handlers
# ========================================
@@ -239,3 +421,17 @@ class WooCommercePlugin(BasePlugin):
async def get_customer_report(self, **kwargs):
return await self.reports.get_customer_report(**kwargs)
+
+ # === F.5a.3: Media attach ===
+ async def attach_media_to_product(self, **kwargs):
+ return await self.media_attach.attach_media_to_product(**kwargs)
+
+ async def upload_and_attach_to_product(self, **kwargs):
+ return await self.media_attach.upload_and_attach_to_product(**kwargs)
+
+ async def set_featured_image(self, **kwargs):
+ return await self.media_attach.set_featured_image(**kwargs)
+
+ # === F.X.fix-pass5: AI image (re-exported from WP handler) ===
+ async def generate_and_upload_image(self, **kwargs):
+ return await self.ai_media.generate_and_upload_image(**kwargs)
diff --git a/plugins/wordpress/client.py b/plugins/wordpress/client.py
index be3592d..0c701d9 100644
--- a/plugins/wordpress/client.py
+++ b/plugins/wordpress/client.py
@@ -33,11 +33,42 @@ class ConnectionError(Exception):
pass
+class SiteUnreachableError(ConnectionError):
+ """Raised when the WordPress site is not reachable at the TCP/DNS layer.
+
+ Subclass of :class:`ConnectionError` so existing ``except ConnectionError``
+ sites keep working. Carries a structured ``error_code='SITE_UNREACHABLE'``
+ plus optional ``install_hint`` so companion-backed handlers and the
+ capability probe can emit a uniform error payload that the dashboard
+ can render as a "check your URL / install companion" prompt in <10s
+ instead of the previous 35-85s hang.
+ """
+
+ error_code = "SITE_UNREACHABLE"
+
+ def __init__(
+ self,
+ message: str,
+ *,
+ install_hint: dict[str, Any] | None = None,
+ reason: str = "site_unreachable",
+ ) -> None:
+ super().__init__(message)
+ self.install_hint = install_hint
+ self.reason = reason
+
+
# Transient HTTP status codes that are worth retrying
_RETRYABLE_STATUS_CODES = {502, 503, 504, 429}
-# Default request timeout in seconds
+# Default request timeout in seconds (wall clock).
_REQUEST_TIMEOUT = 30
+# Connect timeout: how long to wait for the TCP handshake before giving
+# up. Five seconds is enough to rule out DNS/TCP failure on any real
+# site; beyond that the site is down. Short connect + long total lets us
+# fail fast on unreachable hosts while still allowing slow responses
+# from reachable ones to complete.
+_CONNECT_TIMEOUT = 5
# Retry configuration
_MAX_RETRIES = 2
@@ -126,7 +157,7 @@ class WordPressClient:
"""
# Build URL based on endpoint type
if use_custom_namespace:
- # For custom namespaces like airano-mcp-seo-bridge/v1
+ # For custom namespaces like airano-mcp-bridge/v1
url = f"{self.site_url}/wp-json/{endpoint}"
elif use_woocommerce:
# For WooCommerce endpoints
@@ -158,8 +189,10 @@ class WordPressClient:
if json_data:
json_data = {k: v for k, v in json_data.items() if should_include(v)}
- # Make request with retry for transient errors
- timeout = aiohttp.ClientTimeout(total=_REQUEST_TIMEOUT)
+ # Make request with retry for transient errors. connect=5
+ # short-circuits TCP/DNS failures in <10s (2 retries × 5s each
+ # worst case) instead of the previous 35-85s hang.
+ timeout = aiohttp.ClientTimeout(total=_REQUEST_TIMEOUT, connect=_CONNECT_TIMEOUT)
last_exception = None
for attempt in range(_MAX_RETRIES + 1):
@@ -209,11 +242,15 @@ class WordPressClient:
raise # Never retry auth/config errors
except TimeoutError:
- last_exception = ConnectionError(
- f"Request timed out after {_REQUEST_TIMEOUT}s. "
- f"The site at {self.site_url} is not responding. "
- "Possible causes: site is overloaded, network is slow, "
- "or the server is down."
+ last_exception = SiteUnreachableError(
+ (
+ f"Request timed out after {_REQUEST_TIMEOUT}s. "
+ f"The site at {self.site_url} is not responding. "
+ "Possible causes: site is overloaded, network is "
+ "slow, or the server is down."
+ ),
+ install_hint=self._site_unreachable_install_hint(),
+ reason="site_timeout",
)
if attempt < _MAX_RETRIES:
wait = _RETRY_BACKOFF_BASE * (2**attempt)
@@ -225,40 +262,61 @@ class WordPressClient:
continue
except aiohttp.ClientConnectorCertificateError as e:
- raise ConnectionError(
- f"SSL certificate error for {self.site_url}. "
- "The site's SSL certificate is invalid or expired. "
- f"Details: {e}"
+ raise SiteUnreachableError(
+ (
+ f"SSL certificate error for {self.site_url}. "
+ "The site's SSL certificate is invalid or expired. "
+ f"Details: {e}"
+ ),
+ install_hint=self._site_unreachable_install_hint(),
+ reason="site_ssl_error",
) from e
except aiohttp.ClientConnectorDNSError as e:
host = self.site_url.split("://")[-1].split("/")[0]
- raise ConnectionError(
- f"DNS resolution failed for '{host}'. "
- "The domain name could not be found. "
- "Please check that the URL is correct."
+ raise SiteUnreachableError(
+ (
+ f"DNS resolution failed for '{host}'. "
+ "The domain name could not be found. "
+ "Please check that the URL is correct."
+ ),
+ install_hint=self._site_unreachable_install_hint(),
+ reason="site_dns_error",
) from e
except aiohttp.ClientConnectorError as e:
os_error = getattr(e, "os_error", None)
if isinstance(os_error, socket.gaierror):
host = self.site_url.split("://")[-1].split("/")[0]
- raise ConnectionError(
- f"DNS resolution failed for '{host}'. "
- "The domain name could not be found. "
- "Please check that the URL is correct."
+ raise SiteUnreachableError(
+ (
+ f"DNS resolution failed for '{host}'. "
+ "The domain name could not be found. "
+ "Please check that the URL is correct."
+ ),
+ install_hint=self._site_unreachable_install_hint(),
+ reason="site_dns_error",
) from e
- raise ConnectionError(
- f"Cannot connect to {self.site_url}. "
- "The server is unreachable. Possible causes: "
- "wrong URL, server is down, firewall blocking, or wrong port."
+ raise SiteUnreachableError(
+ (
+ f"Cannot connect to {self.site_url}. "
+ "The server is unreachable. Possible causes: "
+ "wrong URL, server is down, firewall blocking, "
+ "or wrong port."
+ ),
+ install_hint=self._site_unreachable_install_hint(),
+ reason="site_connection_refused",
) from e
except aiohttp.InvalidURL:
- raise ConnectionError(
- f"Invalid URL: {self.site_url}. "
- "Please provide a valid URL starting with https:// or http://."
+ raise SiteUnreachableError(
+ (
+ f"Invalid URL: {self.site_url}. "
+ "Please provide a valid URL starting with "
+ "https:// or http://."
+ ),
+ reason="site_invalid_url",
)
except (aiohttp.ClientError, OSError) as e:
@@ -277,6 +335,25 @@ class WordPressClient:
# All retries exhausted
raise last_exception # type: ignore[misc]
+ @staticmethod
+ def _site_unreachable_install_hint() -> dict[str, Any]:
+ """Structured install hint for SITE_UNREACHABLE errors.
+
+ Mirrors the shape produced by
+ ``plugins.wordpress.handlers._companion_hint.companion_install_hint``
+ so dashboard code can render one uniform "fix your connection"
+ prompt regardless of whether the error came from the low-level
+ client or a companion-backed handler.
+ """
+ from plugins.wordpress.handlers._companion_hint import (
+ companion_install_hint,
+ )
+
+ return companion_install_hint(
+ min_version="2.9.0",
+ required_capability="manage_options",
+ )
+
def _parse_error_response(
self, status_code: int, error_text: str, use_woocommerce: bool = False
) -> dict[str, Any]:
diff --git a/plugins/wordpress/handlers/__init__.py b/plugins/wordpress/handlers/__init__.py
index ebef558..709011a 100644
--- a/plugins/wordpress/handlers/__init__.py
+++ b/plugins/wordpress/handlers/__init__.py
@@ -7,14 +7,48 @@ Each handler is responsible for a specific domain of WordPress operations.
Part of Option B clean architecture refactoring.
"""
+from plugins.wordpress.handlers.ai_media import AIMediaHandler
+from plugins.wordpress.handlers.ai_media import get_tool_specifications as get_ai_media_specs
+from plugins.wordpress.handlers.audit_hook import AuditHookHandler
+from plugins.wordpress.handlers.audit_hook import (
+ get_tool_specifications as get_audit_hook_specs,
+)
+from plugins.wordpress.handlers.bulk_meta import BulkMetaHandler
+from plugins.wordpress.handlers.bulk_meta import get_tool_specifications as get_bulk_meta_specs
+from plugins.wordpress.handlers.cache_purge import CachePurgeHandler
+from plugins.wordpress.handlers.cache_purge import (
+ get_tool_specifications as get_cache_purge_specs,
+)
+from plugins.wordpress.handlers.capabilities import CapabilitiesHandler
+from plugins.wordpress.handlers.capabilities import (
+ get_tool_specifications as get_capabilities_specs,
+)
from plugins.wordpress.handlers.comments import CommentsHandler
from plugins.wordpress.handlers.comments import get_tool_specifications as get_comments_specs
from plugins.wordpress.handlers.coupons import CouponsHandler
from plugins.wordpress.handlers.coupons import get_tool_specifications as get_coupons_specs
from plugins.wordpress.handlers.customers import CustomersHandler
from plugins.wordpress.handlers.customers import get_tool_specifications as get_customers_specs
+from plugins.wordpress.handlers.export import ExportHandler
+from plugins.wordpress.handlers.export import get_tool_specifications as get_export_specs
from plugins.wordpress.handlers.media import MediaHandler
from plugins.wordpress.handlers.media import get_tool_specifications as get_media_specs
+from plugins.wordpress.handlers.media_attach import MediaAttachHandler
+from plugins.wordpress.handlers.media_attach import (
+ get_tool_specifications as get_media_attach_specs,
+)
+from plugins.wordpress.handlers.media_bulk import MediaBulkHandler
+from plugins.wordpress.handlers.media_bulk import (
+ get_tool_specifications as get_media_bulk_specs,
+)
+from plugins.wordpress.handlers.media_chunked import MediaChunkedHandler
+from plugins.wordpress.handlers.media_chunked import (
+ get_tool_specifications as get_media_chunked_specs,
+)
+from plugins.wordpress.handlers.media_probe import ProbeHandler
+from plugins.wordpress.handlers.media_probe import (
+ get_tool_specifications as get_media_probe_specs,
+)
from plugins.wordpress.handlers.menus import MenusHandler
from plugins.wordpress.handlers.menus import get_tool_specifications as get_menus_specs
from plugins.wordpress.handlers.orders import OrdersHandler
@@ -23,14 +57,26 @@ from plugins.wordpress.handlers.posts import PostsHandler
from plugins.wordpress.handlers.posts import get_tool_specifications as get_posts_specs
from plugins.wordpress.handlers.products import ProductsHandler
from plugins.wordpress.handlers.products import get_tool_specifications as get_products_specs
+from plugins.wordpress.handlers.regenerate_thumbnails import RegenerateThumbnailsHandler
+from plugins.wordpress.handlers.regenerate_thumbnails import (
+ get_tool_specifications as get_regenerate_thumbnails_specs,
+)
from plugins.wordpress.handlers.reports import ReportsHandler
from plugins.wordpress.handlers.reports import get_tool_specifications as get_reports_specs
from plugins.wordpress.handlers.seo import SEOHandler
from plugins.wordpress.handlers.seo import get_tool_specifications as get_seo_specs
from plugins.wordpress.handlers.site import SiteHandler
from plugins.wordpress.handlers.site import get_tool_specifications as get_site_specs
+from plugins.wordpress.handlers.site_health import SiteHealthHandler
+from plugins.wordpress.handlers.site_health import (
+ get_tool_specifications as get_site_health_specs,
+)
from plugins.wordpress.handlers.taxonomy import TaxonomyHandler
from plugins.wordpress.handlers.taxonomy import get_tool_specifications as get_taxonomy_specs
+from plugins.wordpress.handlers.transient_flush import TransientFlushHandler
+from plugins.wordpress.handlers.transient_flush import (
+ get_tool_specifications as get_transient_flush_specs,
+)
from plugins.wordpress.handlers.users import UsersHandler
from plugins.wordpress.handlers.users import get_tool_specifications as get_users_specs
from plugins.wordpress.handlers.wp_cli import WPCLIHandler
@@ -40,6 +86,19 @@ __all__ = [
# Core Handlers
"PostsHandler",
"MediaHandler",
+ "MediaAttachHandler",
+ "MediaBulkHandler",
+ "MediaChunkedHandler",
+ "ProbeHandler",
+ "AIMediaHandler",
+ "AuditHookHandler",
+ "BulkMetaHandler",
+ "CachePurgeHandler",
+ "CapabilitiesHandler",
+ "RegenerateThumbnailsHandler",
+ "ExportHandler",
+ "SiteHealthHandler",
+ "TransientFlushHandler",
"TaxonomyHandler",
"CommentsHandler",
"UsersHandler",
@@ -57,6 +116,19 @@ __all__ = [
# Tool specifications
"get_posts_specs",
"get_media_specs",
+ "get_media_attach_specs",
+ "get_media_bulk_specs",
+ "get_media_chunked_specs",
+ "get_media_probe_specs",
+ "get_ai_media_specs",
+ "get_audit_hook_specs",
+ "get_bulk_meta_specs",
+ "get_cache_purge_specs",
+ "get_capabilities_specs",
+ "get_regenerate_thumbnails_specs",
+ "get_export_specs",
+ "get_site_health_specs",
+ "get_transient_flush_specs",
"get_taxonomy_specs",
"get_comments_specs",
"get_users_specs",
diff --git a/plugins/wordpress/handlers/_companion_hint.py b/plugins/wordpress/handlers/_companion_hint.py
new file mode 100644
index 0000000..e9dfbe3
--- /dev/null
+++ b/plugins/wordpress/handlers/_companion_hint.py
@@ -0,0 +1,65 @@
+"""Shared ``companion_unreachable`` error-hint helper.
+
+Every companion-backed handler (cache_purge, bulk_meta, export,
+site_health, transient_flush, audit_hook, regenerate_thumbnails,
+capabilities) returns a structured JSON error when the companion
+plugin isn't installed / reachable. This module provides a single
+helper so the hint message, download URL, and install instructions
+stay in sync.
+
+F.20 will swap the download URL constant from the GitHub raw path to
+``wordpress.org/plugins/airano-mcp-bridge/`` once the wp.org listing
+is live. At that point a single edit here + the dashboard template
+(``core/templates/dashboard/sites/manage.html``) covers everything.
+"""
+
+from __future__ import annotations
+
+# Single source of truth for the companion download URL. Mirror of the
+# constant used in ``core/dashboard/routes.py``. Kept as a plain string
+# rather than imported to avoid a core→plugin import cycle.
+COMPANION_DOWNLOAD_URL = (
+ "https://github.com/airano-ir/mcphub/raw/main/" "wordpress-plugin/airano-mcp-bridge.zip"
+)
+
+
+def companion_install_hint(
+ *,
+ min_version: str,
+ required_capability: str = "manage_options",
+ route: str | None = None,
+) -> dict[str, str]:
+ """Return a ``hint`` dict that every companion-backed handler can
+ merge into its ``companion_unreachable`` payload.
+
+ Args:
+ min_version: Earliest companion plugin version that exposes the
+ route this handler uses (e.g. ``"2.4.0"``). Surfaced in the
+ install hint so the user knows what they need.
+ required_capability: WordPress capability the calling user needs
+ for the companion route. Default ``manage_options``.
+ route: Optional route path — included in the hint when set so
+ the user can sanity-check by hitting the endpoint directly.
+
+ Returns:
+ Dict with ``install_url``, ``install_instructions``, and
+ ``required_capability``. Callers merge into their existing
+ structured error response alongside ``error`` / ``message``.
+ """
+ instructions = (
+ f"Install the Airano MCP Bridge companion plugin (v{min_version}+) "
+ "on the WordPress site. Download the zip, then in the WP admin: "
+ "Plugins → Add New → Upload Plugin → select the file → Activate. "
+ f"Ensure the Application Password user has the ``{required_capability}`` "
+ "capability. Run ``wordpress_probe_capabilities`` to verify the "
+ "route is advertised."
+ )
+ out = {
+ "install_url": COMPANION_DOWNLOAD_URL,
+ "install_instructions": instructions,
+ "required_capability": required_capability,
+ "companion_min_version": min_version,
+ }
+ if route:
+ out["route"] = route
+ return out
diff --git a/plugins/wordpress/handlers/_media_core.py b/plugins/wordpress/handlers/_media_core.py
new file mode 100644
index 0000000..44cb4b8
--- /dev/null
+++ b/plugins/wordpress/handlers/_media_core.py
@@ -0,0 +1,407 @@
+"""Shared raw-binary upload primitive for WordPress media library.
+
+WP REST `/wp/v2/media` expects the raw binary file body with a `Content-Disposition`
+header (NOT multipart/form-data). Metadata fields like alt_text/caption/title must
+be set via a follow-up `POST /media/{id}` JSON call.
+
+F.5a.7: when the airano-mcp companion plugin is present AND the file size
+exceeds the site's advertised ``upload_max_filesize``, prefer the companion
+``POST /airano-mcp/v1/upload-chunk`` route which reads the body via
+``php://input`` (bypasses ``upload_max_filesize``). On any failure from the
+companion route we fall back to the standard ``/wp/v2/media`` path so we
+never regress the default upload behaviour.
+"""
+
+from __future__ import annotations
+
+import asyncio
+import logging
+from typing import Any
+
+import aiohttp
+
+from plugins.wordpress.client import WordPressClient
+from plugins.wordpress.handlers._media_security import (
+ ALLOWED_MIMES,
+ DEFAULT_MAX_BYTES,
+ UploadError,
+ content_disposition,
+ safe_filename,
+ sniff_mime,
+ validate_mime,
+ validate_size,
+)
+
+_logger = logging.getLogger("mcphub.wordpress.media")
+
+_UPLOAD_TIMEOUT = 120 # seconds
+
+# Endpoint suffix (under /wp-json/) for the companion upload-chunk route.
+_COMPANION_UPLOAD_ENDPOINT = "airano-mcp/v1/upload-chunk"
+# F.5a.8.5: single-call upload + attach + featured via companion v2.9.0+.
+_COMPANION_UPLOAD_AND_ATTACH_ENDPOINT = "airano-mcp/v1/upload-and-attach"
+
+
+async def _should_use_companion(client: WordPressClient, size: int) -> bool:
+ """F.5a.7: decide whether to prefer the companion upload-chunk route.
+
+ True when the cached probe result marks the companion helper as available
+ AND the payload size exceeds the site's advertised ``upload_max_filesize``
+ (falling back to the effective ceiling when that specific key is unset).
+
+ We only consult the *cached* probe — never trigger a fresh probe from the
+ upload path — so a cold cache simply degrades to the standard route.
+ """
+ try:
+ from plugins.wordpress.handlers.media_probe import get_cached_limits
+ except Exception: # pragma: no cover - defensive
+ return False
+
+ try:
+ cached = await get_cached_limits(client)
+ except Exception as exc: # noqa: BLE001
+ _logger.debug("Companion probe lookup failed: %s", exc)
+ return False
+ if not cached or not cached.get("companion_available"):
+ return False
+
+ limits_bytes = cached.get("limits_bytes") or {}
+ ceiling = (
+ limits_bytes.get("upload_max_filesize")
+ or limits_bytes.get("wp_max_upload_size")
+ or limits_bytes.get("effective_ceiling")
+ )
+ if ceiling is None or ceiling <= 0:
+ return False
+ return size > ceiling
+
+
+async def _companion_has_upload_and_attach(client: WordPressClient) -> bool:
+ """F.5a.8.5: does the cached capability probe advertise the
+ ``upload_and_attach`` route?
+
+ We only check the cached result — never force a fresh probe from
+ the upload path. If the cache is cold, we conservatively return
+ False and the caller falls back to the 3-step path; the probe
+ will warm up on the next dashboard visit.
+ """
+ try:
+ from plugins.wordpress.handlers.capabilities import get_cached_capabilities
+ except Exception: # pragma: no cover
+ return False
+ try:
+ cached = await get_cached_capabilities(client)
+ except Exception as exc: # noqa: BLE001
+ _logger.debug("Companion capability cache lookup failed: %s", exc)
+ return False
+ if not cached or not cached.get("companion_available"):
+ return False
+ routes = cached.get("routes") or {}
+ return bool(routes.get("upload_and_attach"))
+
+
+async def _companion_upload_and_attach(
+ client: WordPressClient,
+ data: bytes,
+ *,
+ sniffed: str,
+ disposition: str,
+ attach_to_post: int | None,
+ set_featured: bool,
+ title: str | None,
+ alt_text: str | None,
+ caption: str | None,
+ description: str | None,
+ idempotency_key: str | None = None,
+) -> dict[str, Any]:
+ """Upload + metadata + attach + featured in a single companion call.
+
+ Raises ``UploadError`` on any non-2xx response so the caller can
+ fall back to ``wp/v2/media`` + separate metadata calls.
+ """
+ params: dict[str, str] = {}
+ if attach_to_post and attach_to_post > 0:
+ params["attach_to_post"] = str(int(attach_to_post))
+ if set_featured and attach_to_post and attach_to_post > 0:
+ params["set_featured"] = "true"
+ if title:
+ params["title"] = str(title)
+ if alt_text:
+ params["alt_text"] = str(alt_text)
+ if caption:
+ params["caption"] = str(caption)
+ if description:
+ params["description"] = str(description)
+
+ url = f"{client.site_url}/wp-json/{_COMPANION_UPLOAD_AND_ATTACH_ENDPOINT}"
+ headers = {
+ "Authorization": client.auth_header,
+ "Content-Type": sniffed,
+ "Content-Disposition": disposition,
+ }
+ # F.X.fix #7: pass the caller's idempotency key through to the
+ # companion so a retry after client timeout returns the original
+ # attachment instead of creating an "-2.webp" orphan.
+ if idempotency_key:
+ headers["Idempotency-Key"] = str(idempotency_key)
+ timeout = aiohttp.ClientTimeout(total=_UPLOAD_TIMEOUT)
+ async with aiohttp.ClientSession(timeout=timeout) as session:
+ async with session.post(url, data=data, headers=headers, params=params) as response:
+ text = await response.text()
+ if response.status >= 400:
+ raise UploadError(
+ f"COMPANION_{response.status}",
+ f"Companion upload-and-attach failed: HTTP {response.status}",
+ {"status": response.status, "body": text[:500]},
+ )
+ try:
+ return await response.json(content_type=None)
+ except Exception as e:
+ raise UploadError(
+ "COMPANION_BAD_RESPONSE",
+ f"Companion upload-and-attach returned non-JSON response: {e}",
+ {"body": text[:500]},
+ ) from e
+
+
+async def _companion_raw_upload(
+ client: WordPressClient,
+ data: bytes,
+ *,
+ sniffed: str,
+ disposition: str,
+) -> dict[str, Any]:
+ """Upload via the companion ``POST /airano-mcp/v1/upload-chunk`` route.
+
+ Raises ``UploadError`` on any non-2xx response so the caller can fall
+ back to the standard /wp/v2/media path.
+ """
+ url = f"{client.site_url}/wp-json/{_COMPANION_UPLOAD_ENDPOINT}"
+ headers = {
+ "Authorization": client.auth_header,
+ "Content-Type": sniffed,
+ "Content-Disposition": disposition,
+ }
+ timeout = aiohttp.ClientTimeout(total=_UPLOAD_TIMEOUT)
+ async with aiohttp.ClientSession(timeout=timeout) as session:
+ async with session.post(url, data=data, headers=headers) as response:
+ text = await response.text()
+ if response.status >= 400:
+ raise UploadError(
+ f"COMPANION_{response.status}",
+ f"Companion upload-chunk failed: HTTP {response.status}",
+ {"status": response.status, "body": text[:500]},
+ )
+ try:
+ return await response.json(content_type=None)
+ except Exception as e:
+ raise UploadError(
+ "COMPANION_BAD_RESPONSE",
+ f"Companion upload returned non-JSON response: {e}",
+ {"body": text[:500]},
+ ) from e
+
+
+async def wp_raw_upload(
+ client: WordPressClient,
+ data: bytes,
+ *,
+ filename: str | None,
+ mime_hint: str | None = None,
+ max_bytes: int = DEFAULT_MAX_BYTES,
+ allowed_mimes: set[str] = ALLOWED_MIMES,
+ # F.5a.8.5: optional attach + featured + metadata params. When any of
+ # these is provided AND the companion's upload-and-attach route is
+ # advertised in the cached capability probe, we POST to the single-
+ # call endpoint instead of doing the 3-step REST dance. Returned
+ # dict is marked ``_upload_route="companion_unified"`` so the caller
+ # (``_apply_metadata_and_attach``) can skip the separate metadata /
+ # featured-image calls.
+ attach_to_post: int | None = None,
+ set_featured: bool = False,
+ title: str | None = None,
+ alt_text: str | None = None,
+ caption: str | None = None,
+ description: str | None = None,
+ idempotency_key: str | None = None,
+) -> dict[str, Any]:
+ """Upload raw bytes to WP media library. Returns the attachment dict from WP.
+
+ When the companion plugin advertises limits smaller than the payload, we
+ POST to ``/airano-mcp/v1/upload-chunk`` first and only fall back to
+ ``/wp/v2/media`` if the companion route errors.
+
+ F.5a.8.5: when ``attach_to_post`` or any metadata field is set AND the
+ companion advertises the ``upload_and_attach`` route, we prefer the
+ single-call path which bundles upload + metadata + attach + featured
+ into one PHP request.
+ """
+ validate_size(data, max_bytes=max_bytes)
+ sniffed = sniff_mime(data, hint=mime_hint)
+ validate_mime(sniffed, allowed=allowed_mimes)
+
+ ascii_name, encoded = safe_filename(filename, mime=sniffed)
+ disposition = content_disposition(ascii_name, encoded)
+
+ # F.5a.8.5 unified route: tried first when the caller wants metadata
+ # applied AND the companion supports it. Falls back to the legacy
+ # path on any error (companion 4xx/5xx, route not advertised, etc.).
+ has_metadata_intent = (
+ any(v is not None for v in (attach_to_post, title, alt_text, caption, description))
+ or set_featured
+ )
+ if has_metadata_intent and await _companion_has_upload_and_attach(client):
+ try:
+ result = await _companion_upload_and_attach(
+ client,
+ data,
+ sniffed=sniffed,
+ disposition=disposition,
+ attach_to_post=attach_to_post,
+ set_featured=set_featured,
+ title=title,
+ alt_text=alt_text,
+ caption=caption,
+ description=description,
+ idempotency_key=idempotency_key,
+ )
+ result["_upload_route"] = "companion_unified"
+ return result
+ except UploadError as exc:
+ _logger.warning(
+ "Companion upload-and-attach failed (%s); falling back to "
+ "/upload-chunk + separate metadata calls",
+ exc.code,
+ )
+
+ # F.5a.7 route selection (non-fatal; falls back to /wp/v2/media on error).
+ if await _should_use_companion(client, len(data)):
+ try:
+ result = await _companion_raw_upload(
+ client,
+ data,
+ sniffed=sniffed,
+ disposition=disposition,
+ )
+ result["_upload_route"] = "companion"
+ return result
+ except UploadError as exc:
+ _logger.warning(
+ "Companion upload-chunk failed (%s); falling back to /wp/v2/media",
+ exc.code,
+ )
+
+ url = f"{client.api_base}/media"
+ headers = {
+ "Authorization": client.auth_header,
+ "Content-Type": sniffed,
+ "Content-Disposition": disposition,
+ }
+
+ timeout = aiohttp.ClientTimeout(total=_UPLOAD_TIMEOUT)
+ async with aiohttp.ClientSession(timeout=timeout) as session:
+ async with session.post(url, data=data, headers=headers) as response:
+ text = await response.text()
+ if response.status == 413:
+ raise UploadError(
+ "WP_413",
+ "WordPress rejected upload (413 Payload Too Large). "
+ "Site's upload_max_filesize / post_max_size is below the file size.",
+ {"status": 413, "body": text[:500]},
+ )
+ if response.status in (401, 403):
+ raise UploadError(
+ "WP_AUTH",
+ f"WordPress rejected auth ({response.status}). Verify Application Password.",
+ {"status": response.status, "body": text[:500]},
+ )
+ if response.status >= 400:
+ raise UploadError(
+ f"WP_{response.status}",
+ f"WordPress upload failed: HTTP {response.status}",
+ {"status": response.status, "body": text[:500]},
+ )
+ try:
+ result = await response.json(content_type=None)
+ if isinstance(result, dict):
+ result["_upload_route"] = "rest"
+ return result
+ except Exception as e:
+ raise UploadError(
+ "WP_BAD_RESPONSE",
+ f"WP upload returned non-JSON response: {e}",
+ {"body": text[:500]},
+ ) from e
+
+
+async def wp_update_media_metadata(
+ client: WordPressClient,
+ media_id: int,
+ *,
+ title: str | None = None,
+ alt_text: str | None = None,
+ caption: str | None = None,
+ description: str | None = None,
+ post: int | None = None,
+) -> dict[str, Any]:
+ """Apply metadata fields via POST /media/{id}. Only sends non-None fields."""
+ payload: dict[str, Any] = {}
+ if title is not None:
+ payload["title"] = title
+ if alt_text is not None:
+ payload["alt_text"] = alt_text
+ if caption is not None:
+ payload["caption"] = caption
+ if description is not None:
+ payload["description"] = description
+ if post is not None:
+ payload["post"] = post
+ if not payload:
+ return {}
+ return await client.post(f"media/{media_id}", json_data=payload)
+
+
+async def wp_set_featured_media(
+ client: WordPressClient, post_id: int, media_id: int
+) -> dict[str, Any]:
+ """Set a post's featured image."""
+ return await client.post(f"posts/{post_id}", json_data={"featured_media": media_id})
+
+
+async def fetch_url_bytes(
+ url: str,
+ *,
+ max_bytes: int = DEFAULT_MAX_BYTES,
+ timeout_sec: int = 60,
+ user_agent: str = "MCPHub-MediaUploader/1.0",
+ resolved_ip: str | None = None,
+) -> tuple[bytes, str | None, str]:
+ """Download up to `max_bytes` from URL. Streams and enforces the limit.
+
+ Returns (data, content_type, filename_guess).
+ """
+ filename_guess = url.rsplit("/", 1)[-1].split("?")[0] or "download"
+ headers = {"User-Agent": user_agent}
+ timeout = aiohttp.ClientTimeout(total=timeout_sec)
+
+ async with aiohttp.ClientSession(timeout=timeout) as session:
+ async with session.get(url, headers=headers, allow_redirects=True) as resp:
+ if resp.status >= 400:
+ raise UploadError(
+ "URL_FETCH_FAILED",
+ f"Download failed: HTTP {resp.status}",
+ {"status": resp.status, "url": url},
+ )
+ declared_ct = resp.headers.get("Content-Type")
+ # Stream read with byte cap
+ buf = bytearray()
+ async for chunk in resp.content.iter_chunked(64 * 1024):
+ buf.extend(chunk)
+ if len(buf) > max_bytes:
+ raise UploadError(
+ "TOO_LARGE",
+ f"Remote file exceeds limit of {max_bytes} bytes while streaming.",
+ {"max": max_bytes, "url": url},
+ )
+ _ = asyncio # keep import for callers if needed
+ return bytes(buf), declared_ct, filename_guess
diff --git a/plugins/wordpress/handlers/_media_optimize.py b/plugins/wordpress/handlers/_media_optimize.py
new file mode 100644
index 0000000..64fc3f3
--- /dev/null
+++ b/plugins/wordpress/handlers/_media_optimize.py
@@ -0,0 +1,196 @@
+"""F.5a.2 / F.5a.8.1: Image optimization pipeline using Pillow.
+
+Defaults for web publishing: JPEG quality 85, PNG→JPEG threshold via opacity check,
+max long edge 2560 px, EXIF strip, animated GIF preserved untouched.
+
+F.5a.8.1 adds an optional format conversion stage: when ``convert_to`` is
+``"webp"`` or ``"avif"`` (or the env var ``WP_MEDIA_CONVERT_TO`` is set),
+raster inputs are re-encoded in that modern format regardless of source
+type. Transparency is preserved; animated GIFs are still left untouched.
+
+Returns the (possibly reduced) bytes and the (possibly updated) MIME type.
+Non-raster types (pdf, svg, video, audio) pass through unchanged.
+"""
+
+from __future__ import annotations
+
+import io
+import logging
+import os
+
+_logger = logging.getLogger("mcphub.wordpress.media.optimize")
+
+try:
+ from PIL import Image, ImageOps # type: ignore
+except Exception: # pragma: no cover
+ Image = None # type: ignore
+ ImageOps = None # type: ignore
+
+_DEFAULT_MAX_EDGE = int(os.environ.get("WP_MEDIA_MAX_EDGE", "2560"))
+_DEFAULT_JPEG_QUALITY = int(os.environ.get("WP_MEDIA_JPEG_QUALITY", "85"))
+# F.5a.8.1: optional output format override.
+# "" → no conversion (current default, keeps source format)
+# "webp" → convert raster images to image/webp
+# "avif" → convert raster images to image/avif (requires Pillow≥9.2 with AVIF)
+_DEFAULT_CONVERT_TO = os.environ.get("WP_MEDIA_CONVERT_TO", "").strip().lower()
+
+_RASTER_MIMES = {"image/jpeg", "image/png", "image/webp", "image/bmp", "image/tiff"}
+
+# Format map for the convert_to override. Values are (Pillow format, output MIME).
+_CONVERT_TO_FORMATS: dict[str, tuple[str, str]] = {
+ "webp": ("WEBP", "image/webp"),
+ "avif": ("AVIF", "image/avif"),
+}
+
+
+def _avif_supported() -> bool:
+ """Return True if the running Pillow actually decodes/encodes AVIF.
+
+ We check at call time rather than import time so environments that
+ upgrade Pillow without restarting still see support as it becomes
+ available.
+ """
+ if Image is None:
+ return False
+ # Pillow registers the extension lazily; probing for a writer is more
+ # reliable than checking the features module (which doesn't list AVIF
+ # on every version).
+ try:
+ return "AVIF" in Image.registered_extensions().values()
+ except Exception: # pragma: no cover - defensive
+ return False
+
+
+def optimize(
+ data: bytes,
+ mime_hint: str | None,
+ *,
+ max_edge: int = _DEFAULT_MAX_EDGE,
+ jpeg_quality: int = _DEFAULT_JPEG_QUALITY,
+ strip_exif: bool = True,
+ convert_to: str | None = None,
+) -> tuple[bytes, str | None]:
+ """Resize/recompress raster images.
+
+ Args:
+ data: raw image bytes.
+ mime_hint: best-guess MIME from sniff/client; non-raster types are
+ passed through unchanged.
+ max_edge: long-edge pixel limit (default ``WP_MEDIA_MAX_EDGE`` or 2560).
+ jpeg_quality: q parameter for JPEG / WebP / AVIF encoders.
+ strip_exif: when True, apply ``ImageOps.exif_transpose`` to bake in
+ rotation then drop the metadata.
+ convert_to: force output format regardless of source. ``"webp"`` or
+ ``"avif"``; ``None`` or ``""`` falls back to
+ ``WP_MEDIA_CONVERT_TO`` then to source-format heuristics. When
+ AVIF is requested but Pillow lacks AVIF support, falls back to
+ WebP rather than silently writing the source bytes.
+
+ Returns:
+ ``(new_bytes, new_mime_or_original)``. If optimization produced
+ larger output without resizing, the original bytes are returned.
+ """
+ if Image is None:
+ return data, mime_hint
+
+ if mime_hint and mime_hint not in _RASTER_MIMES:
+ return data, mime_hint
+
+ try:
+ img = Image.open(io.BytesIO(data))
+ except Exception:
+ return data, mime_hint
+
+ # Preserve animated GIFs untouched (single-frame re-encode would break them)
+ if getattr(img, "is_animated", False):
+ return data, mime_hint
+
+ fmt = (img.format or "").upper() # capture BEFORE exif_transpose strips .format
+
+ try:
+ img = ImageOps.exif_transpose(img) if strip_exif else img
+ except Exception:
+ pass
+
+ w, h = img.size
+ long_edge = max(w, h)
+
+ resized = False
+ if long_edge > max_edge:
+ scale = max_edge / long_edge
+ new_size = (max(1, int(w * scale)), max(1, int(h * scale)))
+ img = img.resize(new_size, Image.LANCZOS)
+ resized = True
+
+ # F.5a.8.1: explicit convert_to wins over the env default.
+ requested_convert = (convert_to or _DEFAULT_CONVERT_TO or "").strip().lower()
+ if requested_convert == "avif" and not _avif_supported():
+ _logger.info("AVIF requested but Pillow lacks AVIF support; falling back to WebP.")
+ requested_convert = "webp"
+
+ has_alpha = img.mode in ("RGBA", "LA") or (img.mode == "P" and "transparency" in img.info)
+
+ if requested_convert in _CONVERT_TO_FORMATS:
+ out_fmt, out_mime = _CONVERT_TO_FORMATS[requested_convert]
+ # Both WebP and AVIF support alpha; convert palette images to RGBA/RGB
+ # so the encoder has a consistent colour model.
+ if img.mode == "P":
+ img = img.convert("RGBA" if has_alpha else "RGB")
+ elif img.mode == "LA":
+ img = img.convert("RGBA")
+ elif img.mode == "L":
+ img = img.convert("RGB")
+ elif fmt == "PNG" and not has_alpha:
+ # Opaque PNG → JPEG is typically smaller
+ out_fmt = "JPEG"
+ out_mime = "image/jpeg"
+ if img.mode != "RGB":
+ img = img.convert("RGB")
+ elif fmt == "JPEG":
+ out_fmt = "JPEG"
+ out_mime = "image/jpeg"
+ if img.mode != "RGB":
+ img = img.convert("RGB")
+ elif fmt == "WEBP":
+ out_fmt = "WEBP"
+ out_mime = "image/webp"
+ elif fmt == "PNG":
+ out_fmt = "PNG"
+ out_mime = "image/png"
+ else:
+ # BMP/TIFF etc — convert to JPEG for web delivery
+ out_fmt = "JPEG"
+ out_mime = "image/jpeg"
+ if img.mode != "RGB":
+ img = img.convert("RGB")
+
+ buf = io.BytesIO()
+ save_kwargs: dict = {"optimize": True}
+ if out_fmt == "JPEG":
+ save_kwargs.update({"quality": jpeg_quality, "progressive": True})
+ elif out_fmt == "WEBP":
+ save_kwargs.update({"quality": jpeg_quality, "method": 6})
+ elif out_fmt == "AVIF":
+ save_kwargs.update({"quality": jpeg_quality})
+ img.save(buf, format=out_fmt, **save_kwargs)
+ new_bytes = buf.getvalue()
+
+ # Explicit format conversion is honoured even if the converted bytes
+ # are larger than the source (the caller asked for a format switch on
+ # purpose — e.g. to serve WebP regardless). The size guard only applies
+ # to the implicit recompression path.
+ if not requested_convert and not resized and len(new_bytes) >= len(data):
+ return data, mime_hint
+
+ _logger.debug(
+ "optimized %s %dx%d %dB -> %s %dB (resized=%s, convert=%s)",
+ fmt,
+ w,
+ h,
+ len(data),
+ out_fmt,
+ len(new_bytes),
+ resized,
+ requested_convert or "-",
+ )
+ return new_bytes, out_mime
diff --git a/plugins/wordpress/handlers/_media_security.py b/plugins/wordpress/handlers/_media_security.py
new file mode 100644
index 0000000..22e5e5a
--- /dev/null
+++ b/plugins/wordpress/handlers/_media_security.py
@@ -0,0 +1,240 @@
+"""Media upload security primitives: MIME sniff, size validation, SSRF guard, filename safety."""
+
+from __future__ import annotations
+
+import ipaddress
+import mimetypes
+import os
+import re
+import socket
+from collections.abc import Iterable
+from dataclasses import dataclass
+from urllib.parse import urlparse
+
+try:
+ import magic as _magic # type: ignore
+except Exception:
+ _magic = None
+
+
+class UploadError(Exception):
+ """Structured upload error with stable code for JSON responses."""
+
+ def __init__(self, code: str, message: str, details: dict | None = None):
+ super().__init__(message)
+ self.code = code
+ self.message = message
+ self.details = details or {}
+
+ def to_dict(self) -> dict:
+ return {"error_code": self.code, "message": self.message, "details": self.details}
+
+
+DEFAULT_MAX_BYTES = int(os.environ.get("WP_MEDIA_MAX_MB", "10")) * 1024 * 1024
+
+ALLOWED_MIMES: set[str] = {
+ "image/jpeg",
+ "image/png",
+ "image/webp",
+ "image/gif",
+ "image/avif",
+ "image/heic",
+ "image/bmp",
+ "image/tiff",
+ "application/pdf",
+ "video/mp4",
+ "video/webm",
+ "video/quicktime",
+ "audio/mpeg",
+ "audio/mp4",
+ "audio/webm",
+ "audio/ogg",
+ "audio/wav",
+}
+
+_EXT_MAP = {
+ "image/jpeg": ".jpg",
+ "image/png": ".png",
+ "image/webp": ".webp",
+ "image/gif": ".gif",
+ "image/avif": ".avif",
+ "image/heic": ".heic",
+ "application/pdf": ".pdf",
+ "video/mp4": ".mp4",
+ "video/webm": ".webm",
+ "video/quicktime": ".mov",
+ "audio/mpeg": ".mp3",
+ "audio/mp4": ".m4a",
+ "audio/ogg": ".ogg",
+ "audio/wav": ".wav",
+}
+
+
+def _builtin_sniff(data: bytes) -> str | None:
+ """Minimal magic-byte sniffer for common media types (libmagic-free fallback)."""
+ if not data:
+ return None
+ if data.startswith(b"\x89PNG\r\n\x1a\n"):
+ return "image/png"
+ if data.startswith(b"\xff\xd8\xff"):
+ return "image/jpeg"
+ if data[:4] == b"GIF8":
+ return "image/gif"
+ if data.startswith(b"%PDF-"):
+ return "application/pdf"
+ if data[:4] == b"RIFF" and data[8:12] == b"WEBP":
+ return "image/webp"
+ if data[:4] == b"RIFF" and data[8:12] == b"WAVE":
+ return "audio/wav"
+ if data[4:12] in (b"ftypmp42", b"ftypisom", b"ftypMSNV", b"ftypavc1"):
+ return "video/mp4"
+ if data[4:8] == b"ftyp":
+ # Generic ISO BMFF — likely mp4/heic
+ brand = data[8:12]
+ if brand in (b"heic", b"heix", b"mif1"):
+ return "image/heic"
+ return "video/mp4"
+ if data.startswith(b"\x1aE\xdf\xa3"):
+ return "video/webm"
+ if data.startswith(b"ID3") or data[:2] == b"\xff\xfb":
+ return "audio/mpeg"
+ if data.startswith(b"OggS"):
+ return "audio/ogg"
+ return None
+
+
+def sniff_mime(data: bytes, *, hint: str | None = None) -> str:
+ """Detect MIME from magic bytes. Uses libmagic if available, else built-in sniff."""
+ if _magic is not None:
+ try:
+ detected = _magic.from_buffer(data[:4096], mime=True)
+ if detected and detected != "application/octet-stream":
+ return detected
+ except Exception:
+ pass
+ built_in = _builtin_sniff(data)
+ if built_in:
+ return built_in
+ if hint and "/" in hint:
+ return hint.lower()
+ return "application/octet-stream"
+
+
+def validate_size(data: bytes, *, max_bytes: int = DEFAULT_MAX_BYTES) -> None:
+ if len(data) == 0:
+ raise UploadError("EMPTY_FILE", "Upload data is empty.")
+ if len(data) > max_bytes:
+ raise UploadError(
+ "TOO_LARGE",
+ f"File is {len(data)} bytes; limit is {max_bytes} bytes "
+ f"(~{max_bytes // 1024 // 1024} MB). Use chunked upload for larger files.",
+ {"size": len(data), "max": max_bytes},
+ )
+
+
+def validate_mime(mime: str, *, allowed: Iterable[str] = ALLOWED_MIMES) -> None:
+ if mime not in allowed:
+ raise UploadError(
+ "MIME_REJECTED",
+ f"MIME type '{mime}' is not allowed. " f"Supported: {', '.join(sorted(allowed))}.",
+ {"mime": mime},
+ )
+
+
+_FILENAME_SAFE_RE = re.compile(r"[^A-Za-z0-9._-]+")
+
+
+def safe_filename(filename: str | None, *, mime: str) -> tuple[str, str | None]:
+ """Return (ascii_filename, rfc5987_encoded_original_if_non_ascii)."""
+ original = (filename or "").strip() or "upload"
+ # Strip any path components
+ original = original.rsplit("/", 1)[-1].rsplit("\\", 1)[-1]
+
+ try:
+ original.encode("ascii")
+ ascii_name = _FILENAME_SAFE_RE.sub("_", original)
+ encoded = None
+ except UnicodeEncodeError:
+ from urllib.parse import quote
+
+ ascii_name = _FILENAME_SAFE_RE.sub(
+ "_", original.encode("ascii", "ignore").decode() or "upload"
+ )
+ encoded = "UTF-8''" + quote(original, safe="")
+
+ # Ensure extension matches MIME
+ ext = _EXT_MAP.get(mime) or (mimetypes.guess_extension(mime) or "")
+ if ext and not ascii_name.lower().endswith(ext):
+ base = ascii_name.rsplit(".", 1)[0] if "." in ascii_name else ascii_name
+ ascii_name = f"{base}{ext}"
+
+ return ascii_name[:255], encoded
+
+
+def content_disposition(filename_ascii: str, filename_encoded: str | None) -> str:
+ """Build Content-Disposition header, optionally with RFC 5987 filename* for non-ASCII."""
+ base = f'attachment; filename="{filename_ascii}"'
+ if filename_encoded:
+ return f"{base}; filename*={filename_encoded}"
+ return base
+
+
+# --- SSRF guard ------------------------------------------------------------
+
+_BLOCKED_HOSTS = {
+ "metadata.google.internal",
+ "metadata",
+ "169.254.169.254",
+}
+
+
+@dataclass
+class SSRFCheck:
+ allowed: bool
+ reason: str | None = None
+ resolved_ip: str | None = None
+
+
+def ssrf_check(url: str, *, allow_http: bool = False) -> SSRFCheck:
+ """Reject URLs pointing to private/loopback/link-local/metadata endpoints."""
+ try:
+ parsed = urlparse(url)
+ except Exception as e:
+ return SSRFCheck(False, f"URL parse failed: {e}")
+
+ if parsed.scheme not in ("http", "https"):
+ return SSRFCheck(False, f"Scheme '{parsed.scheme}' not allowed; use https (or http).")
+ if parsed.scheme == "http" and not allow_http:
+ return SSRFCheck(False, "HTTP URLs are disabled; use HTTPS.")
+
+ host = (parsed.hostname or "").lower()
+ if not host:
+ return SSRFCheck(False, "URL has no host.")
+ if host in _BLOCKED_HOSTS:
+ return SSRFCheck(False, f"Host '{host}' is on the SSRF blocklist.")
+
+ # Resolve and check each IP
+ try:
+ infos = socket.getaddrinfo(host, None)
+ except socket.gaierror as e:
+ return SSRFCheck(False, f"DNS resolution failed: {e}")
+
+ seen: list[str] = []
+ for info in infos:
+ ip_str = info[4][0]
+ seen.append(ip_str)
+ try:
+ ip = ipaddress.ip_address(ip_str)
+ except ValueError:
+ return SSRFCheck(False, f"Unparseable IP '{ip_str}'.")
+ if (
+ ip.is_private
+ or ip.is_loopback
+ or ip.is_link_local
+ or ip.is_reserved
+ or ip.is_multicast
+ or ip.is_unspecified
+ ):
+ return SSRFCheck(False, f"Host resolves to disallowed IP {ip}.", ip_str)
+
+ return SSRFCheck(True, None, seen[0] if seen else None)
diff --git a/plugins/wordpress/handlers/ai_media.py b/plugins/wordpress/handlers/ai_media.py
new file mode 100644
index 0000000..0b29aac
--- /dev/null
+++ b/plugins/wordpress/handlers/ai_media.py
@@ -0,0 +1,462 @@
+"""F.5a.4: AI image generation + upload chain.
+
+Provides ``wordpress_generate_and_upload_image`` which chains:
+
+1. Resolve the caller's per-user provider API key (or env fallback).
+2. Call the chosen provider (OpenAI / Stability / Replicate) and get raw bytes.
+3. Reuse the F.5a.1 raw-upload path (optimize → POST /wp/v2/media).
+4. Optionally apply metadata (alt/caption/title), attach to a post, or set as featured.
+5. Emit an audit-log entry with cost/provider/usage.
+
+The tool is registered on the WordPress plugin because the end result is
+a WordPress media item — the ``ai_image`` package is a pure provider
+library, not an MCP plugin with its own endpoints.
+"""
+
+from __future__ import annotations
+
+import hashlib
+import json
+import logging
+import os
+import time
+from typing import Any
+
+from plugins.ai_image.providers.base import GenerationRequest, ProviderError
+from plugins.ai_image.registry import get_provider, list_providers
+from plugins.wordpress.client import WordPressClient
+from plugins.wordpress.handlers._media_core import wp_raw_upload
+from plugins.wordpress.handlers._media_security import UploadError
+from plugins.wordpress.handlers.media import (
+ _apply_metadata_and_attach,
+ _format_upload_result,
+ _maybe_optimize,
+)
+
+_logger = logging.getLogger("mcphub.wordpress.ai_media")
+
+
+def _content_sha(data: bytes) -> str:
+ """SHA-256 of the raw generated bytes, truncated for brevity.
+
+ Two independent provider calls for the same prompt produce
+ different bytes (diffusion models are non-deterministic), so this
+ hash is effectively a per-call fingerprint. Including it in the
+ idempotency key means a retry with the *same already-generated*
+ bytes dedupes, while a fresh call with a new image gets a new id.
+ """
+ return hashlib.sha256(data).hexdigest()[:32]
+
+
+def _idempotency_key_for(
+ *,
+ provider: str,
+ model: str | None,
+ prompt: str,
+ size: str,
+ attach_to_post: int | None,
+ set_featured: bool,
+ site_url: str,
+ user_id: str | None,
+ content_sha: str,
+) -> str:
+ """Build the ``Idempotency-Key`` header value for an AI upload.
+
+ Matches the ``^[A-Za-z0-9_\\-]{1,128}$`` regex the companion
+ validates (see airano-mcp-bridge.php handle_upload_and_attach).
+ """
+ raw = "|".join(
+ [
+ provider or "",
+ model or "",
+ prompt or "",
+ size or "",
+ str(attach_to_post or 0),
+ "1" if set_featured else "0",
+ site_url or "",
+ user_id or "",
+ content_sha or "",
+ ]
+ )
+ digest = hashlib.sha256(raw.encode("utf-8")).hexdigest()
+ return f"mcphub_ai_{digest[:48]}"
+
+
+def get_tool_specifications() -> list[dict[str, Any]]:
+ return [
+ {
+ "name": "generate_and_upload_image",
+ "method_name": "generate_and_upload_image",
+ "description": (
+ "Generate an image with an AI provider (OpenAI DALL-E, "
+ "Stability, or Replicate Flux) and upload it to the WordPress "
+ "media library in one call. Optionally attach to a post or set "
+ "as featured image. Uses the caller's stored provider API key; "
+ "falls back to server env vars if no per-user key is set."
+ ),
+ "schema": {
+ "type": "object",
+ "properties": {
+ "provider": {
+ "type": "string",
+ "enum": list_providers(),
+ "description": "AI provider to use.",
+ },
+ "prompt": {
+ "type": "string",
+ "description": "Text prompt describing the image to generate.",
+ },
+ "size": {
+ "type": "string",
+ "default": "1024x1024",
+ "description": "Requested image size (WxH). Providers map to supported sizes.",
+ },
+ "quality": {
+ "type": "string",
+ "default": "standard",
+ "description": "Provider-specific quality hint (e.g. 'standard'/'hd').",
+ },
+ "model": {
+ "anyOf": [{"type": "string"}, {"type": "null"}],
+ "description": "Optional model override (e.g. 'dall-e-3', 'flux-dev').",
+ },
+ "negative_prompt": {
+ "anyOf": [{"type": "string"}, {"type": "null"}],
+ },
+ "filename": {
+ "anyOf": [{"type": "string"}, {"type": "null"}],
+ "description": "Optional filename hint for the WP library.",
+ },
+ "title": {"anyOf": [{"type": "string"}, {"type": "null"}]},
+ "alt_text": {"anyOf": [{"type": "string"}, {"type": "null"}]},
+ "caption": {"anyOf": [{"type": "string"}, {"type": "null"}]},
+ "attach_to_post": {
+ "anyOf": [{"type": "integer", "minimum": 1}, {"type": "null"}],
+ },
+ "set_featured": {"type": "boolean", "default": False},
+ "skip_optimize": {"type": "boolean", "default": False},
+ "convert_to": {
+ "anyOf": [
+ {"type": "string", "enum": ["webp", "avif"]},
+ {"type": "null"},
+ ],
+ "description": (
+ "F.5a.8.1: re-encode the generated image as WebP or "
+ "AVIF before upload (falls back to WebP if AVIF is "
+ "unavailable)."
+ ),
+ },
+ },
+ "required": ["provider", "prompt"],
+ },
+ "scope": "write",
+ }
+ ]
+
+
+class AIMediaHandler:
+ """Handles AI-powered image generation + WP upload chain."""
+
+ def __init__(
+ self,
+ client: WordPressClient,
+ user_id: str | None = None,
+ site_id: str | None = None,
+ wc_client: WordPressClient | None = None,
+ ):
+ self.client = client
+ # ``user_id`` is injected by the user-endpoint plumbing (F.5a.4).
+ # Admin / system endpoints leave it None.
+ self.user_id = user_id
+ # ``site_id`` is injected by the user-endpoint plumbing (F.5a.9.x)
+ # and is the primary input for resolving per-site provider API
+ # keys. Admin / env-driven endpoints leave it None, in which case
+ # the resolver falls back to the legacy env-var path.
+ self.site_id = site_id
+ # F.X.fix-pass6 — optional separate client for /wc/v3/* (the
+ # WC consumer-key/secret pair on a WC site). Used by
+ # _apply_metadata_and_attach to detect "attach_to_post is a
+ # WC product" and route the featured-image set through
+ # /wc/v3/products/{id}/images instead of /wp/v2/posts/{id}
+ # which 404s for the product CPT. None = same client serves
+ # both REST roots (WP plugin or legacy single-credential WC).
+ self.wc_client = wc_client
+
+ async def generate_and_upload_image(
+ self,
+ provider: str,
+ prompt: str,
+ size: str = "1024x1024",
+ quality: str = "standard",
+ model: str | None = None,
+ negative_prompt: str | None = None,
+ filename: str | None = None,
+ title: str | None = None,
+ alt_text: str | None = None,
+ caption: str | None = None,
+ attach_to_post: int | None = None,
+ set_featured: bool = False,
+ skip_optimize: bool = False,
+ convert_to: str | None = None,
+ ) -> str:
+ started = time.time()
+ try:
+ from core.tool_rate_limiter import ToolRateLimitError, get_tool_rate_limiter
+
+ try:
+ get_tool_rate_limiter().check("wordpress_generate_and_upload_image", self.user_id)
+ except ToolRateLimitError as e:
+ return json.dumps(e.to_dict(), indent=2)
+
+ provider_impl = get_provider(provider)
+ api_key = await self._resolve_api_key(provider)
+ # F.X.fix-pass3 — when caller omitted ``model``, fall back
+ # to the per-site default the operator pinned in the
+ # dashboard. Keeps MCP call sites short and lets the user
+ # rotate models without code changes.
+ if model is None and self.site_id:
+ try:
+ from core.database import get_database
+
+ db = get_database()
+ model = await db.get_site_provider_default_model(self.site_id, provider)
+ except Exception as exc: # noqa: BLE001
+ _logger.debug(
+ "default_model lookup skipped site=%s provider=%s: %s",
+ self.site_id,
+ provider,
+ exc,
+ )
+ if not api_key:
+ if self.site_id:
+ msg = (
+ f"No API key configured for provider '{provider}' on "
+ f"this site. Open the site in the dashboard and add the "
+ f"key under 'AI Image Generation' in Connection Settings."
+ )
+ dashboard_url = f"/dashboard/sites/{self.site_id}"
+ else:
+ msg = (
+ f"No API key configured for provider '{provider}'. "
+ f"Set the {provider.upper()}_API_KEY env var on the server."
+ )
+ dashboard_url = None
+ payload = {
+ "error_code": "NO_PROVIDER_KEY",
+ "message": msg,
+ "provider": provider,
+ }
+ if dashboard_url:
+ payload["dashboard_url"] = dashboard_url
+ return json.dumps(payload, indent=2)
+
+ request = GenerationRequest(
+ prompt=prompt,
+ size=size,
+ quality=quality,
+ model=model,
+ negative_prompt=negative_prompt,
+ )
+ result = await provider_impl.generate(api_key, request)
+
+ data, mime_hint = _maybe_optimize(
+ result.data, result.mime, skip=skip_optimize, convert_to=convert_to
+ )
+ # F.X.fix #7: stable idempotency key per logical call. A
+ # client-side retry (after timeout) produces the same
+ # digest, so the companion dedupes instead of creating an
+ # "-2.webp" orphan. Key covers every input that would
+ # change the intended outcome.
+ idempotency_key = _idempotency_key_for(
+ provider=provider,
+ model=result.meta.get("model") or model,
+ prompt=prompt,
+ size=size,
+ attach_to_post=attach_to_post,
+ set_featured=set_featured,
+ site_url=self.client.site_url,
+ user_id=self.user_id,
+ content_sha=_content_sha(data),
+ )
+ media = await wp_raw_upload(
+ self.client,
+ data,
+ filename=filename or result.filename,
+ mime_hint=mime_hint or result.mime,
+ # F.5a.8.5: single-call upload+metadata+attach+featured
+ # when the companion's upload-and-attach route is
+ # advertised; _apply_metadata_and_attach becomes a no-op.
+ attach_to_post=attach_to_post,
+ set_featured=set_featured,
+ title=title,
+ alt_text=alt_text,
+ caption=caption,
+ idempotency_key=idempotency_key,
+ )
+ attach_status = await _apply_metadata_and_attach(
+ self.client,
+ media,
+ title=title,
+ alt_text=alt_text,
+ caption=caption,
+ attach_to_post=attach_to_post,
+ set_featured=set_featured,
+ wc_client=self.wc_client,
+ )
+
+ duration_ms = int((time.time() - started) * 1000)
+ self._audit(
+ provider=provider,
+ model=result.meta.get("model") or model,
+ size=size,
+ duration_ms=duration_ms,
+ bytes_=len(result.data),
+ cost_usd=result.cost_usd,
+ media_id=media.get("id"),
+ error=None,
+ )
+ from core.media_audit import log_media_upload
+
+ log_media_upload(
+ site=self.client.site_url,
+ user_id=self.user_id,
+ mime=media.get("mime_type") or result.mime,
+ size_bytes=len(data),
+ source=f"ai:{provider}",
+ media_id=media.get("id"),
+ cost_usd=result.cost_usd,
+ )
+
+ # F.X.fix-pass6 — surface partial-success: media uploaded
+ # but featured-set / metadata-apply may have warnings. Old
+ # behaviour bubbled those exceptions up as
+ # GENERATION_FAILED, hiding the freshly-created media id
+ # and leaving an orphan in the library.
+ payload = _format_upload_result(media, source=f"ai:{provider}")
+ payload["provider"] = provider
+ payload["provider_meta"] = result.meta
+ payload["cost_usd"] = result.cost_usd
+ payload["duration_ms"] = duration_ms
+ payload["attach"] = {
+ "metadata_applied": attach_status.get("metadata_applied", False),
+ "featured_set": attach_status.get("featured_set", False),
+ "featured_context": attach_status.get("featured_context"),
+ }
+ warnings = attach_status.get("warnings") or []
+ if warnings:
+ payload["warnings"] = warnings
+ return json.dumps(payload, indent=2)
+
+ except ProviderError as e:
+ self._audit(
+ provider=provider,
+ model=model,
+ size=size,
+ duration_ms=int((time.time() - started) * 1000),
+ bytes_=0,
+ cost_usd=None,
+ media_id=None,
+ error=e.code,
+ )
+ return json.dumps(e.to_dict(), indent=2)
+ except UploadError as e:
+ self._audit(
+ provider=provider,
+ model=model,
+ size=size,
+ duration_ms=int((time.time() - started) * 1000),
+ bytes_=0,
+ cost_usd=None,
+ media_id=None,
+ error=e.code,
+ )
+ return json.dumps(e.to_dict(), indent=2)
+ except Exception as e:
+ _logger.exception("generate_and_upload_image failed")
+ return json.dumps(
+ {
+ "error_code": "GENERATION_FAILED",
+ "message": f"generate_and_upload_image failed: {e}",
+ },
+ indent=2,
+ )
+
+ async def _resolve_api_key(self, provider: str) -> str | None:
+ """Resolve the provider key for this call.
+
+ F.5a.9.x policy (single-source, per-site):
+
+ 1. ``site_id`` set (per-user endpoint, the only UI-reachable path):
+ read the key from the per-site ``site_provider_keys`` row.
+ Env fallback is deliberately NOT applied here so a single
+ ``OPENAI_API_KEY=...`` on the server cannot silently paper
+ over a missing per-site configuration — callers instead get
+ ``NO_PROVIDER_KEY`` pointing at the site's dashboard page.
+
+ 2. ``site_id is None`` (admin / master-key endpoint, no site
+ context): fall back to the ``_API_KEY`` env var.
+ This preserves the legacy self-hosted / admin workflow.
+
+ The per-user provider-keys store from F.18.8 has been removed
+ in favour of the per-site model; there is no hybrid fallback.
+ """
+ # 1. Per-site key (the primary path)
+ if self.site_id:
+ try:
+ from core.site_api import get_site_provider_key
+
+ return await get_site_provider_key(self.site_id, provider)
+ except Exception as exc:
+ _logger.error(
+ "Site provider-key lookup failed site=%s provider=%s: %s",
+ self.site_id,
+ provider,
+ exc,
+ )
+ return None
+
+ # 2. Admin / env-var fallback (no site context)
+ env_var = f"{provider.upper()}_API_KEY"
+ return os.environ.get(env_var)
+
+ def _audit(
+ self,
+ *,
+ provider: str,
+ model: str | None,
+ size: str,
+ duration_ms: int,
+ bytes_: int,
+ cost_usd: float | None,
+ media_id: int | None,
+ error: str | None,
+ ) -> None:
+ try:
+ from core.audit_log import get_audit_logger
+
+ logger_ = get_audit_logger()
+ except Exception:
+ return
+ try:
+ logger_.log_tool_call(
+ tool_name="wordpress_generate_and_upload_image",
+ params={
+ "provider": provider,
+ "model": model,
+ "size": size,
+ "bytes": bytes_,
+ "cost_usd": cost_usd,
+ "media_id": media_id,
+ },
+ duration_ms=duration_ms,
+ user_id=self.user_id,
+ error=error,
+ result_summary=(
+ f"{provider}/{model or '-'} {bytes_}B "
+ f"${cost_usd or 0:.4f} -> media_id={media_id}"
+ if not error
+ else f"failed: {error}"
+ ),
+ )
+ except Exception:
+ _logger.debug("audit log emit failed", exc_info=True)
diff --git a/plugins/wordpress/handlers/audit_hook.py b/plugins/wordpress/handlers/audit_hook.py
new file mode 100644
index 0000000..bae2b60
--- /dev/null
+++ b/plugins/wordpress/handlers/audit_hook.py
@@ -0,0 +1,275 @@
+"""F.18.7 — Manage the companion plugin's audit-hook webhook config.
+
+Wraps ``GET|POST|DELETE /airano-mcp/v1/audit-hook`` (companion plugin
+v2.7.0+). Three tools:
+
+- ``wordpress_audit_hook_status`` (read): returns current config + stats.
+- ``wordpress_audit_hook_configure`` (admin): upserts endpoint_url / secret /
+ enabled / events.
+- ``wordpress_audit_hook_disable`` (admin): clears config and stops pushing.
+
+The actual event receiver lives in ``core/companion_audit.py``; this
+module only manages the per-site configuration on the WordPress side.
+"""
+
+from __future__ import annotations
+
+import json
+import logging
+from typing import Any
+
+from plugins.wordpress.client import WordPressClient
+from plugins.wordpress.handlers._companion_hint import (
+ companion_install_hint as _companion_install_hint,
+)
+
+logger = logging.getLogger("mcphub.wordpress.audit_hook")
+
+SUPPORTED_EVENTS = (
+ "transition_post_status",
+ "deleted_post",
+ "user_register",
+ "profile_update",
+ "deleted_user",
+ "activated_plugin",
+ "deactivated_plugin",
+ "switch_theme",
+)
+
+
+def get_tool_specifications() -> list[dict[str, Any]]:
+ return [
+ {
+ "name": "audit_hook_status",
+ "method_name": "audit_hook_status",
+ "description": (
+ "Read the companion plugin's audit-hook configuration "
+ "(v2.7.0+): current endpoint_url, whether a secret is set, "
+ "enabled flag, event list, last-push timestamp, failure "
+ "count. Secret is never returned in full — only the last 4 "
+ "characters. Requires manage_options."
+ ),
+ "schema": {"type": "object", "properties": {}},
+ "scope": "read",
+ },
+ {
+ "name": "audit_hook_configure",
+ "method_name": "audit_hook_configure",
+ "description": (
+ "Configure the companion plugin's audit-hook webhook. Sets "
+ "the MCPHub endpoint_url, shared HMAC secret (≥16 chars), "
+ "enabled flag, and list of hooked events. Returns the "
+ "resulting status. Requires manage_options."
+ ),
+ "schema": {
+ "type": "object",
+ "properties": {
+ "endpoint_url": {
+ "type": "string",
+ "description": (
+ "Full URL to MCPHub's /api/companion-audit "
+ "route (e.g. https://mcp.example.com/api/companion-audit)."
+ ),
+ },
+ "secret": {
+ "type": "string",
+ "description": (
+ "HMAC-SHA256 shared secret (≥16 chars). Store "
+ "the identical value in MCPHub's "
+ "CompanionAuditSecretStore for this site."
+ ),
+ },
+ "enabled": {"type": "boolean"},
+ "events": {
+ "type": "array",
+ "items": {"type": "string", "enum": list(SUPPORTED_EVENTS)},
+ "description": (
+ "List of WP action hooks to forward. Default: " "all supported events."
+ ),
+ },
+ },
+ },
+ "scope": "admin",
+ },
+ {
+ "name": "audit_hook_disable",
+ "method_name": "audit_hook_disable",
+ "description": (
+ "Clear the companion plugin's audit-hook configuration "
+ "(endpoint_url, secret, events) and stop forwarding events. "
+ "Requires manage_options."
+ ),
+ "schema": {"type": "object", "properties": {}},
+ "scope": "admin",
+ },
+ ]
+
+
+def _validate_configure(
+ *,
+ endpoint_url: str | None,
+ secret: str | None,
+ enabled: Any,
+ events: Any,
+) -> dict[str, Any] | None:
+ # endpoint_url: allow empty-string to mean "clear" on the PHP side,
+ # but reject obviously-broken inputs client-side.
+ if (
+ endpoint_url is not None
+ and endpoint_url
+ and not endpoint_url.startswith(("http://", "https://"))
+ ):
+ return {
+ "error": "invalid_endpoint_url",
+ "message": "endpoint_url must start with http:// or https://",
+ }
+ if secret is not None and secret != "" and len(secret) < 16:
+ return {
+ "error": "secret_too_short",
+ "message": "shared secret must be at least 16 characters",
+ }
+ if events is not None:
+ if not isinstance(events, list):
+ return {
+ "error": "invalid_events",
+ "message": "events must be a list of known event names",
+ }
+ for e in events:
+ if not isinstance(e, str) or e not in SUPPORTED_EVENTS:
+ return {
+ "error": "unknown_event",
+ "message": f"event {e!r} is not supported",
+ "supported": list(SUPPORTED_EVENTS),
+ }
+ if enabled is not None and not isinstance(enabled, bool):
+ return {
+ "error": "invalid_enabled",
+ "message": "enabled must be true or false",
+ }
+ return None
+
+
+def _unreachable(exc: Exception) -> dict[str, Any]:
+ return {
+ "ok": False,
+ "error": "companion_unreachable",
+ "message": str(exc),
+ "hint": (
+ "Requires airano-mcp-bridge companion plugin v2.7.0+ and "
+ "manage_options capability. Run wordpress_probe_capabilities "
+ "to verify availability."
+ ),
+ "install_hint": _companion_install_hint(
+ min_version="2.7.0",
+ required_capability="manage_options",
+ route="airano-mcp/v1/audit-hook",
+ ),
+ }
+
+
+class AuditHookHandler:
+ """Configure + query the companion plugin's audit-hook webhook."""
+
+ def __init__(self, client: WordPressClient) -> None:
+ self.client = client
+
+ async def audit_hook_status(self) -> str:
+ try:
+ payload = await self.client.get(
+ "airano-mcp/v1/audit-hook",
+ use_custom_namespace=True,
+ )
+ except Exception as exc: # noqa: BLE001
+ logger.error("audit_hook_status companion call failed: %s", exc)
+ return json.dumps(_unreachable(exc), indent=2)
+
+ if not isinstance(payload, dict):
+ return json.dumps(
+ {
+ "ok": False,
+ "error": "invalid_response",
+ "message": "companion returned a non-object payload",
+ },
+ indent=2,
+ )
+ return json.dumps({"ok": True, **payload}, indent=2)
+
+ async def audit_hook_configure(
+ self,
+ endpoint_url: str | None = None,
+ secret: str | None = None,
+ enabled: Any = None,
+ events: Any = None,
+ ) -> str:
+ err = _validate_configure(
+ endpoint_url=endpoint_url,
+ secret=secret,
+ enabled=enabled,
+ events=events,
+ )
+ if err is not None:
+ return json.dumps({"ok": False, **err}, indent=2)
+
+ body: dict[str, Any] = {}
+ if endpoint_url is not None:
+ body["endpoint_url"] = endpoint_url
+ if secret is not None:
+ body["secret"] = secret
+ if enabled is not None:
+ body["enabled"] = bool(enabled)
+ if events is not None:
+ body["events"] = list(events)
+
+ if not body:
+ return json.dumps(
+ {
+ "ok": False,
+ "error": "no_fields",
+ "message": (
+ "Provide at least one of: endpoint_url, secret, " "enabled, events."
+ ),
+ },
+ indent=2,
+ )
+
+ try:
+ payload = await self.client.post(
+ "airano-mcp/v1/audit-hook",
+ json_data=body,
+ use_custom_namespace=True,
+ )
+ except Exception as exc: # noqa: BLE001
+ logger.error("audit_hook_configure companion call failed: %s", exc)
+ return json.dumps(_unreachable(exc), indent=2)
+
+ if not isinstance(payload, dict):
+ return json.dumps(
+ {
+ "ok": False,
+ "error": "invalid_response",
+ "message": "companion returned a non-object payload",
+ },
+ indent=2,
+ )
+ return json.dumps({"ok": True, **payload}, indent=2)
+
+ async def audit_hook_disable(self) -> str:
+ try:
+ payload = await self.client.delete(
+ "airano-mcp/v1/audit-hook",
+ use_custom_namespace=True,
+ )
+ except Exception as exc: # noqa: BLE001
+ logger.error("audit_hook_disable companion call failed: %s", exc)
+ return json.dumps(_unreachable(exc), indent=2)
+
+ if not isinstance(payload, dict):
+ return json.dumps(
+ {
+ "ok": False,
+ "error": "invalid_response",
+ "message": "companion returned a non-object payload",
+ },
+ indent=2,
+ )
+ return json.dumps({"ok": True, **payload}, indent=2)
diff --git a/plugins/wordpress/handlers/bulk_meta.py b/plugins/wordpress/handlers/bulk_meta.py
new file mode 100644
index 0000000..90038a8
--- /dev/null
+++ b/plugins/wordpress/handlers/bulk_meta.py
@@ -0,0 +1,179 @@
+"""F.18.2 — Batch post/product meta writes via companion plugin.
+
+Wraps ``POST /airano-mcp/v1/bulk-meta`` (companion plugin v2.2.0+). One
+HTTP round-trip updates meta for up to 500 posts/products in a single
+REST call; without the companion each post would need its own request.
+
+Tool: ``wordpress_bulk_update_meta(updates=[...])``
+"""
+
+from __future__ import annotations
+
+import json
+import logging
+from typing import Any
+
+from plugins.wordpress.client import WordPressClient
+from plugins.wordpress.handlers._companion_hint import (
+ companion_install_hint as _companion_install_hint,
+)
+
+logger = logging.getLogger("mcphub.wordpress.bulk_meta")
+
+# Matches BULK_META_MAX_ITEMS in airano-mcp-bridge.php so we reject client-side
+# before burning a round-trip on a 413.
+MAX_BULK_ITEMS = 500
+
+
+def get_tool_specifications() -> list[dict[str, Any]]:
+ return [
+ {
+ "name": "bulk_update_meta",
+ "method_name": "bulk_update_meta",
+ "description": (
+ "Batch-update post_meta (posts, pages, WooCommerce products) in a "
+ "single REST round-trip via the airano-mcp-bridge companion "
+ "plugin (v2.2.0+). Each item is permission-checked in PHP via "
+ "current_user_can('edit_post', post_id). Pass a null meta value "
+ "to delete that key. Maximum 500 items per call."
+ ),
+ "schema": {
+ "type": "object",
+ "properties": {
+ "updates": {
+ "type": "array",
+ "description": (
+ "List of {post_id, meta} objects. `meta` is a dict of "
+ "meta_key => value; null values delete the key."
+ ),
+ "items": {
+ "type": "object",
+ "properties": {
+ "post_id": {"type": "integer"},
+ "meta": {"type": "object"},
+ },
+ "required": ["post_id", "meta"],
+ },
+ }
+ },
+ "required": ["updates"],
+ },
+ "scope": "write",
+ }
+ ]
+
+
+def _validate_updates(updates: Any) -> list[dict[str, Any]] | dict[str, Any]:
+ """Validate the updates list. Returns the list on success, or an error dict."""
+ if not isinstance(updates, list):
+ return {
+ "error": "invalid_updates",
+ "message": "`updates` must be a list of {post_id, meta} objects.",
+ }
+ if not updates:
+ return {
+ "error": "empty_updates",
+ "message": "No updates supplied.",
+ }
+ if len(updates) > MAX_BULK_ITEMS:
+ return {
+ "error": "too_many_items",
+ "message": (
+ f"At most {MAX_BULK_ITEMS} items per bulk_update_meta call; " f"got {len(updates)}."
+ ),
+ }
+
+ cleaned: list[dict[str, Any]] = []
+ for idx, item in enumerate(updates):
+ if not isinstance(item, dict):
+ return {
+ "error": "invalid_item",
+ "message": f"updates[{idx}] is not an object.",
+ "index": idx,
+ }
+ post_id = item.get("post_id")
+ meta = item.get("meta")
+ if not isinstance(post_id, int) or post_id <= 0:
+ return {
+ "error": "invalid_post_id",
+ "message": f"updates[{idx}].post_id must be a positive integer.",
+ "index": idx,
+ }
+ if not isinstance(meta, dict):
+ return {
+ "error": "invalid_meta",
+ "message": f"updates[{idx}].meta must be an object.",
+ "index": idx,
+ }
+ cleaned.append({"post_id": post_id, "meta": meta})
+
+ return cleaned
+
+
+class BulkMetaHandler:
+ """Batch meta writes via the companion plugin."""
+
+ def __init__(self, client: WordPressClient) -> None:
+ self.client = client
+
+ async def bulk_update_meta(self, updates: Any) -> str:
+ validated = _validate_updates(updates)
+ if isinstance(validated, dict):
+ # client-side rejection — don't burn a round-trip
+ return json.dumps(
+ {
+ "ok": False,
+ **validated,
+ "total": len(updates) if isinstance(updates, list) else 0,
+ "updated": 0,
+ "failed": 0,
+ "skipped": 0,
+ "results": [],
+ },
+ indent=2,
+ )
+
+ try:
+ payload = await self.client.post(
+ "airano-mcp/v1/bulk-meta",
+ json_data={"updates": validated},
+ use_custom_namespace=True,
+ )
+ except Exception as exc: # noqa: BLE001
+ logger.error("bulk_update_meta companion call failed: %s", exc)
+ return json.dumps(
+ {
+ "ok": False,
+ "error": "companion_unreachable",
+ "message": str(exc),
+ "hint": (
+ "Requires airano-mcp-bridge companion plugin v2.2.0+; "
+ "install/update it from wordpress-plugin/airano-mcp-bridge.zip "
+ "or run wordpress_probe_capabilities to verify availability."
+ ),
+ "install_hint": _companion_install_hint(
+ min_version="2.2.0",
+ required_capability="manage_options",
+ route="airano-mcp/v1/bulk-meta",
+ ),
+ "total": len(validated),
+ "updated": 0,
+ "failed": 0,
+ "skipped": 0,
+ "results": [],
+ },
+ indent=2,
+ )
+
+ # The companion already returns a well-shaped response — just
+ # re-emit with the `ok` flag prepended so callers don't have to
+ # infer success from counts.
+ result = {
+ "ok": True,
+ "total": int(payload.get("total", 0)),
+ "updated": int(payload.get("updated", 0)),
+ "failed": int(payload.get("failed", 0)),
+ "skipped": int(payload.get("skipped", 0)),
+ "results": payload.get("results", []),
+ }
+ return json.dumps(result, indent=2)
diff --git a/plugins/wordpress/handlers/cache_purge.py b/plugins/wordpress/handlers/cache_purge.py
new file mode 100644
index 0000000..79e2bc5
--- /dev/null
+++ b/plugins/wordpress/handlers/cache_purge.py
@@ -0,0 +1,109 @@
+"""F.18.4 — Cache purge via companion plugin.
+
+Wraps ``POST /airano-mcp/v1/cache-purge`` (companion plugin v2.4.0+).
+Auto-detects active cache plugins (LiteSpeed, WP Rocket, W3 Total Cache,
+WP Super Cache, WP Fastest Cache, SiteGround Optimizer) and invokes
+their purge API. Always flushes the WP object cache. Replaces the
+previous Docker-socket + WP-CLI path on managed hosts.
+
+Tool: ``wordpress_cache_purge()``
+"""
+
+from __future__ import annotations
+
+import json
+import logging
+from typing import Any
+
+from plugins.wordpress.client import WordPressClient
+from plugins.wordpress.handlers._companion_hint import (
+ companion_install_hint as _companion_install_hint,
+)
+
+logger = logging.getLogger("mcphub.wordpress.cache_purge")
+
+
+def get_tool_specifications() -> list[dict[str, Any]]:
+ return [
+ {
+ "name": "cache_purge",
+ "method_name": "cache_purge",
+ "description": (
+ "Purge all caches on the WordPress site via the "
+ "airano-mcp-bridge companion plugin (v2.4.0+). Auto-detects "
+ "active cache plugins (LiteSpeed, WP Rocket, W3 Total Cache, "
+ "WP Super Cache, WP Fastest Cache, SiteGround Optimizer) and "
+ "calls each one's purge API. Always flushes the object cache. "
+ "Requires manage_options on the calling application password."
+ ),
+ "schema": {"type": "object", "properties": {}},
+ "scope": "admin",
+ }
+ ]
+
+
+class CachePurgeHandler:
+ """Cache purge via companion plugin."""
+
+ def __init__(self, client: WordPressClient) -> None:
+ self.client = client
+
+ async def cache_purge(self) -> str:
+ try:
+ payload = await self.client.post(
+ "airano-mcp/v1/cache-purge",
+ json_data={},
+ use_custom_namespace=True,
+ )
+ except Exception as exc: # noqa: BLE001
+ logger.error("cache_purge companion call failed: %s", exc)
+ return json.dumps(
+ {
+ "ok": False,
+ "error": "companion_unreachable",
+ "message": str(exc),
+ "hint": (
+ "Requires airano-mcp-bridge companion plugin v2.4.0+ "
+ "and manage_options capability. Run "
+ "wordpress_probe_capabilities to verify."
+ ),
+ "install_hint": _companion_install_hint(
+ min_version="2.4.0",
+ required_capability="manage_options",
+ route="airano-mcp/v1/cache-purge",
+ ),
+ "detected": [],
+ "purged": [],
+ "errors": [],
+ },
+ indent=2,
+ )
+
+ if not isinstance(payload, dict):
+ return json.dumps(
+ {
+ "ok": False,
+ "error": "invalid_response",
+ "message": "companion returned a non-object payload",
+ "detected": [],
+ "purged": [],
+ "errors": [],
+ },
+ indent=2,
+ )
+
+ # Pass through + normalise.
+ detected = list(payload.get("detected") or [])
+ purged = list(payload.get("purged") or [])
+ errors = list(payload.get("errors") or [])
+ ok = bool(payload.get("ok", not errors))
+
+ result = {
+ "ok": ok,
+ "detected": detected,
+ "purged": purged,
+ "skipped": list(payload.get("skipped") or []),
+ "errors": errors,
+ "plugin_version": payload.get("plugin_version"),
+ }
+ return json.dumps(result, indent=2)
diff --git a/plugins/wordpress/handlers/capabilities.py b/plugins/wordpress/handlers/capabilities.py
new file mode 100644
index 0000000..8f9ee78
--- /dev/null
+++ b/plugins/wordpress/handlers/capabilities.py
@@ -0,0 +1,221 @@
+"""F.18.1 — Probe companion-plugin capabilities for the current credentials.
+
+Calls ``GET /airano-mcp/v1/capabilities`` which returns the exact capability
+set of the authenticated user plus the list of routes the installed companion
+plugin actually ships (so MCPHub can gracefully degrade if the site is on an
+older version). Consumed by F.7e's credential-capability probe.
+
+Results are cached in-memory per ``(site_url, username)`` for 24 h, matching
+``media_probe``'s behaviour.
+"""
+
+from __future__ import annotations
+
+import asyncio
+import json
+import logging
+import time
+from dataclasses import dataclass, field
+from typing import Any
+
+from plugins.wordpress.client import WordPressClient
+
+logger = logging.getLogger("mcphub.wordpress.capabilities")
+
+CACHE_TTL_SECONDS = 24 * 3600
+
+
+def get_tool_specifications() -> list[dict[str, Any]]:
+ return [
+ {
+ "name": "probe_capabilities",
+ "method_name": "probe_capabilities",
+ "description": (
+ "Probe the airano-mcp-bridge companion plugin for the effective "
+ "capability set of the calling application password plus the list of "
+ "companion routes the installed version ships. Returns "
+ "`companion_available: false` when the plugin is missing or outdated. "
+ "Cached 24 h per (site, user)."
+ ),
+ "schema": {"type": "object", "properties": {}},
+ "scope": "read",
+ }
+ ]
+
+
+# The exact capability keys the companion plugin advertises. Keep this list
+# in lock-step with ``airano-mcp-bridge.php::get_capabilities()``; anything the
+# plugin returns that isn't in this list is preserved as-is under ``extra``.
+_EXPECTED_CAPS = (
+ "upload_files",
+ "edit_posts",
+ "publish_posts",
+ "edit_others_posts",
+ "delete_posts",
+ "edit_pages",
+ "publish_pages",
+ "manage_categories",
+ "moderate_comments",
+ "manage_options",
+ "edit_users",
+ "list_users",
+ "manage_woocommerce",
+ "edit_shop_orders",
+ "edit_products",
+)
+
+_EXPECTED_ROUTES = (
+ "seo_meta",
+ "upload_limits",
+ "upload_chunk",
+ "upload_and_attach",
+ "capabilities",
+ "bulk_meta",
+ "export",
+ "cache_purge",
+ "transient_flush",
+ "site_health",
+ "audit_hook",
+ "regenerate_thumbnails",
+)
+
+
+@dataclass
+class _CacheEntry:
+ fetched_at: float
+ data: dict[str, Any]
+
+
+@dataclass
+class _CapabilitiesCache:
+ """Process-local TTL cache keyed by (site_url, username)."""
+
+ ttl: float = CACHE_TTL_SECONDS
+ _entries: dict[tuple[str, str], _CacheEntry] = field(default_factory=dict)
+ _lock: asyncio.Lock = field(default_factory=asyncio.Lock)
+
+ async def get(self, key: tuple[str, str]) -> dict[str, Any] | None:
+ async with self._lock:
+ entry = self._entries.get(key)
+ if entry is None:
+ return None
+ if (time.time() - entry.fetched_at) > self.ttl:
+ self._entries.pop(key, None)
+ return None
+ return dict(entry.data)
+
+ async def set(self, key: tuple[str, str], data: dict[str, Any]) -> None:
+ async with self._lock:
+ self._entries[key] = _CacheEntry(fetched_at=time.time(), data=dict(data))
+
+ async def clear(self) -> None:
+ async with self._lock:
+ self._entries.clear()
+
+
+_cache = _CapabilitiesCache()
+
+
+def get_capabilities_cache() -> _CapabilitiesCache:
+ return _cache
+
+
+def _empty_capabilities_payload(site_url: str, reason: str) -> dict[str, Any]:
+ """Stable response shape for the missing-companion case."""
+ # Local import avoids a module-import cycle at startup time
+ # (capabilities → _companion_hint → capabilities via __init__).
+ from plugins.wordpress.handlers._companion_hint import companion_install_hint
+
+ return {
+ "site_url": site_url,
+ "companion_available": False,
+ "reason": reason,
+ "plugin_version": None,
+ "user": None,
+ "features": None,
+ "routes": dict.fromkeys(_EXPECTED_ROUTES, False),
+ "wordpress": None,
+ "install_hint": companion_install_hint(
+ min_version="2.1.0",
+ required_capability="read",
+ route="airano-mcp/v1/capabilities",
+ ),
+ }
+
+
+class CapabilitiesHandler:
+ """Read-only probe of the companion plugin's capability advertisement."""
+
+ def __init__(self, client: WordPressClient, *, cache: _CapabilitiesCache | None = None) -> None:
+ self.client = client
+ self._cache = cache or _cache
+
+ @property
+ def _cache_key(self) -> tuple[str, str]:
+ return (self.client.site_url, self.client.username)
+
+ async def probe_capabilities(self) -> str:
+ cached = await self._cache.get(self._cache_key)
+ if cached is not None:
+ cached["cached"] = True
+ return json.dumps(cached, indent=2)
+
+ result = await self._fetch_capabilities()
+ await self._cache.set(self._cache_key, result)
+ result_out = dict(result)
+ result_out["cached"] = False
+ return json.dumps(result_out, indent=2)
+
+ async def _fetch_capabilities(self) -> dict[str, Any]:
+ try:
+ payload = await self.client.get(
+ "airano-mcp/v1/capabilities",
+ use_custom_namespace=True,
+ )
+ except Exception as exc: # noqa: BLE001
+ logger.debug("Capabilities probe failed: %s", exc)
+ return _empty_capabilities_payload(
+ self.client.site_url, reason=f"companion_unreachable: {exc}"
+ )
+
+ if not isinstance(payload, dict):
+ return _empty_capabilities_payload(
+ self.client.site_url, reason="companion_returned_non_dict"
+ )
+
+ # Normalise the shape: fill any missing cap/route with False so
+ # downstream consumers can index without KeyError checks.
+ caps_raw = (payload.get("user") or {}).get("capabilities") or {}
+ caps = {k: bool(caps_raw.get(k, False)) for k in _EXPECTED_CAPS}
+ extra_caps = {k: bool(v) for k, v in caps_raw.items() if k not in _EXPECTED_CAPS}
+
+ routes_raw = payload.get("routes") or {}
+ routes = {k: bool(routes_raw.get(k, False)) for k in _EXPECTED_ROUTES}
+
+ user = payload.get("user") or {}
+ user_out = {
+ "id": user.get("id"),
+ "login": user.get("login"),
+ "roles": list(user.get("roles") or []),
+ "capabilities": caps,
+ "extra_capabilities": extra_caps,
+ }
+
+ return {
+ "site_url": self.client.site_url,
+ "companion_available": True,
+ "plugin_version": payload.get("plugin_version"),
+ "user": user_out,
+ "features": payload.get("features"),
+ "routes": routes,
+ "wordpress": payload.get("wordpress"),
+ }
+
+
+async def get_cached_capabilities(
+ client: WordPressClient, *, cache: _CapabilitiesCache | None = None
+) -> dict[str, Any] | None:
+ """Return the cached capability dict for ``client`` without forcing a probe."""
+ c = cache or _cache
+ key = (client.site_url, client.username)
+ return await c.get(key)
diff --git a/plugins/wordpress/handlers/export.py b/plugins/wordpress/handlers/export.py
new file mode 100644
index 0000000..e0b3c37
--- /dev/null
+++ b/plugins/wordpress/handlers/export.py
@@ -0,0 +1,210 @@
+"""F.18.3 — Structured JSON export via companion plugin.
+
+Wraps ``GET /airano-mcp/v1/export`` (companion plugin v2.3.0+). Returns
+posts (posts, pages, products, custom post types) plus referenced media,
+taxonomy terms, and meta in a single JSON envelope, with pagination
+hints (``has_more`` + ``next_offset``). Intended for offline processing,
+migrations, and content snapshots.
+
+Tool: ``wordpress_export_content(post_type="post", status="publish", ...)``
+"""
+
+from __future__ import annotations
+
+import json
+import logging
+from typing import Any
+
+from plugins.wordpress.client import WordPressClient
+from plugins.wordpress.handlers._companion_hint import (
+ companion_install_hint as _companion_install_hint,
+)
+
+logger = logging.getLogger("mcphub.wordpress.export")
+
+# Matches EXPORT_MAX_LIMIT in airano-mcp-bridge.php.
+EXPORT_MAX_LIMIT = 500
+EXPORT_DEFAULT_LIMIT = 100
+
+
+def get_tool_specifications() -> list[dict[str, Any]]:
+ return [
+ {
+ "name": "export_content",
+ "method_name": "export_content",
+ "description": (
+ "Export posts/pages/products as structured JSON via the "
+ "airano-mcp-bridge companion plugin (v2.3.0+). Includes "
+ "referenced media, taxonomy terms, and post_meta. Paginates "
+ "via offset/limit; response contains has_more + next_offset. "
+ "Not a WXR dump — intended for AI-pipeline processing, not "
+ "WP-to-WP import."
+ ),
+ "schema": {
+ "type": "object",
+ "properties": {
+ "post_type": {
+ "type": "string",
+ "description": (
+ "Comma-separated list of post types "
+ "(e.g. 'post', 'post,page', 'product'). Default 'post'."
+ ),
+ },
+ "status": {
+ "type": "string",
+ "description": (
+ "Comma-separated list of statuses, or 'any'. " "Default 'publish'."
+ ),
+ },
+ "since": {
+ "type": "string",
+ "description": (
+ "Only return posts modified after this ISO8601 " "timestamp. Optional."
+ ),
+ },
+ "limit": {
+ "type": "integer",
+ "description": (f"1..{EXPORT_MAX_LIMIT}, default {EXPORT_DEFAULT_LIMIT}."),
+ },
+ "offset": {"type": "integer", "description": "Default 0."},
+ "include_media": {
+ "type": "boolean",
+ "description": "Include featured media objects (default true).",
+ },
+ "include_terms": {
+ "type": "boolean",
+ "description": "Include taxonomy terms per post (default true).",
+ },
+ "include_meta": {
+ "type": "boolean",
+ "description": "Include post_meta (default true).",
+ },
+ },
+ },
+ "scope": "read",
+ }
+ ]
+
+
+def _normalise_bool(v: Any, default: bool) -> bool:
+ if v is None:
+ return default
+ if isinstance(v, bool):
+ return v
+ if isinstance(v, (int, float)):
+ return bool(v)
+ s = str(v).strip().lower()
+ if s in {"true", "1", "yes", "on"}:
+ return True
+ if s in {"false", "0", "no", "off"}:
+ return False
+ return default
+
+
+def _build_query_params(
+ *,
+ post_type: str | None,
+ status: str | None,
+ since: str | None,
+ limit: int | None,
+ offset: int | None,
+ include_media: Any,
+ include_terms: Any,
+ include_meta: Any,
+) -> dict[str, Any]:
+ params: dict[str, Any] = {
+ "post_type": post_type or "post",
+ "status": status or "publish",
+ }
+ if since:
+ params["since"] = since
+
+ if limit is None:
+ params["limit"] = EXPORT_DEFAULT_LIMIT
+ else:
+ lim = int(limit)
+ if lim <= 0:
+ lim = EXPORT_DEFAULT_LIMIT
+ if lim > EXPORT_MAX_LIMIT:
+ lim = EXPORT_MAX_LIMIT
+ params["limit"] = lim
+
+ params["offset"] = max(0, int(offset or 0))
+
+ # Pass booleans as "true"/"false" strings so the PHP side's
+ # bool_param() helper can parse them uniformly.
+ params["include_media"] = "true" if _normalise_bool(include_media, True) else "false"
+ params["include_terms"] = "true" if _normalise_bool(include_terms, True) else "false"
+ params["include_meta"] = "true" if _normalise_bool(include_meta, True) else "false"
+
+ return params
+
+
+class ExportHandler:
+ """Structured JSON export via the companion plugin."""
+
+ def __init__(self, client: WordPressClient) -> None:
+ self.client = client
+
+ async def export_content(
+ self,
+ post_type: str | None = None,
+ status: str | None = None,
+ since: str | None = None,
+ limit: int | None = None,
+ offset: int | None = None,
+ include_media: Any = True,
+ include_terms: Any = True,
+ include_meta: Any = True,
+ ) -> str:
+ params = _build_query_params(
+ post_type=post_type,
+ status=status,
+ since=since,
+ limit=limit,
+ offset=offset,
+ include_media=include_media,
+ include_terms=include_terms,
+ include_meta=include_meta,
+ )
+
+ try:
+ payload = await self.client.get(
+ "airano-mcp/v1/export",
+ params=params,
+ use_custom_namespace=True,
+ )
+ except Exception as exc: # noqa: BLE001
+ logger.error("export_content companion call failed: %s", exc)
+ return json.dumps(
+ {
+ "ok": False,
+ "error": "companion_unreachable",
+ "message": str(exc),
+ "hint": (
+ "Requires airano-mcp-bridge companion plugin v2.3.0+. "
+ "Run wordpress_probe_capabilities to verify availability."
+ ),
+ "install_hint": _companion_install_hint(
+ min_version="2.3.0",
+ required_capability="edit_posts",
+ route="airano-mcp/v1/export",
+ ),
+ "params": params,
+ },
+ indent=2,
+ )
+
+ if not isinstance(payload, dict):
+ return json.dumps(
+ {
+ "ok": False,
+ "error": "invalid_response",
+ "message": "companion returned a non-object payload",
+ "params": params,
+ },
+ indent=2,
+ )
+
+ result = {"ok": True, **payload}
+ return json.dumps(result, indent=2)
diff --git a/plugins/wordpress/handlers/media.py b/plugins/wordpress/handlers/media.py
index 988caeb..0f4b52b 100644
--- a/plugins/wordpress/handlers/media.py
+++ b/plugins/wordpress/handlers/media.py
@@ -1,11 +1,23 @@
"""Media Handler - manages WordPress media library operations"""
+import base64 as _b64
+import binascii
import json
from typing import Any
-import aiohttp
-
+from core.media_audit import log_media_upload
from plugins.wordpress.client import WordPressClient
+from plugins.wordpress.handlers._media_core import (
+ fetch_url_bytes,
+ wp_raw_upload,
+ wp_set_featured_media,
+ wp_update_media_metadata,
+)
+from plugins.wordpress.handlers._media_security import (
+ DEFAULT_MAX_BYTES,
+ UploadError,
+ ssrf_check,
+)
def get_tool_specifications() -> list[dict[str, Any]]:
@@ -61,13 +73,17 @@ def get_tool_specifications() -> list[dict[str, Any]]:
{
"name": "upload_media_from_url",
"method_name": "upload_media_from_url",
- "description": "Upload media from URL to media library (sideload). Downloads file from public URL and uploads to WordPress.",
+ "description": "Upload media from a public URL to the WordPress media library (sideload). Downloads the file with SSRF protection, sniffs MIME, and uploads via raw-binary POST.",
"schema": {
"type": "object",
"properties": {
"url": {
"type": "string",
- "description": "Public URL of the media file to upload (image, video, document, etc.)",
+ "description": "Public HTTPS URL of the media file",
+ },
+ "filename": {
+ "anyOf": [{"type": "string"}, {"type": "null"}],
+ "description": "Override filename (default: derived from URL)",
},
"title": {
"anyOf": [{"type": "string"}, {"type": "null"}],
@@ -75,17 +91,96 @@ def get_tool_specifications() -> list[dict[str, Any]]:
},
"alt_text": {
"anyOf": [{"type": "string"}, {"type": "null"}],
- "description": "Alternative text for accessibility (important for images)",
+ "description": "Alternative text for accessibility",
},
"caption": {
"anyOf": [{"type": "string"}, {"type": "null"}],
- "description": "Media caption (displayed below image when inserted into content)",
+ "description": "Media caption",
+ },
+ "attach_to_post": {
+ "anyOf": [{"type": "integer"}, {"type": "null"}],
+ "description": "Attach uploaded media to this post/page ID",
+ },
+ "set_featured": {
+ "type": "boolean",
+ "default": False,
+ "description": "If true and attach_to_post is set, also set as the post's featured image",
+ },
+ "skip_optimize": {
+ "type": "boolean",
+ "default": False,
+ "description": "Skip server-side image optimization (F.5a.2)",
+ },
+ "convert_to": {
+ "anyOf": [{"type": "string", "enum": ["webp", "avif"]}, {"type": "null"}],
+ "description": (
+ "F.5a.8.1: re-encode the image in a modern format before upload. "
+ "'webp' or 'avif'. Falls back to WebP if AVIF is unavailable. "
+ "Leave null to keep source format (or use WP_MEDIA_CONVERT_TO env default)."
+ ),
},
},
"required": ["url"],
},
"scope": "write",
},
+ {
+ "name": "upload_media_from_base64",
+ "method_name": "upload_media_from_base64",
+ "description": "Upload a base64-encoded file directly to the WordPress media library. For chat-attached images/files smaller than ~10 MB. Use upload_media_from_url for larger files or chunked path later.",
+ "schema": {
+ "type": "object",
+ "properties": {
+ "data": {
+ "type": "string",
+ "description": "Base64-encoded file bytes (no data: URL prefix required; prefix will be stripped if present)",
+ },
+ "filename": {
+ "type": "string",
+ "description": "Filename including extension (e.g. 'cover.jpg')",
+ },
+ "mime": {
+ "anyOf": [{"type": "string"}, {"type": "null"}],
+ "description": "Client-supplied MIME hint; ignored if magic-byte sniff says otherwise",
+ },
+ "title": {
+ "anyOf": [{"type": "string"}, {"type": "null"}],
+ "description": "Media title",
+ },
+ "alt_text": {
+ "anyOf": [{"type": "string"}, {"type": "null"}],
+ "description": "Alternative text",
+ },
+ "caption": {
+ "anyOf": [{"type": "string"}, {"type": "null"}],
+ "description": "Caption",
+ },
+ "attach_to_post": {
+ "anyOf": [{"type": "integer"}, {"type": "null"}],
+ "description": "Attach to this post/page ID",
+ },
+ "set_featured": {
+ "type": "boolean",
+ "default": False,
+ "description": "Also set as the post's featured image",
+ },
+ "skip_optimize": {
+ "type": "boolean",
+ "default": False,
+ "description": "Skip server-side image optimization",
+ },
+ "convert_to": {
+ "anyOf": [{"type": "string", "enum": ["webp", "avif"]}, {"type": "null"}],
+ "description": (
+ "F.5a.8.1: re-encode the image in a modern format before upload. "
+ "'webp' or 'avif'. Falls back to WebP if AVIF is unavailable."
+ ),
+ },
+ },
+ "required": ["data", "filename"],
+ },
+ "scope": "write",
+ },
{
"name": "update_media",
"method_name": "update_media",
@@ -157,17 +252,162 @@ def get_tool_specifications() -> list[dict[str, Any]]:
]
+def _decode_base64(data: str) -> bytes:
+ """Accept raw base64 or data: URL prefix; return decoded bytes."""
+ s = (data or "").strip()
+ if s.startswith("data:") and "," in s:
+ s = s.split(",", 1)[1]
+ s = s.replace("\n", "").replace("\r", "").replace(" ", "")
+ try:
+ return _b64.b64decode(s, validate=True)
+ except (binascii.Error, ValueError) as e:
+ raise UploadError("BAD_BASE64", f"Invalid base64 payload: {e}") from e
+
+
+def _maybe_optimize(
+ data: bytes,
+ mime_hint: str | None,
+ *,
+ skip: bool,
+ convert_to: str | None = None,
+) -> tuple[bytes, str | None]:
+ """Route image bytes through the F.5a.2 optimize pipeline.
+
+ F.5a.8.1: ``convert_to`` is forwarded to the optimizer so callers can
+ force WebP/AVIF output. A falsy value defers to the ``WP_MEDIA_CONVERT_TO``
+ env var.
+ """
+ if skip:
+ return data, mime_hint
+ try:
+ from plugins.wordpress.handlers._media_optimize import optimize # type: ignore
+ except ImportError:
+ return data, mime_hint
+ return optimize(data, mime_hint, convert_to=convert_to)
+
+
+async def _apply_metadata_and_attach(
+ client: WordPressClient,
+ media: dict[str, Any],
+ *,
+ title: str | None,
+ alt_text: str | None,
+ caption: str | None,
+ attach_to_post: int | None,
+ set_featured: bool,
+ wc_client: WordPressClient | None = None,
+) -> dict[str, Any]:
+ """Apply metadata + attach to post / featured-image.
+
+ F.5a.8.5 — when the companion's single-call ``upload-and-attach``
+ route already applied these fields, skip everything (saves 1-2
+ redundant round-trips).
+
+ F.X.fix-pass6 — return a status dict so callers can surface
+ "media uploaded but featured failed" as partial success instead
+ of a misleading GENERATION_FAILED. When ``set_featured`` targets
+ a WooCommerce product (CPT, not addressable via /wp/v2/posts/{id}),
+ route through the WC products endpoint via ``wc_client``. Both
+ "metadata applied" and "featured set" steps are reported
+ independently in the result dict.
+ """
+ status: dict[str, Any] = {
+ "metadata_applied": False,
+ "featured_set": False,
+ "featured_context": None,
+ "warnings": [],
+ }
+ if media.get("_upload_route") == "companion_unified":
+ # Companion did metadata + attach + featured atomically.
+ status["metadata_applied"] = True
+ status["featured_set"] = bool(set_featured and attach_to_post)
+ status["featured_context"] = "companion_unified"
+ return status
+
+ if any(v is not None for v in (title, alt_text, caption)) or attach_to_post is not None:
+ try:
+ await wp_update_media_metadata(
+ client,
+ media["id"],
+ title=title,
+ alt_text=alt_text,
+ caption=caption,
+ post=attach_to_post,
+ )
+ status["metadata_applied"] = True
+ except Exception as exc: # noqa: BLE001
+ status["warnings"].append(f"metadata_failed: {exc}")
+
+ if set_featured and attach_to_post is not None:
+ # 1. WC product first (CPT). Falls through to /wp/v2/posts on miss.
+ wc = wc_client or client
+ wc_product = None
+ try:
+ wc_product = await wc.get(f"products/{attach_to_post}", use_woocommerce=True)
+ except Exception:
+ wc_product = None
+
+ if isinstance(wc_product, dict) and wc_product.get("id"):
+ try:
+ existing_images = list(wc_product.get("images") or [])
+ # Featured = images[0]; preserve gallery via the same
+ # merge primitive used by attach_media_to_product.
+ from plugins.wordpress.handlers.media_attach import _merge_product_images
+
+ new_images = _merge_product_images(
+ existing=existing_images,
+ new_ids=[media["id"]],
+ role="main",
+ mode="replace",
+ )
+ await wc.put(
+ f"products/{attach_to_post}",
+ json_data={"images": new_images},
+ use_woocommerce=True,
+ )
+ status["featured_set"] = True
+ status["featured_context"] = "product"
+ except Exception as exc: # noqa: BLE001
+ status["warnings"].append(f"featured_set_failed (product): {exc}")
+ else:
+ try:
+ await wp_set_featured_media(client, attach_to_post, media["id"])
+ status["featured_set"] = True
+ status["featured_context"] = "post"
+ except Exception as exc: # noqa: BLE001
+ status["warnings"].append(f"featured_set_failed (post): {exc}")
+
+ return status
+
+
+def _format_upload_result(media: dict[str, Any], *, source: str) -> dict[str, Any]:
+ title = media.get("title")
+ rendered_title = title.get("rendered") if isinstance(title, dict) else title
+ return {
+ "id": media["id"],
+ "title": rendered_title or "",
+ "url": media.get("source_url", ""),
+ "mime_type": media.get("mime_type", ""),
+ "media_type": media.get("media_type", ""),
+ "size_bytes": media.get("media_details", {}).get("filesize"),
+ "source": source,
+ "message": f"Media uploaded successfully (id={media['id']}).",
+ }
+
+
class MediaHandler:
"""Handle media-related operations for WordPress"""
- def __init__(self, client: WordPressClient):
+ def __init__(self, client: WordPressClient, *, user_id: str | None = None):
"""
Initialize media handler.
Args:
client: WordPress API client instance
+ user_id: Calling user id, used for audit logging (None = admin/env)
"""
self.client = client
+ self.user_id = user_id
# === MEDIA ===
@@ -207,6 +447,11 @@ class MediaHandler:
"date": m["date"],
"alt_text": m.get("alt_text", ""),
"link": m.get("link", ""),
+ # F.X.fix #6: expose post_parent so callers can
+ # verify "is media X attached to post Y" without
+ # a second WP REST round trip. WP returns 0 for
+ # unattached media; we preserve that.
+ "post_parent": m.get("post") or 0,
}
for m in media
],
@@ -244,6 +489,9 @@ class MediaHandler:
"modified": media.get("modified", ""),
"link": media.get("link", ""),
"media_details": media.get("media_details", {}),
+ # F.X.fix #6: expose post_parent so attach-verification
+ # doesn't need a second round trip via posts/{id}.
+ "post_parent": media.get("post") or 0,
}
return json.dumps(result, indent=2)
@@ -255,82 +503,123 @@ class MediaHandler:
async def upload_media_from_url(
self,
url: str,
+ filename: str | None = None,
title: str | None = None,
alt_text: str | None = None,
caption: str | None = None,
+ attach_to_post: int | None = None,
+ set_featured: bool = False,
+ skip_optimize: bool = False,
+ convert_to: str | None = None,
) -> str:
- """
- Upload media from URL to media library (sideload).
-
- Downloads file from a public URL and uploads it to WordPress media library.
-
- Args:
- url: Public URL of the media file to upload
- title: Media title (used in media library)
- alt_text: Alternative text for accessibility
- caption: Media caption (displayed below image)
-
- Returns:
- JSON string with uploaded media data
- """
+ """Upload media from a public URL to the WordPress media library."""
try:
- # Download file from URL
- async with aiohttp.ClientSession() as session, session.get(url) as response:
- if response.status >= 400:
- raise Exception(f"Failed to download from URL: HTTP {response.status}")
+ ssrf = ssrf_check(url)
+ if not ssrf.allowed:
+ raise UploadError(
+ "SSRF", ssrf.reason or "URL rejected by SSRF guard.", {"url": url}
+ )
- file_content = await response.read()
- content_type = response.headers.get("Content-Type", "application/octet-stream")
+ data, declared_ct, fname_guess = await fetch_url_bytes(url, max_bytes=DEFAULT_MAX_BYTES)
+ data, mime_hint = _maybe_optimize(
+ data, declared_ct, skip=skip_optimize, convert_to=convert_to
+ )
- # Extract filename from URL
- filename = url.split("/")[-1].split("?")[0]
- if not filename:
- filename = "downloaded_file"
+ media = await wp_raw_upload(
+ self.client,
+ data,
+ filename=filename or fname_guess,
+ mime_hint=mime_hint or declared_ct,
+ # F.5a.8.5: forward metadata so the companion's single-
+ # call route (when advertised) bundles upload + attach +
+ # featured in one PHP request. ``_apply_metadata_and_attach``
+ # below is a no-op in that case.
+ attach_to_post=attach_to_post,
+ set_featured=set_featured,
+ title=title,
+ alt_text=alt_text,
+ caption=caption,
+ )
+ await _apply_metadata_and_attach(
+ self.client,
+ media,
+ title=title,
+ alt_text=alt_text,
+ caption=caption,
+ attach_to_post=attach_to_post,
+ set_featured=set_featured,
+ )
- # Create FormData for upload
- form = aiohttp.FormData()
- form.add_field("file", file_content, filename=filename, content_type=content_type)
-
- # Upload to WordPress using client's upload method
- # Note: We need to use the client's base_url and auth directly for file upload
- upload_url = f"{self.client.base_url}/media"
- headers = {
- "Authorization": self.client.auth_header,
- "Content-Disposition": f'attachment; filename="{filename}"',
- }
-
- async with aiohttp.ClientSession() as session:
- async with session.post(upload_url, data=form, headers=headers) as response:
- if response.status >= 400:
- error_text = await response.text()
- raise Exception(f"Upload failed (HTTP {response.status}): {error_text}")
-
- media = await response.json()
-
- # Update metadata if provided
- if title or alt_text or caption:
- update_data = {}
- if title:
- update_data["title"] = title
- if alt_text:
- update_data["alt_text"] = alt_text
- if caption:
- update_data["caption"] = caption
-
- await self.client.post(f"media/{media['id']}", json_data=update_data)
-
- result = {
- "id": media["id"],
- "title": media["title"]["rendered"],
- "url": media["source_url"],
- "mime_type": media["mime_type"],
- "message": f"Media uploaded from URL successfully with ID {media['id']}",
- }
-
- return json.dumps(result, indent=2)
+ log_media_upload(
+ site=self.client.site_url,
+ user_id=self.user_id,
+ mime=media.get("mime_type") or mime_hint or declared_ct,
+ size_bytes=len(data),
+ source="url",
+ media_id=media.get("id"),
+ )
+ return json.dumps(_format_upload_result(media, source=url), indent=2)
+ except UploadError as e:
+ return json.dumps(e.to_dict(), indent=2)
except Exception as e:
return json.dumps(
- {"error": str(e), "message": f"Failed to upload media from URL: {str(e)}"}, indent=2
+ {"error_code": "INTERNAL", "message": f"Upload from URL failed: {e}"}, indent=2
+ )
+
+ async def upload_media_from_base64(
+ self,
+ data: str,
+ filename: str,
+ mime: str | None = None,
+ title: str | None = None,
+ alt_text: str | None = None,
+ caption: str | None = None,
+ attach_to_post: int | None = None,
+ set_featured: bool = False,
+ skip_optimize: bool = False,
+ convert_to: str | None = None,
+ ) -> str:
+ """Upload a base64-encoded file to the WordPress media library."""
+ try:
+ raw = _decode_base64(data)
+ raw, mime_hint = _maybe_optimize(raw, mime, skip=skip_optimize, convert_to=convert_to)
+
+ media = await wp_raw_upload(
+ self.client,
+ raw,
+ filename=filename,
+ mime_hint=mime_hint or mime,
+ # F.5a.8.5: single-call path when companion advertises it.
+ attach_to_post=attach_to_post,
+ set_featured=set_featured,
+ title=title,
+ alt_text=alt_text,
+ caption=caption,
+ )
+ await _apply_metadata_and_attach(
+ self.client,
+ media,
+ title=title,
+ alt_text=alt_text,
+ caption=caption,
+ attach_to_post=attach_to_post,
+ set_featured=set_featured,
+ )
+
+ log_media_upload(
+ site=self.client.site_url,
+ user_id=self.user_id,
+ mime=media.get("mime_type") or mime_hint or mime,
+ size_bytes=len(raw),
+ source="base64",
+ media_id=media.get("id"),
+ )
+ return json.dumps(_format_upload_result(media, source="base64"), indent=2)
+ except UploadError as e:
+ return json.dumps(e.to_dict(), indent=2)
+ except Exception as e:
+ return json.dumps(
+ {"error_code": "INTERNAL", "message": f"Base64 upload failed: {e}"}, indent=2
)
async def update_media(
diff --git a/plugins/wordpress/handlers/media_attach.py b/plugins/wordpress/handlers/media_attach.py
new file mode 100644
index 0000000..259c3e1
--- /dev/null
+++ b/plugins/wordpress/handlers/media_attach.py
@@ -0,0 +1,397 @@
+"""F.5a.3: WooCommerce product image attachment + WP featured-image tool.
+
+WooCommerce `PUT /products/{id}` with `images: []` REPLACES the whole gallery.
+For additive behaviour we GET-merge-PUT. We only ever reference existing
+media by `id` (never pass external `src` — WC would re-download it).
+"""
+
+from __future__ import annotations
+
+import json
+from typing import Any
+
+from plugins.wordpress.client import WordPressClient
+from plugins.wordpress.handlers._media_core import (
+ fetch_url_bytes,
+ wp_raw_upload,
+ wp_set_featured_media,
+ wp_update_media_metadata,
+)
+from plugins.wordpress.handlers._media_security import (
+ DEFAULT_MAX_BYTES,
+ UploadError,
+ ssrf_check,
+)
+from plugins.wordpress.handlers.media import _decode_base64, _maybe_optimize
+
+
+def get_tool_specifications() -> list[dict[str, Any]]:
+ return [
+ {
+ "name": "attach_media_to_product",
+ "method_name": "attach_media_to_product",
+ "description": "Attach existing media library items to a WooCommerce product. Use role='main' for the primary image (gallery index 0) or role='gallery' for extra images. mode='append' preserves the existing gallery; mode='replace' wipes and rewrites it.",
+ "schema": {
+ "type": "object",
+ "properties": {
+ "product_id": {"type": "integer", "minimum": 1},
+ "media_ids": {
+ "type": "array",
+ "items": {"type": "integer", "minimum": 1},
+ "minItems": 1,
+ },
+ "role": {"type": "string", "enum": ["main", "gallery"], "default": "gallery"},
+ "mode": {
+ "type": "string",
+ "enum": ["append", "replace"],
+ "default": "append",
+ },
+ },
+ "required": ["product_id", "media_ids"],
+ },
+ "scope": "write",
+ },
+ {
+ "name": "upload_and_attach_to_product",
+ "method_name": "upload_and_attach_to_product",
+ "description": "Upload a single image (from base64 or URL) and attach it to a WooCommerce product in one call.",
+ "schema": {
+ "type": "object",
+ "properties": {
+ "product_id": {"type": "integer", "minimum": 1},
+ "source": {"type": "string", "enum": ["base64", "url"]},
+ "data": {
+ "anyOf": [{"type": "string"}, {"type": "null"}],
+ "description": "Base64 payload (when source=base64)",
+ },
+ "url": {
+ "anyOf": [{"type": "string"}, {"type": "null"}],
+ "description": "Public URL (when source=url)",
+ },
+ "filename": {"type": "string"},
+ "mime": {"anyOf": [{"type": "string"}, {"type": "null"}]},
+ "alt_text": {"anyOf": [{"type": "string"}, {"type": "null"}]},
+ "role": {"type": "string", "enum": ["main", "gallery"], "default": "gallery"},
+ "mode": {
+ "type": "string",
+ "enum": ["append", "replace"],
+ "default": "append",
+ },
+ "skip_optimize": {"type": "boolean", "default": False},
+ },
+ "required": ["product_id", "source", "filename"],
+ },
+ "scope": "write",
+ },
+ {
+ "name": "set_featured_image",
+ "method_name": "set_featured_image",
+ "description": "Set a WordPress post's featured image to an existing media library item.",
+ "schema": {
+ "type": "object",
+ "properties": {
+ "post_id": {"type": "integer", "minimum": 1},
+ "media_id": {"type": "integer", "minimum": 1},
+ },
+ "required": ["post_id", "media_id"],
+ },
+ "scope": "write",
+ },
+ ]
+
+
+class MediaAttachHandler:
+ """Media-to-product and media-to-post attachment helpers.
+
+ F.X.fix-pass4 — Two clients, one purpose:
+
+ * ``self.client`` authenticates WC REST (``/wc/v3/*``) — typically
+ a Consumer Key / Secret pair on a WC site.
+ * ``self.wp_media_client`` authenticates WP core REST
+ (``/wp/v2/media``, ``/wp/v2/posts/{id}``) — must be a WP user +
+ Application Password. WC keys do NOT work for /wp/v2/.
+
+ Resolution logic at call time:
+
+ * If a dedicated ``wp_media_client`` was passed in, use it for
+ /wp/v2/* — this is the explicit "user configured WP App Password
+ on a WC site" path.
+ * Else, if ``self.client.username`` starts with ``ck_`` we know
+ the primary is a WC-keys pair that cannot hit /wp/v2/* — surface
+ the structured ``WP_CREDENTIALS_MISSING`` error so the user
+ knows where to add credentials.
+ * Else, fall back to ``self.client`` — for the legacy
+ single-credential WC mode (Application Password only) and for
+ tests instantiating the handler with a plain WordPressClient.
+ """
+
+ def __init__(
+ self,
+ client: WordPressClient,
+ *,
+ wp_media_client: WordPressClient | None = None,
+ ):
+ self.client = client
+ self.wp_media_client = wp_media_client
+
+ def _require_wp_media_client(self) -> WordPressClient | None:
+ """Pick the right client for /wp/v2/* calls, or None if no
+ viable credential set is available."""
+ if self.wp_media_client is not None:
+ return self.wp_media_client
+ primary_user = getattr(self.client, "username", "") or ""
+ if primary_user.startswith("ck_"):
+ # Primary is a WC consumer key — won't authenticate WP REST.
+ return None
+ # Primary is an Application Password (WP plugin mode or
+ # legacy single-credential WC mode) — same client works.
+ return self.client
+
+ @staticmethod
+ def _wp_credentials_missing_error() -> str:
+ """Standard error when wp_media_client is None on WC sites."""
+ return json.dumps(
+ {
+ "error_code": "WP_CREDENTIALS_MISSING",
+ "message": (
+ "This tool needs a WordPress Application Password to upload "
+ "media via /wp/v2/media. WooCommerce Consumer Key + Secret "
+ "do NOT authenticate the WP core REST API. Open the site in "
+ "the dashboard and fill 'WordPress Username' + 'WordPress "
+ "Application Password' under Connection Settings (advanced)."
+ ),
+ "remediation": {
+ "where": "Dashboard → site → Connection Settings",
+ "fields": ["wp_username", "wp_app_password"],
+ "wp_admin_path": "Users → Profile → Application Passwords",
+ },
+ },
+ indent=2,
+ )
+
+ async def attach_media_to_product(
+ self,
+ product_id: int,
+ media_ids: list[int],
+ role: str = "gallery",
+ mode: str = "append",
+ ) -> str:
+ try:
+ if role not in ("main", "gallery"):
+ raise UploadError("BAD_ROLE", f"role must be 'main' or 'gallery', got '{role}'.")
+ if mode not in ("append", "replace"):
+ raise UploadError("BAD_MODE", f"mode must be 'append' or 'replace', got '{mode}'.")
+
+ # /wp/v2/media GET to validate media_ids needs WP creds.
+ if self._require_wp_media_client() is None:
+ return self._wp_credentials_missing_error()
+
+ await self._validate_media_ids(media_ids)
+
+ new_images = _merge_product_images(
+ existing=await self._get_product_images(product_id),
+ new_ids=media_ids,
+ role=role,
+ mode=mode,
+ )
+ updated = await self.client.put(
+ f"products/{product_id}",
+ json_data={"images": new_images},
+ use_woocommerce=True,
+ )
+ return json.dumps(
+ {
+ "product_id": product_id,
+ "images": [
+ {"id": i.get("id"), "src": i.get("src")} for i in updated.get("images", [])
+ ],
+ "message": f"Attached {len(media_ids)} media item(s) to product {product_id}.",
+ },
+ indent=2,
+ )
+ except UploadError as e:
+ return json.dumps(e.to_dict(), indent=2)
+ except Exception as e:
+ return json.dumps(
+ {"error_code": "INTERNAL", "message": f"Attach failed: {e}"}, indent=2
+ )
+
+ async def upload_and_attach_to_product(
+ self,
+ product_id: int,
+ source: str,
+ filename: str,
+ data: str | None = None,
+ url: str | None = None,
+ mime: str | None = None,
+ alt_text: str | None = None,
+ role: str = "gallery",
+ mode: str = "append",
+ skip_optimize: bool = False,
+ ) -> str:
+ try:
+ wp_client = self._require_wp_media_client()
+ if wp_client is None:
+ return self._wp_credentials_missing_error()
+ if source == "base64":
+ if not data:
+ raise UploadError("MISSING_FIELD", "source=base64 requires 'data'.")
+ raw = _decode_base64(data)
+ mime_hint = mime
+ elif source == "url":
+ if not url:
+ raise UploadError("MISSING_FIELD", "source=url requires 'url'.")
+ ssrf = ssrf_check(url)
+ if not ssrf.allowed:
+ raise UploadError("SSRF", ssrf.reason or "URL rejected.", {"url": url})
+ raw, declared_ct, _ = await fetch_url_bytes(url, max_bytes=DEFAULT_MAX_BYTES)
+ mime_hint = mime or declared_ct
+ else:
+ raise UploadError(
+ "BAD_SOURCE", f"source must be 'base64' or 'url', got '{source}'."
+ )
+
+ raw, mime_hint = _maybe_optimize(raw, mime_hint, skip=skip_optimize)
+ media = await wp_raw_upload(wp_client, raw, filename=filename, mime_hint=mime_hint)
+ if alt_text is not None:
+ await wp_update_media_metadata(wp_client, media["id"], alt_text=alt_text)
+
+ # Chain into attach
+ attach_json = await self.attach_media_to_product(
+ product_id=product_id, media_ids=[media["id"]], role=role, mode=mode
+ )
+ attach = json.loads(attach_json)
+ return json.dumps(
+ {
+ "media_id": media["id"],
+ "media_url": media.get("source_url"),
+ "product_id": product_id,
+ "attach_result": attach,
+ },
+ indent=2,
+ )
+ except UploadError as e:
+ return json.dumps(e.to_dict(), indent=2)
+ except Exception as e:
+ return json.dumps(
+ {"error_code": "INTERNAL", "message": f"Upload+attach failed: {e}"}, indent=2
+ )
+
+ async def set_featured_image(self, post_id: int, media_id: int) -> str:
+ """Set the featured image of a WC product OR a WP post.
+
+ F.X.fix-pass5 — auto-detect routing: WC products (CPT) are not
+ addressable via ``/wp/v2/posts/{id}``; their featured image is
+ the first entry of the ``images`` array on the WC product
+ record. We try the WC ``products/{id}`` endpoint first because
+ this tool is WC-namespaced; if that 404s we fall through to
+ the legacy WP REST behaviour for regular posts/pages.
+ """
+ try:
+ wp_client = self._require_wp_media_client()
+ if wp_client is None:
+ return self._wp_credentials_missing_error()
+ await self._validate_media_ids([media_id])
+
+ # 1. Try WC product first.
+ wc_product: dict[str, Any] | None = None
+ try:
+ wc_product = await self.client.get(f"products/{post_id}", use_woocommerce=True)
+ except Exception:
+ wc_product = None
+
+ if isinstance(wc_product, dict) and wc_product.get("id"):
+ # Featured = images[0]; preserve gallery via the existing
+ # role="main" mode="replace" merge logic.
+ new_images = _merge_product_images(
+ existing=list(wc_product.get("images") or []),
+ new_ids=[media_id],
+ role="main",
+ mode="replace",
+ )
+ updated = await self.client.put(
+ f"products/{post_id}",
+ json_data={"images": new_images},
+ use_woocommerce=True,
+ )
+ featured_id = (updated.get("images") or [{}])[0].get("id", media_id)
+ return json.dumps(
+ {
+ "post_id": post_id,
+ "featured_media": featured_id,
+ "context": "product",
+ "message": (
+ f"Set media {media_id} as featured image of WooCommerce "
+ f"product {post_id}."
+ ),
+ },
+ indent=2,
+ )
+
+ # 2. Fall back to WP REST for regular posts/pages.
+ result = await wp_set_featured_media(wp_client, post_id, media_id)
+ return json.dumps(
+ {
+ "post_id": post_id,
+ "featured_media": result.get("featured_media", media_id),
+ "context": "post",
+ "message": f"Set media {media_id} as featured image of post {post_id}.",
+ },
+ indent=2,
+ )
+ except UploadError as e:
+ return json.dumps(e.to_dict(), indent=2)
+ except Exception as e:
+ return json.dumps(
+ {"error_code": "INTERNAL", "message": f"Set featured failed: {e}"}, indent=2
+ )
+
+ # --- internals ----------------------------------------------------------
+
+ async def _get_product_images(self, product_id: int) -> list[dict[str, Any]]:
+ product = await self.client.get(f"products/{product_id}", use_woocommerce=True)
+ return list(product.get("images", []))
+
+ async def _validate_media_ids(self, media_ids: list[int]) -> None:
+ if not media_ids:
+ raise UploadError("MISSING_FIELD", "media_ids must not be empty.")
+ # /wp/v2/media GET — needs WP creds. Caller has already
+ # validated wp_media_client is present.
+ wp_client = self.wp_media_client or self.client
+ for mid in media_ids:
+ try:
+ await wp_client.get(f"media/{mid}")
+ except Exception as e:
+ raise UploadError(
+ "MEDIA_NOT_FOUND",
+ f"Media id {mid} not found in the library.",
+ {"media_id": mid, "error": str(e)},
+ ) from e
+
+
+def _merge_product_images(
+ existing: list[dict[str, Any]],
+ new_ids: list[int],
+ *,
+ role: str,
+ mode: str,
+) -> list[dict[str, Any]]:
+ """Pure function — easy to unit-test."""
+ new_entries = [{"id": mid} for mid in new_ids]
+
+ if mode == "replace":
+ if role == "main":
+ # First = main, rest = gallery
+ return new_entries
+ # role=gallery + mode=replace → keep existing main (index 0), replace the rest
+ main = existing[:1]
+ return main + new_entries
+
+ # mode == "append"
+ if role == "main":
+ # New first image becomes main; existing main demoted into gallery
+ return new_entries + existing
+ # role=gallery + mode=append → keep existing, add new to the end (dedupe by id)
+ seen_ids = {i.get("id") for i in existing if i.get("id") is not None}
+ tail = [e for e in new_entries if e["id"] not in seen_ids]
+ return existing + tail
diff --git a/plugins/wordpress/handlers/media_bulk.py b/plugins/wordpress/handlers/media_bulk.py
new file mode 100644
index 0000000..3e065da
--- /dev/null
+++ b/plugins/wordpress/handlers/media_bulk.py
@@ -0,0 +1,250 @@
+"""F.5a.8.3 — Bulk media library operations.
+
+Wraps stock ``wp/v2/media`` REST endpoints with two batch tools:
+
+* ``wordpress_bulk_delete_media`` — delete (trash or force-delete) a list of
+ attachments in one call, collecting per-item results.
+* ``wordpress_bulk_reassign_media`` — change the ``post`` parent of a list
+ of attachments in one call (useful for moving media between posts, or
+ detaching from a deleted parent).
+
+Both tools use the existing authenticated REST client. They iterate
+serially with a small concurrency cap so a large batch doesn't flood a
+shared-hosting WP backend, but the whole call returns a single JSON
+envelope with ``processed`` / ``errors`` / ``total`` — matching the
+shape used by F.18.2 bulk-meta.
+
+Per-call cap: 100 attachment IDs.
+"""
+
+from __future__ import annotations
+
+import asyncio
+import json
+import logging
+from typing import Any
+
+from plugins.wordpress.client import WordPressClient
+
+logger = logging.getLogger("mcphub.wordpress.media_bulk")
+
+_MAX_IDS_PER_CALL = 100
+_CONCURRENCY = 4 # parallel in-flight REST calls
+
+
+def get_tool_specifications() -> list[dict[str, Any]]:
+ return [
+ {
+ "name": "bulk_delete_media",
+ "method_name": "bulk_delete_media",
+ "description": (
+ "Delete (trash or permanently remove) a list of media "
+ "attachments in a single call. Max 100 IDs per request. "
+ "Returns processed / errors / total. Uses stock "
+ "/wp/v2/media/{id} DELETE so no companion plugin is "
+ "required, but issues N requests (with a small concurrency "
+ "cap); for 1000+ attachments, paginate client-side."
+ ),
+ "schema": {
+ "type": "object",
+ "properties": {
+ "media_ids": {
+ "type": "array",
+ "items": {"type": "integer", "minimum": 1},
+ "minItems": 1,
+ "maxItems": _MAX_IDS_PER_CALL,
+ "description": f"Attachment IDs (max {_MAX_IDS_PER_CALL}).",
+ },
+ "force": {
+ "type": "boolean",
+ "default": False,
+ "description": (
+ "true = permanently delete (bypass trash). "
+ "false = move to trash. Media in trash > 30 "
+ "days is removed by WP cron by default."
+ ),
+ },
+ },
+ "required": ["media_ids"],
+ },
+ "scope": "admin",
+ },
+ {
+ "name": "bulk_reassign_media",
+ "method_name": "bulk_reassign_media",
+ "description": (
+ "Reassign the parent post of a list of media attachments "
+ "in a single call. Useful for moving attachments between "
+ "posts, or detaching (use target_post=0). Max 100 IDs. "
+ "Returns processed / errors / total."
+ ),
+ "schema": {
+ "type": "object",
+ "properties": {
+ "media_ids": {
+ "type": "array",
+ "items": {"type": "integer", "minimum": 1},
+ "minItems": 1,
+ "maxItems": _MAX_IDS_PER_CALL,
+ "description": f"Attachment IDs (max {_MAX_IDS_PER_CALL}).",
+ },
+ "target_post": {
+ "type": "integer",
+ "minimum": 0,
+ "description": (
+ "Post ID to assign as the new parent. " "0 = detach (orphan the media)."
+ ),
+ },
+ },
+ "required": ["media_ids", "target_post"],
+ },
+ "scope": "write",
+ },
+ ]
+
+
+class MediaBulkHandler:
+ """Batch delete + reassign for WP media attachments."""
+
+ def __init__(self, client: WordPressClient) -> None:
+ self.client = client
+
+ # ------------------------------------------------------------------
+ # Input normalization
+ # ------------------------------------------------------------------
+
+ def _normalize_ids(self, media_ids: list[Any]) -> list[int]:
+ """Dedup, coerce to int, drop non-positive, cap at _MAX_IDS_PER_CALL.
+
+ Order is preserved so the caller can correlate request and response
+ by index if they care.
+ """
+ out: list[int] = []
+ seen: set[int] = set()
+ for raw in media_ids or []:
+ try:
+ i = int(raw)
+ except (TypeError, ValueError):
+ continue
+ if i > 0 and i not in seen:
+ out.append(i)
+ seen.add(i)
+ if len(out) >= _MAX_IDS_PER_CALL:
+ break
+ return out
+
+ # ------------------------------------------------------------------
+ # Bulk delete
+ # ------------------------------------------------------------------
+
+ async def bulk_delete_media(
+ self,
+ media_ids: list[int],
+ force: bool = False,
+ ) -> str:
+ ids = self._normalize_ids(media_ids or [])
+ if not ids:
+ return json.dumps(
+ {
+ "ok": False,
+ "error": "invalid_request",
+ "message": "media_ids must contain at least one positive integer.",
+ },
+ indent=2,
+ )
+
+ params = {"force": "true" if force else "false"}
+ sem = asyncio.Semaphore(_CONCURRENCY)
+
+ processed: list[dict[str, Any]] = []
+ errors: list[dict[str, Any]] = []
+
+ async def _one(mid: int) -> None:
+ async with sem:
+ try:
+ await self.client.delete(f"media/{mid}", params=params)
+ processed.append({"id": mid})
+ except Exception as exc: # noqa: BLE001
+ errors.append({"id": mid, "error": str(exc)})
+
+ await asyncio.gather(*[_one(i) for i in ids])
+
+ return json.dumps(
+ {
+ "ok": not errors,
+ "total": len(ids),
+ "processed": len(processed),
+ "errors": errors,
+ "force": force,
+ },
+ indent=2,
+ )
+
+ # ------------------------------------------------------------------
+ # Bulk reassign
+ # ------------------------------------------------------------------
+
+ async def bulk_reassign_media(
+ self,
+ media_ids: list[int],
+ target_post: int,
+ ) -> str:
+ try:
+ target = int(target_post)
+ except (TypeError, ValueError):
+ return json.dumps(
+ {
+ "ok": False,
+ "error": "invalid_request",
+ "message": "target_post must be an integer (0 to detach).",
+ },
+ indent=2,
+ )
+ if target < 0:
+ return json.dumps(
+ {
+ "ok": False,
+ "error": "invalid_request",
+ "message": "target_post must be >= 0.",
+ },
+ indent=2,
+ )
+
+ ids = self._normalize_ids(media_ids or [])
+ if not ids:
+ return json.dumps(
+ {
+ "ok": False,
+ "error": "invalid_request",
+ "message": "media_ids must contain at least one positive integer.",
+ },
+ indent=2,
+ )
+
+ sem = asyncio.Semaphore(_CONCURRENCY)
+ processed: list[dict[str, Any]] = []
+ errors: list[dict[str, Any]] = []
+
+ async def _one(mid: int) -> None:
+ async with sem:
+ try:
+ await self.client.post(
+ f"media/{mid}",
+ json_data={"post": target},
+ )
+ processed.append({"id": mid})
+ except Exception as exc: # noqa: BLE001
+ errors.append({"id": mid, "error": str(exc)})
+
+ await asyncio.gather(*[_one(i) for i in ids])
+
+ return json.dumps(
+ {
+ "ok": not errors,
+ "total": len(ids),
+ "processed": len(processed),
+ "errors": errors,
+ "target_post": target,
+ },
+ indent=2,
+ )
diff --git a/plugins/wordpress/handlers/media_chunked.py b/plugins/wordpress/handlers/media_chunked.py
new file mode 100644
index 0000000..6bf26ea
--- /dev/null
+++ b/plugins/wordpress/handlers/media_chunked.py
@@ -0,0 +1,322 @@
+"""Chunked media upload tools (F.5a.5).
+
+Exposes four tools that wrap `core.upload_sessions.UploadSessionStore`:
+
+- `upload_media_chunked_start(filename, total_bytes, mime?, sha256?)`
+- `upload_media_chunked_chunk(session_id, index, data_b64)`
+- `upload_media_chunked_finish(session_id, title?, alt_text?, caption?,
+ attach_to_post?, set_featured?, skip_optimize?)`
+- `upload_media_chunked_abort(session_id)`
+
+At `finish`, reuses the existing F.5a.1/.2 primitives: assembled bytes →
+optional Pillow optimization → `wp_raw_upload` → metadata/attach/featured.
+"""
+
+from __future__ import annotations
+
+import base64 as _b64
+import binascii
+import json
+from typing import Any
+
+from core.media_audit import log_media_upload
+from core.upload_sessions import (
+ UploadSessionError,
+ UploadSessionStore,
+ get_upload_session_store,
+)
+from plugins.wordpress.client import WordPressClient
+from plugins.wordpress.handlers._media_core import wp_raw_upload
+from plugins.wordpress.handlers._media_security import UploadError
+from plugins.wordpress.handlers.media import (
+ _apply_metadata_and_attach,
+ _format_upload_result,
+ _maybe_optimize,
+)
+
+
+def get_tool_specifications() -> list[dict[str, Any]]:
+ """Return tool specs for ToolGenerator."""
+ return [
+ {
+ "name": "upload_media_chunked_start",
+ "method_name": "upload_media_chunked_start",
+ "description": (
+ "Start a chunked upload session for a large media file. "
+ "Returns a session_id to use for subsequent chunk/finish/abort "
+ "calls. TTL 1h, hard cap 500 MB per session."
+ ),
+ "schema": {
+ "type": "object",
+ "properties": {
+ "filename": {"type": "string", "description": "Filename with extension."},
+ "total_bytes": {
+ "type": "integer",
+ "minimum": 1,
+ "description": "Declared total size in bytes.",
+ },
+ "mime": {
+ "anyOf": [{"type": "string"}, {"type": "null"}],
+ "description": "MIME hint; still sniffed at finish.",
+ },
+ "sha256": {
+ "anyOf": [{"type": "string"}, {"type": "null"}],
+ "description": "Optional hex sha256 of the full payload; verified at finish.",
+ },
+ },
+ "required": ["filename", "total_bytes"],
+ },
+ "scope": "write",
+ },
+ {
+ "name": "upload_media_chunked_chunk",
+ "method_name": "upload_media_chunked_chunk",
+ "description": (
+ "Append a single base64-encoded chunk to a chunked upload session. "
+ "Chunks must arrive in order starting at index 0."
+ ),
+ "schema": {
+ "type": "object",
+ "properties": {
+ "session_id": {"type": "string"},
+ "index": {"type": "integer", "minimum": 0},
+ "data_b64": {
+ "type": "string",
+ "description": "Base64-encoded chunk bytes.",
+ },
+ "chunk_sha256": {
+ "anyOf": [{"type": "string"}, {"type": "null"}],
+ "description": "Optional hex sha256 of this chunk.",
+ },
+ },
+ "required": ["session_id", "index", "data_b64"],
+ },
+ "scope": "write",
+ },
+ {
+ "name": "upload_media_chunked_finish",
+ "method_name": "upload_media_chunked_finish",
+ "description": (
+ "Finalize a chunked upload: assemble + optimize + upload to "
+ "WordPress. Verifies sha256 if supplied at start."
+ ),
+ "schema": {
+ "type": "object",
+ "properties": {
+ "session_id": {"type": "string"},
+ "title": {"anyOf": [{"type": "string"}, {"type": "null"}]},
+ "alt_text": {"anyOf": [{"type": "string"}, {"type": "null"}]},
+ "caption": {"anyOf": [{"type": "string"}, {"type": "null"}]},
+ "attach_to_post": {
+ "anyOf": [{"type": "integer"}, {"type": "null"}],
+ },
+ "set_featured": {"type": "boolean", "default": False},
+ "skip_optimize": {"type": "boolean", "default": False},
+ },
+ "required": ["session_id"],
+ },
+ "scope": "write",
+ },
+ {
+ "name": "upload_media_chunked_abort",
+ "method_name": "upload_media_chunked_abort",
+ "description": "Abort a chunked upload session and delete its spill file.",
+ "schema": {
+ "type": "object",
+ "properties": {
+ "session_id": {"type": "string"},
+ },
+ "required": ["session_id"],
+ },
+ "scope": "write",
+ },
+ {
+ "name": "upload_media_chunked_status",
+ "method_name": "upload_media_chunked_status",
+ "description": (
+ "Query the current state of a chunked-upload session — returns "
+ "``received_bytes`` and ``next_chunk`` so the caller can resume "
+ "after a disconnect. The session survives for 1 h after the "
+ "last activity; re-start the session with ``upload_media_chunked_start`` "
+ "if it has expired or been aborted. Returns ``{error_code: "
+ "NOT_FOUND}`` when the session is unknown."
+ ),
+ "schema": {
+ "type": "object",
+ "properties": {
+ "session_id": {
+ "type": "string",
+ "description": "Session ID returned by upload_media_chunked_start.",
+ },
+ },
+ "required": ["session_id"],
+ },
+ "scope": "read",
+ },
+ ]
+
+
+def _decode_chunk(data_b64: str) -> bytes:
+ s = (data_b64 or "").strip()
+ if s.startswith("data:") and "," in s:
+ s = s.split(",", 1)[1]
+ s = s.replace("\n", "").replace("\r", "").replace(" ", "")
+ try:
+ return _b64.b64decode(s, validate=True)
+ except (binascii.Error, ValueError) as e:
+ raise UploadSessionError("BAD_BASE64", f"Invalid base64 chunk: {e}") from e
+
+
+class MediaChunkedHandler:
+ """Chunked-upload tool handler for the WordPress plugin."""
+
+ def __init__(
+ self,
+ client: WordPressClient,
+ *,
+ user_id: str | None = None,
+ store: UploadSessionStore | None = None,
+ ) -> None:
+ self.client = client
+ self.user_id = user_id or "admin"
+ self._store = store
+
+ @property
+ def store(self) -> UploadSessionStore:
+ return self._store or get_upload_session_store()
+
+ async def upload_media_chunked_start(
+ self,
+ filename: str,
+ total_bytes: int,
+ mime: str | None = None,
+ sha256: str | None = None,
+ ) -> str:
+ try:
+ sess = await self.store.start(
+ user_id=self.user_id,
+ filename=filename,
+ total_bytes=total_bytes,
+ mime=mime,
+ sha256=sha256,
+ )
+ return json.dumps(sess.to_public_dict(), indent=2)
+ except UploadSessionError as e:
+ return json.dumps(e.to_dict(), indent=2)
+
+ async def upload_media_chunked_chunk(
+ self,
+ session_id: str,
+ index: int,
+ data_b64: str,
+ chunk_sha256: str | None = None,
+ ) -> str:
+ try:
+ data = _decode_chunk(data_b64)
+ sess = await self.store.append_chunk(session_id, index, data, chunk_sha256=chunk_sha256)
+ return json.dumps(sess.to_public_dict(), indent=2)
+ except UploadSessionError as e:
+ return json.dumps(e.to_dict(), indent=2)
+
+ async def upload_media_chunked_finish(
+ self,
+ session_id: str,
+ title: str | None = None,
+ alt_text: str | None = None,
+ caption: str | None = None,
+ attach_to_post: int | None = None,
+ set_featured: bool = False,
+ skip_optimize: bool = False,
+ ) -> str:
+ from core.tool_rate_limiter import ToolRateLimitError, get_tool_rate_limiter
+
+ try:
+ get_tool_rate_limiter().check(
+ "wordpress_upload_media_chunked_finish",
+ self.user_id if self.user_id != "admin" else None,
+ )
+ except ToolRateLimitError as e:
+ return json.dumps(e.to_dict(), indent=2)
+
+ try:
+ sess, assembled = await self.store.finalize(session_id)
+ data, mime_hint = _maybe_optimize(assembled, sess.mime, skip=skip_optimize)
+ media = await wp_raw_upload(
+ self.client,
+ data,
+ filename=sess.filename,
+ mime_hint=mime_hint or sess.mime,
+ # F.5a.8.5: single-call upload+metadata+attach+featured
+ # when the companion advertises it.
+ attach_to_post=attach_to_post,
+ set_featured=set_featured,
+ title=title,
+ alt_text=alt_text,
+ caption=caption,
+ )
+ await _apply_metadata_and_attach(
+ self.client,
+ media,
+ title=title,
+ alt_text=alt_text,
+ caption=caption,
+ attach_to_post=attach_to_post,
+ set_featured=set_featured,
+ )
+ log_media_upload(
+ site=self.client.site_url,
+ user_id=self.user_id if self.user_id != "admin" else None,
+ mime=media.get("mime_type") or mime_hint or sess.mime,
+ size_bytes=len(data),
+ source="chunked",
+ media_id=media.get("id"),
+ )
+ return json.dumps(_format_upload_result(media, source="chunked"), indent=2)
+ except UploadSessionError as e:
+ return json.dumps(e.to_dict(), indent=2)
+ except UploadError as e:
+ return json.dumps(e.to_dict(), indent=2)
+ except Exception as e: # noqa: BLE001
+ return json.dumps(
+ {"error_code": "INTERNAL", "message": f"Chunked finish failed: {e}"}, indent=2
+ )
+
+ async def upload_media_chunked_status(self, session_id: str) -> str:
+ """Return the current received_bytes / next_chunk for a session.
+
+ Enables a resume-after-disconnect flow: the client keeps the
+ session_id from the original ``start`` call, then queries this
+ tool to discover how many bytes the server already has before
+ resuming ``chunk`` calls at the reported ``next_chunk`` index.
+ Aborted sessions (spill file gone) still return their last
+ known status, clearly marked by the ``status`` field.
+ """
+ try:
+ sess = await self.store.get(session_id)
+ if sess is None:
+ return json.dumps(
+ {
+ "error_code": "NO_SESSION",
+ "message": (
+ "Session not found. Either it has never existed, "
+ "already been finalised, or been aborted. Start a "
+ "new session with upload_media_chunked_start."
+ ),
+ "session_id": session_id,
+ },
+ indent=2,
+ )
+ return json.dumps(sess.to_public_dict(), indent=2)
+ except Exception as exc: # noqa: BLE001
+ return json.dumps(
+ {
+ "error_code": "INTERNAL",
+ "message": f"Status lookup failed: {exc}",
+ "session_id": session_id,
+ },
+ indent=2,
+ )
+
+ async def upload_media_chunked_abort(self, session_id: str) -> str:
+ removed = await self.store.abort(session_id)
+ return json.dumps({"session_id": session_id, "aborted": bool(removed)}, indent=2)
diff --git a/plugins/wordpress/handlers/media_probe.py b/plugins/wordpress/handlers/media_probe.py
new file mode 100644
index 0000000..f7623d9
--- /dev/null
+++ b/plugins/wordpress/handlers/media_probe.py
@@ -0,0 +1,229 @@
+"""F.5a.6.3 — Probe WordPress upload limits with 24 h cache.
+
+Tries the airano-mcp-bridge companion endpoint first (which can read
+PHP ini values directly); falls back to whatever the standard WP REST
+index publishes. Results are cached in-memory per site for 24 h.
+
+The cache is keyed by ``(site_url, username)`` so admin and per-user
+clients don't poison each other's view.
+"""
+
+from __future__ import annotations
+
+import asyncio
+import json
+import logging
+import time
+from dataclasses import dataclass, field
+from typing import Any
+
+from plugins.wordpress.client import WordPressClient
+
+logger = logging.getLogger("mcphub.wordpress.media_probe")
+
+CACHE_TTL_SECONDS = 24 * 3600
+
+
+def get_tool_specifications() -> list[dict[str, Any]]:
+ return [
+ {
+ "name": "probe_upload_limits",
+ "method_name": "probe_upload_limits",
+ "description": (
+ "Probe a WordPress site for its effective upload limits "
+ "(upload_max_filesize, post_max_size, memory_limit, "
+ "max_input_time, wp_max_upload_size). Uses the "
+ "airano-mcp-bridge companion plugin if present, else "
+ "best-effort from the WP REST index. Cached 24 h per site."
+ ),
+ "schema": {"type": "object", "properties": {}},
+ "scope": "read",
+ }
+ ]
+
+
+@dataclass
+class _CacheEntry:
+ fetched_at: float
+ data: dict[str, Any]
+
+
+@dataclass
+class _ProbeCache:
+ """Process-local TTL cache keyed by (site_url, username)."""
+
+ ttl: float = CACHE_TTL_SECONDS
+ _entries: dict[tuple[str, str], _CacheEntry] = field(default_factory=dict)
+ _lock: asyncio.Lock = field(default_factory=asyncio.Lock)
+
+ async def get(self, key: tuple[str, str]) -> dict[str, Any] | None:
+ async with self._lock:
+ entry = self._entries.get(key)
+ if entry is None:
+ return None
+ if (time.time() - entry.fetched_at) > self.ttl:
+ self._entries.pop(key, None)
+ return None
+ return dict(entry.data)
+
+ async def set(self, key: tuple[str, str], data: dict[str, Any]) -> None:
+ async with self._lock:
+ self._entries[key] = _CacheEntry(fetched_at=time.time(), data=dict(data))
+
+ async def clear(self) -> None:
+ async with self._lock:
+ self._entries.clear()
+
+
+_cache = _ProbeCache()
+
+
+def get_probe_cache() -> _ProbeCache:
+ return _cache
+
+
+_LIMIT_KEYS = (
+ "upload_max_filesize",
+ "post_max_size",
+ "memory_limit",
+ "max_input_time",
+ "wp_max_upload_size",
+)
+
+# Byte-valued keys among `_LIMIT_KEYS` — used by F.5a.7 route selection to
+# decide whether to prefer the companion upload-chunk route over /wp/v2/media.
+_BYTE_VALUED_KEYS = ("upload_max_filesize", "post_max_size", "wp_max_upload_size")
+
+
+def _empty_limits() -> dict[str, Any]:
+ return dict.fromkeys(_LIMIT_KEYS)
+
+
+def parse_php_size(value: Any) -> int | None:
+ """Parse a PHP ``ini_get`` size string like ``"64M"`` to bytes.
+
+ Accepts an already-numeric value (returns it as-is cast to int), a suffix
+ of K/M/G/T (both upper- and lower-case), or a bare integer string.
+ Returns None if the value can't be parsed or represents "no limit" (``-1``).
+ """
+ if value is None:
+ return None
+ if isinstance(value, bool):
+ return None
+ if isinstance(value, int):
+ return None if value < 0 else value
+ if isinstance(value, float):
+ return None if value < 0 else int(value)
+ s = str(value).strip()
+ if not s:
+ return None
+ if s in {"-1", "0"}:
+ return None
+ unit = s[-1].upper()
+ multipliers = {"K": 1024, "M": 1024**2, "G": 1024**3, "T": 1024**4}
+ try:
+ if unit in multipliers:
+ return int(float(s[:-1]) * multipliers[unit])
+ return int(s)
+ except (TypeError, ValueError):
+ return None
+
+
+def effective_upload_ceiling(limits: dict[str, Any] | None) -> int | None:
+ """Return the smallest relevant size ceiling (in bytes) across the limits.
+
+ For route-selection purposes we take the min of the byte-valued keys that
+ are populated. If nothing is populated, returns None (= "unknown, treat
+ every upload as small enough for the REST route").
+ """
+ if not limits:
+ return None
+ parsed: list[int] = []
+ for key in _BYTE_VALUED_KEYS:
+ raw = limits.get(key)
+ got = parse_php_size(raw)
+ if got is not None:
+ parsed.append(got)
+ return min(parsed) if parsed else None
+
+
+class ProbeHandler:
+ """Read-only probe of a WordPress site's upload limits."""
+
+ def __init__(self, client: WordPressClient, *, cache: _ProbeCache | None = None) -> None:
+ self.client = client
+ self._cache = cache or _cache
+
+ @property
+ def _cache_key(self) -> tuple[str, str]:
+ return (self.client.site_url, self.client.username)
+
+ async def probe_upload_limits(self) -> str:
+ cached = await self._cache.get(self._cache_key)
+ if cached is not None:
+ cached["cached"] = True
+ return json.dumps(cached, indent=2)
+
+ result = await self._fetch_limits()
+ await self._cache.set(self._cache_key, result)
+ result_out = dict(result)
+ result_out["cached"] = False
+ return json.dumps(result_out, indent=2)
+
+ async def _fetch_limits(self) -> dict[str, Any]:
+ limits = _empty_limits()
+ source = "unknown"
+ # Companion plugin first.
+ try:
+ payload = await self.client.get(
+ "airano-mcp/v1/upload-limits",
+ use_custom_namespace=True,
+ )
+ if isinstance(payload, dict):
+ for k in _LIMIT_KEYS:
+ if payload.get(k) is not None:
+ limits[k] = payload[k]
+ source = "companion"
+ except Exception as exc: # noqa: BLE001
+ logger.debug("Companion probe failed (%s); falling back to REST index.", exc)
+
+ # Fallback / supplement: REST index may expose wp_max_upload_size.
+ if all(v is None for v in limits.values()):
+ try:
+ root = await self.client.get("", use_custom_namespace=True) # /wp-json/
+ if isinstance(root, dict):
+ if "wp_max_upload_size" in root:
+ limits["wp_max_upload_size"] = root["wp_max_upload_size"]
+ source = "rest_index"
+ except Exception as exc: # noqa: BLE001
+ logger.debug("REST index probe failed: %s", exc)
+
+ # F.5a.7: expose byte-parsed ceiling so _media_core can pick the
+ # best upload route without re-parsing PHP ini strings.
+ ceiling = effective_upload_ceiling(limits)
+ return {
+ "site_url": self.client.site_url,
+ "source": source,
+ "companion_available": source == "companion",
+ "limits": limits,
+ "limits_bytes": {
+ "upload_max_filesize": parse_php_size(limits.get("upload_max_filesize")),
+ "post_max_size": parse_php_size(limits.get("post_max_size")),
+ "wp_max_upload_size": parse_php_size(limits.get("wp_max_upload_size")),
+ "effective_ceiling": ceiling,
+ },
+ }
+
+
+async def get_cached_limits(
+ client: WordPressClient, *, cache: _ProbeCache | None = None
+) -> dict[str, Any] | None:
+ """Return the cached limits dict for ``client`` without forcing a probe.
+
+ Used by `_media_core.wp_raw_upload` to decide whether to prefer the
+ companion upload-chunk route. Returns None when no probe has been cached
+ yet — callers treat that as "no hints available; take the standard path".
+ """
+ c = cache or _cache
+ key = (client.site_url, client.username)
+ return await c.get(key)
diff --git a/plugins/wordpress/handlers/posts.py b/plugins/wordpress/handlers/posts.py
index ef7438a..9104097 100644
--- a/plugins/wordpress/handlers/posts.py
+++ b/plugins/wordpress/handlers/posts.py
@@ -565,60 +565,89 @@ class PostsHandler:
Args:
post_id: Post ID to retrieve
- fields: Comma-separated list of fields to return (e.g., 'id,title,status')
+ fields: Comma-separated list of fields to return (e.g., 'id,title,status').
+ When set, behaves as a STRICT allow-list — only the
+ requested names (plus ``id``) are returned. Unknown
+ names are ignored.
Returns:
- JSON string with post data
+ JSON string with post data. Default projection includes
+ ``featured_media`` (int or 0), ``slug`` (str), and
+ ``featured_media_url`` (derived from
+ ``_embedded['wp:featuredmedia'][0].source_url``; empty
+ string when no featured image is set) so callers can
+ verify a post's featured image was set without a separate
+ media-fetch round trip.
"""
try:
+ # F.X.fix #4: default projection includes the fields every
+ # caller asked for during F.X.test (featured_media, slug,
+ # featured_media_url). When ``fields`` is supplied we honour
+ # it as a STRICT allow-list — callers must opt in to each
+ # name explicitly, no hidden always-on subset beyond id.
params = {"_embed": "true"}
+ field_map = {
+ "id": "id",
+ "title": "title",
+ "content": "content",
+ "excerpt": "excerpt",
+ "status": "status",
+ "date": "date",
+ "modified": "modified",
+ "author": "_embedded",
+ "categories": "categories",
+ "tags": "tags",
+ "link": "link",
+ "slug": "slug",
+ "featured_media": "featured_media",
+ "featured_media_url": "_embedded",
+ "word_count": "content",
+ }
if fields:
- # Map our field names to WordPress API _fields
- wp_fields = set()
- requested = {f.strip().lower() for f in fields.split(",")}
- field_map = {
- "id": "id",
- "title": "title",
- "content": "content",
- "excerpt": "excerpt",
- "status": "status",
- "date": "date",
- "modified": "modified",
- "author": "_embedded",
- "categories": "categories",
- "tags": "tags",
- "link": "link",
- "slug": "slug",
- }
+ wp_fields: set[str] = set()
+ requested = {f.strip().lower() for f in fields.split(",") if f.strip()}
for f in requested:
if f in field_map:
wp_fields.add(field_map[f])
- # Always include id and title for basic identification
- wp_fields.update({"id", "title"})
+ wp_fields.add("id")
params["_fields"] = ",".join(wp_fields)
post = await self.client.get(f"posts/{post_id}", params=params)
+ # Derive featured_media_url from the embedded media entry.
+ # WP returns the embedded resource under
+ # ``_embedded['wp:featuredmedia']`` (hyphenated key, list of
+ # one). Empty string when no featured image is attached.
+ featured_media_url = ""
+ embedded = post.get("_embedded") or {}
+ media_entries = embedded.get("wp:featuredmedia") or []
+ if isinstance(media_entries, list) and media_entries:
+ first = media_entries[0] or {}
+ if isinstance(first, dict):
+ featured_media_url = first.get("source_url") or ""
+
# Build full result
full_result = {
"id": post["id"],
"title": post.get("title", {}).get("rendered", ""),
+ "slug": post.get("slug", ""),
"content": post.get("content", {}).get("rendered", ""),
"excerpt": post.get("excerpt", {}).get("rendered", ""),
"status": post.get("status", ""),
"date": post.get("date", ""),
"modified": post.get("modified", ""),
- "author": post.get("_embedded", {}).get("author", [{}])[0].get("name", "Unknown"),
+ "author": embedded.get("author", [{}])[0].get("name", "Unknown"),
"categories": post.get("categories", []),
"tags": post.get("tags", []),
"link": post.get("link", ""),
+ "featured_media": post.get("featured_media", 0) or 0,
+ "featured_media_url": featured_media_url,
"word_count": _count_words(post.get("content", {}).get("rendered", "")),
}
- # Filter to requested fields only
+ # Strict allow-list filtering when ``fields`` is provided.
if fields:
- requested = {f.strip().lower() for f in fields.split(",")}
- # Always include id
+ requested = {f.strip().lower() for f in fields.split(",") if f.strip()}
requested.add("id")
result = {k: v for k, v in full_result.items() if k in requested}
else:
diff --git a/plugins/wordpress/handlers/products.py b/plugins/wordpress/handlers/products.py
index 2ae8a22..f448db7 100644
--- a/plugins/wordpress/handlers/products.py
+++ b/plugins/wordpress/handlers/products.py
@@ -255,6 +255,18 @@ def get_tool_specifications() -> list[dict[str, Any]]:
"items": {
"type": "object",
"properties": {
+ "id": {
+ "type": "integer",
+ "minimum": 1,
+ "description": (
+ "Optional. Pass the global attribute "
+ "id from list_product_attributes to "
+ "link this product to an existing "
+ "global attribute (e.g. pa_color). "
+ "Omit (or set 0) to create an inline "
+ "custom attribute on this product only."
+ ),
+ },
"name": {"type": "string"},
"options": {"type": "array", "items": {"type": "string"}},
"visible": {"type": "boolean"},
@@ -264,7 +276,12 @@ def get_tool_specifications() -> list[dict[str, Any]]:
},
{"type": "null"},
],
- "description": 'Product attributes for variable products (e.g., [{"name": "Color", "options": ["Red", "Blue"], "variation": true}])',
+ "description": (
+ "Product attributes for variable products. "
+ 'Custom (per-product): [{"name": "Color", "options": ["Red", "Blue"], "variation": true}]. '
+ 'Global (linked, recommended): [{"id": 1, "options": ["Red", "Blue"], "variation": true}] — '
+ "use list_product_attributes to find existing ids."
+ ),
},
},
"required": ["name"],
diff --git a/plugins/wordpress/handlers/regenerate_thumbnails.py b/plugins/wordpress/handlers/regenerate_thumbnails.py
new file mode 100644
index 0000000..b9aaae5
--- /dev/null
+++ b/plugins/wordpress/handlers/regenerate_thumbnails.py
@@ -0,0 +1,220 @@
+"""F.5a.8.2 — Regenerate attachment thumbnails via companion plugin.
+
+Wraps ``POST /airano-mcp/v1/regenerate-thumbnails`` (companion plugin v2.8.0+).
+Rebuilds the registered WP image sub-sizes via ``wp_generate_attachment_metadata``
+after an upload, a format conversion (F.5a.8.1), or when new sizes are
+registered by the active theme. Two modes:
+
+1. ``ids=[...]`` — regenerate a specific list (up to 50 per call).
+2. ``all=True`` — iterate over ``image/*`` attachments in ID order with
+ ``offset``/``limit`` paging.
+
+Tool: ``wordpress_regenerate_thumbnails``.
+"""
+
+from __future__ import annotations
+
+import json
+import logging
+from typing import Any
+
+from plugins.wordpress.client import WordPressClient
+from plugins.wordpress.handlers._companion_hint import (
+ companion_install_hint as _companion_install_hint,
+)
+
+logger = logging.getLogger("mcphub.wordpress.regenerate_thumbnails")
+
+_MAX_IDS_PER_CALL = 50
+
+
+def get_tool_specifications() -> list[dict[str, Any]]:
+ return [
+ {
+ "name": "regenerate_thumbnails",
+ "method_name": "regenerate_thumbnails",
+ "description": (
+ "Rebuild attachment sub-sizes (the registered WP image sizes "
+ "plus any from add_image_size() in the active theme). Use "
+ "this after upload_media_from_url / _from_base64 with "
+ "convert_to=webp|avif, after a theme switch that adds new "
+ "sizes, or for legacy attachments missing thumbnails. Routes "
+ "through the airano-mcp-bridge companion plugin v2.8.0+. "
+ "Body shapes: either an 'ids' list to target specific "
+ f"attachments (max {_MAX_IDS_PER_CALL} per call); or "
+ "all=true with offset/limit for paged batch. Returns "
+ "has_more + next_offset in batch mode so callers can continue."
+ ),
+ "schema": {
+ "type": "object",
+ "properties": {
+ "ids": {
+ "anyOf": [
+ {
+ "type": "array",
+ "items": {"type": "integer", "minimum": 1},
+ "maxItems": _MAX_IDS_PER_CALL,
+ },
+ {"type": "null"},
+ ],
+ "description": (
+ f"Attachment IDs (max {_MAX_IDS_PER_CALL}). "
+ "Mutually exclusive with 'all'."
+ ),
+ },
+ "all": {
+ "type": "boolean",
+ "default": False,
+ "description": (
+ "Batch mode: iterate image attachments in ID order. "
+ "Use with offset/limit for pagination."
+ ),
+ },
+ "offset": {
+ "type": "integer",
+ "minimum": 0,
+ "default": 0,
+ "description": "Batch offset (for 'all' mode).",
+ },
+ "limit": {
+ "type": "integer",
+ "minimum": 1,
+ "maximum": _MAX_IDS_PER_CALL,
+ "default": _MAX_IDS_PER_CALL,
+ "description": (
+ f"Max attachments per batch page (capped at "
+ f"{_MAX_IDS_PER_CALL} server-side)."
+ ),
+ },
+ },
+ },
+ "scope": "write",
+ }
+ ]
+
+
+class RegenerateThumbnailsHandler:
+ """Rebuild attachment sub-sizes via the companion plugin."""
+
+ def __init__(self, client: WordPressClient) -> None:
+ self.client = client
+
+ async def regenerate_thumbnails(
+ self,
+ ids: list[int] | None = None,
+ all: bool = False,
+ offset: int = 0,
+ limit: int = _MAX_IDS_PER_CALL,
+ ) -> str:
+ """Proxy to ``POST /airano-mcp/v1/regenerate-thumbnails``.
+
+ Exactly one of ``ids`` or ``all`` must be truthy. Input is validated
+ client-side so obvious errors don't consume a round-trip.
+ """
+ if not ids and not all:
+ return json.dumps(
+ {
+ "ok": False,
+ "error": "invalid_request",
+ "message": (
+ "Provide either 'ids' (list of attachment IDs) or "
+ "'all': true for batch mode."
+ ),
+ },
+ indent=2,
+ )
+
+ if ids and all:
+ return json.dumps(
+ {
+ "ok": False,
+ "error": "invalid_request",
+ "message": "'ids' and 'all' are mutually exclusive.",
+ },
+ indent=2,
+ )
+
+ body: dict[str, Any] = {}
+ if ids:
+ # Deduplicate + cap + coerce.
+ uniq: list[int] = []
+ seen: set[int] = set()
+ for raw in ids:
+ try:
+ i = int(raw)
+ except (TypeError, ValueError):
+ continue
+ if i > 0 and i not in seen:
+ uniq.append(i)
+ seen.add(i)
+ body["ids"] = uniq[:_MAX_IDS_PER_CALL]
+ if not body["ids"]:
+ return json.dumps(
+ {
+ "ok": False,
+ "error": "invalid_request",
+ "message": "'ids' must contain at least one positive integer.",
+ },
+ indent=2,
+ )
+ else:
+ body["all"] = True
+ body["offset"] = max(0, int(offset))
+ body["limit"] = min(_MAX_IDS_PER_CALL, max(1, int(limit)))
+
+ try:
+ payload = await self.client.post(
+ "airano-mcp/v1/regenerate-thumbnails",
+ json_data=body,
+ use_custom_namespace=True,
+ )
+ except Exception as exc: # noqa: BLE001
+ logger.error("regenerate_thumbnails companion call failed: %s", exc)
+ return json.dumps(
+ {
+ "ok": False,
+ "error": "companion_unreachable",
+ "message": str(exc),
+ "hint": (
+ "Requires airano-mcp-bridge companion plugin v2.8.0+. "
+ "Run wordpress_probe_capabilities to verify the route "
+ "is advertised."
+ ),
+ "install_hint": _companion_install_hint(
+ min_version="2.8.0",
+ required_capability="upload_files",
+ route="airano-mcp/v1/regenerate-thumbnails",
+ ),
+ },
+ indent=2,
+ )
+
+ if not isinstance(payload, dict):
+ return json.dumps(
+ {
+ "ok": False,
+ "error": "invalid_response",
+ "message": "companion returned a non-object payload",
+ },
+ indent=2,
+ )
+
+ result: dict[str, Any] = {
+ "ok": bool(
+ (payload.get("processed") or 0) > 0
+ or (not payload.get("errors") and not payload.get("attempted"))
+ ),
+ "mode": payload.get("mode"),
+ "attempted": payload.get("attempted", 0),
+ "processed": payload.get("processed", 0),
+ "skipped": list(payload.get("skipped") or []),
+ "errors": list(payload.get("errors") or []),
+ "plugin_version": payload.get("plugin_version"),
+ }
+ if payload.get("mode") == "all":
+ result["offset"] = payload.get("offset")
+ result["limit"] = payload.get("limit")
+ result["has_more"] = bool(payload.get("has_more"))
+ result["next_offset"] = payload.get("next_offset")
+ result["total"] = payload.get("total")
+ return json.dumps(result, indent=2)
diff --git a/plugins/wordpress/handlers/seo.py b/plugins/wordpress/handlers/seo.py
index 65da8f2..6abf46b 100644
--- a/plugins/wordpress/handlers/seo.py
+++ b/plugins/wordpress/handlers/seo.py
@@ -188,7 +188,7 @@ class SEOHandler:
# First, try to use the new health check endpoint (v1.1.0+)
try:
status_result = await self.client.get(
- "airano-mcp-seo-bridge/v1/status", use_custom_namespace=True
+ "airano-mcp-bridge/v1/status", use_custom_namespace=True
)
if status_result and isinstance(status_result, dict):
@@ -234,12 +234,17 @@ class SEOHandler:
first_item = result[0] if isinstance(result, list) and len(result) > 0 else {}
meta = first_item.get("meta", {})
- # Check for Rank Math fields
+ # Check for Rank Math fields. Note: Rank Math stores the
+ # meta title under ``rank_math_title`` (NOT
+ # ``rank_math_seo_title``). The SEO API Bridge companion
+ # plugin reads/writes the canonical key; this detection
+ # must match or the plugin goes undetected even when
+ # active.
rank_math_active = any(
key in meta
for key in [
"rank_math_focus_keyword",
- "rank_math_seo_title",
+ "rank_math_title",
"rank_math_description",
]
)
@@ -312,7 +317,12 @@ class SEOHandler:
seo_data.update(
{
"focus_keyword": meta.get("rank_math_focus_keyword", ""),
- "seo_title": meta.get("rank_math_seo_title", ""),
+ # F.X.fix #2: Rank Math's canonical meta-title key
+ # is ``rank_math_title``. The previous reader
+ # used ``rank_math_seo_title`` which does not
+ # exist in the Rank Math schema, so seo_title
+ # was always blank.
+ "seo_title": meta.get("rank_math_title", ""),
"meta_description": meta.get("rank_math_description", ""),
"additional_keywords": meta.get("rank_math_additional_keywords", ""),
"canonical_url": meta.get("rank_math_canonical_url", ""),
@@ -383,7 +393,7 @@ class SEOHandler:
try:
# Use SEO API Bridge endpoint for products (same as update_product_seo)
result = await self.client.get(
- f"airano-mcp-seo-bridge/v1/products/{product_id}/seo", use_custom_namespace=True
+ f"airano-mcp-bridge/v1/products/{product_id}/seo", use_custom_namespace=True
)
return json.dumps(result, indent=2)
@@ -452,11 +462,14 @@ class SEOHandler:
meta = {}
if seo_check.get("rank_math", {}).get("active"):
- # Use Rank Math field names
+ # Use Rank Math field names. F.X.fix #2: canonical meta
+ # title key is ``rank_math_title`` (NOT
+ # ``rank_math_seo_title``) — the latter was silently
+ # written to an unread key so seo_title never appeared.
if focus_keyword is not None:
meta["rank_math_focus_keyword"] = focus_keyword
if seo_title is not None:
- meta["rank_math_seo_title"] = seo_title
+ meta["rank_math_title"] = seo_title
if meta_description is not None:
meta["rank_math_description"] = meta_description
if additional_keywords is not None:
@@ -592,7 +605,7 @@ class SEOHandler:
# Use SEO API Bridge endpoint for products
await self.client.post(
- f"airano-mcp-seo-bridge/v1/products/{product_id}/seo",
+ f"airano-mcp-bridge/v1/products/{product_id}/seo",
json_data=data,
use_custom_namespace=True,
)
diff --git a/plugins/wordpress/handlers/site_health.py b/plugins/wordpress/handlers/site_health.py
new file mode 100644
index 0000000..475e774
--- /dev/null
+++ b/plugins/wordpress/handlers/site_health.py
@@ -0,0 +1,101 @@
+"""F.18.6 — Unified site-health snapshot via companion plugin.
+
+Wraps ``GET /airano-mcp/v1/site-health`` (companion plugin v2.6.0+).
+Returns WordPress / PHP / server / database / plugins / theme /
+writability info in a single JSON envelope. Replaces the existing
+``get_site_health`` (which called the stock ``/wp-site-health`` REST
+endpoints) with a richer single-round-trip snapshot.
+
+The legacy ``wordpress_get_site_health`` tool (site.py) is left intact;
+this adds a new ``wordpress_site_health`` tool that prefers the
+companion and gracefully reports ``companion_available: false`` when
+the plugin is missing or outdated.
+
+Tool: ``wordpress_site_health()``
+"""
+
+from __future__ import annotations
+
+import json
+import logging
+from typing import Any
+
+from plugins.wordpress.client import WordPressClient
+from plugins.wordpress.handlers._companion_hint import (
+ companion_install_hint as _companion_install_hint,
+)
+
+logger = logging.getLogger("mcphub.wordpress.site_health")
+
+
+def get_tool_specifications() -> list[dict[str, Any]]:
+ return [
+ {
+ "name": "site_health",
+ "method_name": "site_health",
+ "description": (
+ "Unified site-health snapshot via the airano-mcp-bridge "
+ "companion plugin (v2.6.0+). Single request returns WP + PHP + "
+ "MySQL versions, loaded PHP extensions, server software + disk "
+ "free, active plugins with versions, active theme, and "
+ "writability checks. Falls back to `companion_available: false` "
+ "when the plugin is missing. Requires manage_options."
+ ),
+ "schema": {"type": "object", "properties": {}},
+ "scope": "read",
+ }
+ ]
+
+
+class SiteHealthHandler:
+ """Unified site-health snapshot via companion plugin."""
+
+ def __init__(self, client: WordPressClient) -> None:
+ self.client = client
+
+ async def site_health(self) -> str:
+ try:
+ payload = await self.client.get(
+ "airano-mcp/v1/site-health",
+ use_custom_namespace=True,
+ )
+ except Exception as exc: # noqa: BLE001
+ logger.debug("site_health companion call failed: %s", exc)
+ return json.dumps(
+ {
+ "ok": False,
+ "companion_available": False,
+ "error": "companion_unreachable",
+ "message": str(exc),
+ "hint": (
+ "Requires airano-mcp-bridge companion plugin v2.6.0+ "
+ "and manage_options capability. Run "
+ "wordpress_probe_capabilities to verify. For the legacy "
+ "path use wordpress_get_site_health (stock WP endpoints)."
+ ),
+ "install_hint": _companion_install_hint(
+ min_version="2.6.0",
+ required_capability="manage_options",
+ route="airano-mcp/v1/site-health",
+ ),
+ },
+ indent=2,
+ )
+
+ if not isinstance(payload, dict):
+ return json.dumps(
+ {
+ "ok": False,
+ "companion_available": False,
+ "error": "invalid_response",
+ "message": "companion returned a non-object payload",
+ },
+ indent=2,
+ )
+
+ result = {
+ "ok": bool(payload.get("ok", True)),
+ "companion_available": True,
+ **payload,
+ }
+ return json.dumps(result, indent=2)
diff --git a/plugins/wordpress/handlers/transient_flush.py b/plugins/wordpress/handlers/transient_flush.py
new file mode 100644
index 0000000..040099b
--- /dev/null
+++ b/plugins/wordpress/handlers/transient_flush.py
@@ -0,0 +1,184 @@
+"""F.18.5 — Transient flush via companion plugin.
+
+Wraps ``POST /airano-mcp/v1/transient-flush`` (companion plugin v2.5.0+).
+Native PHP transient cleanup. Scopes:
+
+- ``expired`` (default): runs ``delete_expired_transients()``; reports
+ the number of expired rows removed.
+- ``all``: deletes every ``_transient_%`` row (both regular and
+ optionally site transients on multisite).
+- ``pattern``: shell-glob match, e.g. ``rank_math_*``.
+
+Tool: ``wordpress_transient_flush(scope="expired", pattern=None, ...)``
+"""
+
+from __future__ import annotations
+
+import json
+import logging
+from typing import Any
+
+from plugins.wordpress.client import WordPressClient
+from plugins.wordpress.handlers._companion_hint import (
+ companion_install_hint as _companion_install_hint,
+)
+
+logger = logging.getLogger("mcphub.wordpress.transient_flush")
+
+_VALID_SCOPES = ("expired", "all", "pattern")
+
+
+def get_tool_specifications() -> list[dict[str, Any]]:
+ return [
+ {
+ "name": "transient_flush",
+ "method_name": "transient_flush",
+ "description": (
+ "Flush WordPress transients via the airano-mcp-bridge "
+ "companion plugin (v2.5.0+). Scopes: 'expired' (default, "
+ "delete_expired_transients), 'all' (every transient), "
+ "'pattern' (shell glob like 'rank_math_*'). On multisite, "
+ "`include_site_transients=true` also purges "
+ "_site_transient_* rows. Requires manage_options."
+ ),
+ "schema": {
+ "type": "object",
+ "properties": {
+ "scope": {
+ "type": "string",
+ "enum": list(_VALID_SCOPES),
+ "description": "Default 'expired'.",
+ },
+ "pattern": {
+ "type": "string",
+ "description": (
+ "Shell-style glob (e.g. 'rank_math_*'). Required "
+ "when scope='pattern'."
+ ),
+ },
+ "include_site_transients": {
+ "type": "boolean",
+ "description": "Default true. Only relevant on multisite.",
+ },
+ },
+ },
+ "scope": "admin",
+ }
+ ]
+
+
+def _validate(scope: str | None, pattern: str | None) -> dict[str, Any] | None:
+ if scope is None:
+ scope = "expired"
+ if scope not in _VALID_SCOPES:
+ return {
+ "error": "invalid_scope",
+ "message": (f"scope must be one of {list(_VALID_SCOPES)}; got {scope!r}."),
+ }
+ if scope == "pattern" and not pattern:
+ return {
+ "error": "pattern_required",
+ "message": "pattern is required when scope='pattern'.",
+ }
+ return None
+
+
+class TransientFlushHandler:
+ """Native PHP transient cleanup via companion plugin."""
+
+ def __init__(self, client: WordPressClient) -> None:
+ self.client = client
+
+ async def transient_flush(
+ self,
+ scope: str | None = "expired",
+ pattern: str | None = None,
+ include_site_transients: Any = True,
+ ) -> str:
+ err = _validate(scope, pattern)
+ if err is not None:
+ return json.dumps(
+ {
+ "ok": False,
+ **err,
+ "scope": scope,
+ "pattern": pattern,
+ "deleted_count": 0,
+ "deleted_sample": [],
+ },
+ indent=2,
+ )
+
+ # Normalise include_site_transients to a real bool for the PHP side;
+ # WP's parser is lenient but the aiohttp JSON filter elsewhere in
+ # the client may drop "false"-y strings.
+ include_flag: bool
+ if isinstance(include_site_transients, bool):
+ include_flag = include_site_transients
+ elif isinstance(include_site_transients, (int, float)):
+ include_flag = bool(include_site_transients)
+ elif isinstance(include_site_transients, str):
+ include_flag = include_site_transients.strip().lower() not in {
+ "false",
+ "0",
+ "no",
+ "off",
+ "",
+ }
+ else:
+ include_flag = True
+
+ body: dict[str, Any] = {
+ "scope": scope or "expired",
+ "include_site_transients": include_flag,
+ }
+ if scope == "pattern":
+ body["pattern"] = pattern
+
+ try:
+ payload = await self.client.post(
+ "airano-mcp/v1/transient-flush",
+ json_data=body,
+ use_custom_namespace=True,
+ )
+ except Exception as exc: # noqa: BLE001
+ logger.error("transient_flush companion call failed: %s", exc)
+ return json.dumps(
+ {
+ "ok": False,
+ "error": "companion_unreachable",
+ "message": str(exc),
+ "hint": (
+ "Requires airano-mcp-bridge companion plugin v2.5.0+ "
+ "and manage_options capability. Run "
+ "wordpress_probe_capabilities to verify."
+ ),
+ "install_hint": _companion_install_hint(
+ min_version="2.5.0",
+ required_capability="manage_options",
+ route="airano-mcp/v1/transient-flush",
+ ),
+ "scope": scope,
+ "pattern": pattern,
+ "deleted_count": 0,
+ "deleted_sample": [],
+ },
+ indent=2,
+ )
+
+ if not isinstance(payload, dict):
+ return json.dumps(
+ {
+ "ok": False,
+ "error": "invalid_response",
+ "message": "companion returned a non-object payload",
+ "scope": scope,
+ "pattern": pattern,
+ "deleted_count": 0,
+ "deleted_sample": [],
+ },
+ indent=2,
+ )
+
+ result = {"ok": bool(payload.get("ok", True)), **payload}
+ return json.dumps(result, indent=2)
diff --git a/plugins/wordpress/plugin.py b/plugins/wordpress/plugin.py
index 8d638d3..12b9b52 100644
--- a/plugins/wordpress/plugin.py
+++ b/plugins/wordpress/plugin.py
@@ -65,7 +65,40 @@ class WordPressPlugin(BasePlugin):
# Initialize core WordPress handlers
self.posts = handlers.PostsHandler(self.client)
- self.media = handlers.MediaHandler(self.client)
+ self.media = handlers.MediaHandler(self.client, user_id=config.get("user_id"))
+ # F.5a.4 / F.5a.9.x: AI image handler receives user_id AND site_id.
+ # site_id is the primary input for resolving per-site provider API
+ # keys; user_id is retained for audit / rate-limit context and for
+ # the admin/env-fallback path when no site is known.
+ self.ai_media = handlers.AIMediaHandler(
+ self.client,
+ user_id=config.get("user_id"),
+ site_id=config.get("site_id"),
+ )
+ # F.5a.5: chunked media uploads (session store + disk spill)
+ self.media_chunked = handlers.MediaChunkedHandler(
+ self.client, user_id=config.get("user_id")
+ )
+ # F.5a.6.3: probe WP upload limits (24h cache)
+ self.media_probe = handlers.ProbeHandler(self.client)
+ # F.18.1: probe companion-plugin capabilities (24h cache)
+ self.capabilities = handlers.CapabilitiesHandler(self.client)
+ # F.18.2: batch post_meta writes via companion plugin
+ self.bulk_meta = handlers.BulkMetaHandler(self.client)
+ # F.18.3: structured JSON export via companion plugin
+ self.export = handlers.ExportHandler(self.client)
+ # F.18.4: cache purge via companion plugin
+ self.cache_purge_handler = handlers.CachePurgeHandler(self.client)
+ # F.18.5: native transient flush via companion plugin
+ self.transient_flush_handler = handlers.TransientFlushHandler(self.client)
+ # F.18.6: unified site-health snapshot via companion plugin
+ self.site_health_handler = handlers.SiteHealthHandler(self.client)
+ # F.18.7: audit-hook configuration + query
+ self.audit_hook_handler = handlers.AuditHookHandler(self.client)
+ # F.5a.8.2: regenerate attachment sub-sizes via companion plugin
+ self.regenerate_thumbnails_handler = handlers.RegenerateThumbnailsHandler(self.client)
+ # F.5a.8.3: bulk delete / reassign attachments via stock WP REST
+ self.media_bulk = handlers.MediaBulkHandler(self.client)
self.taxonomy = handlers.TaxonomyHandler(self.client)
self.comments = handlers.CommentsHandler(self.client)
self.users = handlers.UsersHandler(self.client)
@@ -103,7 +136,19 @@ class WordPressPlugin(BasePlugin):
# Core WordPress handlers
specs.extend(handlers.get_posts_specs()) # 13 tools
- specs.extend(handlers.get_media_specs()) # 5 tools
+ specs.extend(handlers.get_media_specs()) # 6 tools (F.5a.1: added base64 upload)
+ specs.extend(handlers.get_ai_media_specs()) # 1 tool (F.5a.4: generate+upload)
+ specs.extend(handlers.get_media_chunked_specs()) # 5 tools (F.5a.5 + F.5a.8.4 status)
+ specs.extend(handlers.get_media_probe_specs()) # 1 tool (F.5a.6.3: probe limits)
+ specs.extend(handlers.get_capabilities_specs()) # 1 tool (F.18.1: probe capabilities)
+ specs.extend(handlers.get_bulk_meta_specs()) # 1 tool (F.18.2: bulk meta write)
+ specs.extend(handlers.get_export_specs()) # 1 tool (F.18.3: structured export)
+ specs.extend(handlers.get_cache_purge_specs()) # 1 tool (F.18.4: cache purge)
+ specs.extend(handlers.get_transient_flush_specs()) # 1 tool (F.18.5: transient flush)
+ specs.extend(handlers.get_site_health_specs()) # 1 tool (F.18.6: site health)
+ specs.extend(handlers.get_audit_hook_specs()) # 3 tools (F.18.7: audit hook)
+ specs.extend(handlers.get_regenerate_thumbnails_specs()) # 1 tool (F.5a.8.2)
+ specs.extend(handlers.get_media_bulk_specs()) # 2 tools (F.5a.8.3: bulk delete+reassign)
specs.extend(handlers.get_taxonomy_specs()) # 11 tools
specs.extend(handlers.get_comments_specs()) # 5 tools
specs.extend(handlers.get_users_specs()) # 2 tools
@@ -128,6 +173,68 @@ class WordPressPlugin(BasePlugin):
"""
return await self.site.health_check()
+ async def probe_credential_capabilities(self) -> dict[str, Any]:
+ """F.7e — return the capabilities the saved app_password grants.
+
+ Delegates to the companion-plugin-backed ``CapabilitiesHandler``
+ (F.18.1), which reads ``GET /airano-mcp/v1/capabilities`` — the
+ WordPress user's effective capability map. When the companion
+ isn't installed we return an empty-granted payload marked as
+ unavailable so the caller can show a "companion plugin needed"
+ hint rather than falsely claim the key is under-privileged.
+
+ F.X.fix #3 — fast-fail on unreachable sites. When the low-level
+ client raises :class:`SiteUnreachableError` (DNS/TCP/timeout),
+ we short-circuit to a structured ``probe_available=False``
+ payload carrying the ``install_hint`` so the dashboard renders
+ the "check your URL / install companion" prompt in <10s instead
+ of hanging on the 30s total timeout.
+ """
+ from plugins.wordpress.client import SiteUnreachableError
+ from plugins.wordpress.handlers.capabilities import _empty_capabilities_payload
+
+ try:
+ payload = await self.capabilities._fetch_capabilities()
+ except SiteUnreachableError as exc:
+ out: dict[str, Any] = {
+ "probe_available": False,
+ "granted": [],
+ "source": "wordpress_companion",
+ "reason": f"site_unreachable: {exc.reason}",
+ }
+ if exc.install_hint:
+ out["install_hint"] = exc.install_hint
+ return out
+ except Exception as exc: # noqa: BLE001
+ payload = _empty_capabilities_payload(
+ self.client.site_url, reason=f"probe_failed: {exc}"
+ )
+
+ if not payload.get("companion_available"):
+ return {
+ "probe_available": False,
+ "granted": [],
+ "source": "wordpress_companion",
+ "reason": (payload.get("reason") or "companion_not_installed"),
+ }
+
+ user_caps = (payload.get("user") or {}).get("capabilities") or {}
+ granted = sorted(k for k, v in user_caps.items() if v)
+ roles = list((payload.get("user") or {}).get("roles") or [])
+ # F.X.fix-pass3 — surface routes + features so the central
+ # tool-prerequisites resolver in core/tool_access can decide
+ # which tools to auto-disable (SEO needs Rank Math/Yoast,
+ # cache_purge/etc. need the matching companion route, …).
+ return {
+ "probe_available": True,
+ "granted": granted,
+ "source": "wordpress_companion",
+ "roles": roles,
+ "plugin_version": payload.get("plugin_version"),
+ "routes": dict(payload.get("routes") or {}),
+ "features": dict(payload.get("features") or {}),
+ }
+
# ========================================
# Method Delegation to Handlers
# ========================================
@@ -187,12 +294,84 @@ class WordPressPlugin(BasePlugin):
async def upload_media_from_url(self, **kwargs):
return await self.media.upload_media_from_url(**kwargs)
+ async def upload_media_from_base64(self, **kwargs):
+ return await self.media.upload_media_from_base64(**kwargs)
+
async def update_media(self, **kwargs):
return await self.media.update_media(**kwargs)
async def delete_media(self, **kwargs):
return await self.media.delete_media(**kwargs)
+ # === AI Image Generation (F.5a.4) ===
+ async def generate_and_upload_image(self, **kwargs):
+ return await self.ai_media.generate_and_upload_image(**kwargs)
+
+ # === Chunked Media Upload (F.5a.5) ===
+ async def upload_media_chunked_start(self, **kwargs):
+ return await self.media_chunked.upload_media_chunked_start(**kwargs)
+
+ async def upload_media_chunked_chunk(self, **kwargs):
+ return await self.media_chunked.upload_media_chunked_chunk(**kwargs)
+
+ async def upload_media_chunked_finish(self, **kwargs):
+ return await self.media_chunked.upload_media_chunked_finish(**kwargs)
+
+ async def upload_media_chunked_abort(self, **kwargs):
+ return await self.media_chunked.upload_media_chunked_abort(**kwargs)
+
+ async def upload_media_chunked_status(self, **kwargs):
+ return await self.media_chunked.upload_media_chunked_status(**kwargs)
+
+ # === Probe Upload Limits (F.5a.6.3) ===
+ async def probe_upload_limits(self, **kwargs):
+ return await self.media_probe.probe_upload_limits(**kwargs)
+
+ # === Probe Companion Capabilities (F.18.1) ===
+ async def probe_capabilities(self, **kwargs):
+ return await self.capabilities.probe_capabilities(**kwargs)
+
+ # === Bulk Meta Write (F.18.2) ===
+ async def bulk_update_meta(self, **kwargs):
+ return await self.bulk_meta.bulk_update_meta(**kwargs)
+
+ # === Structured Export (F.18.3) ===
+ async def export_content(self, **kwargs):
+ return await self.export.export_content(**kwargs)
+
+ # === Cache Purge (F.18.4) ===
+ async def cache_purge(self, **kwargs):
+ return await self.cache_purge_handler.cache_purge(**kwargs)
+
+ # === Regenerate Thumbnails (F.5a.8.2) ===
+ async def regenerate_thumbnails(self, **kwargs):
+ return await self.regenerate_thumbnails_handler.regenerate_thumbnails(**kwargs)
+
+ # === Bulk Media Operations (F.5a.8.3) ===
+ async def bulk_delete_media(self, **kwargs):
+ return await self.media_bulk.bulk_delete_media(**kwargs)
+
+ async def bulk_reassign_media(self, **kwargs):
+ return await self.media_bulk.bulk_reassign_media(**kwargs)
+
+ # === Transient Flush (F.18.5) ===
+ async def transient_flush(self, **kwargs):
+ return await self.transient_flush_handler.transient_flush(**kwargs)
+
+ # === Unified Site Health (F.18.6) ===
+ async def site_health(self, **kwargs):
+ return await self.site_health_handler.site_health(**kwargs)
+
+ # === Audit Hook (F.18.7) ===
+ async def audit_hook_status(self, **kwargs):
+ return await self.audit_hook_handler.audit_hook_status(**kwargs)
+
+ async def audit_hook_configure(self, **kwargs):
+ return await self.audit_hook_handler.audit_hook_configure(**kwargs)
+
+ async def audit_hook_disable(self, **kwargs):
+ return await self.audit_hook_handler.audit_hook_disable(**kwargs)
+
# === Taxonomy (Categories & Tags) ===
async def list_categories(self, **kwargs):
return await self.taxonomy.list_categories(**kwargs)
diff --git a/pyproject.toml b/pyproject.toml
index f630cca..266f60d 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -1,6 +1,6 @@
[project]
name = "mcphub-server"
-version = "3.11.0"
+version = "3.12.0"
description = "AI-native management hub for WordPress, WooCommerce, and self-hosted services via Model Context Protocol (MCP)"
authors = [
{name = "MCP Hub", email = "contact@mcphub.dev"}
@@ -40,6 +40,8 @@ dependencies = [
"aiosqlite>=0.20.0",
"jinja2>=3.1.2",
"bcrypt>=4.0.0",
+ "Pillow>=10.2.0",
+ "python-magic>=0.4.27",
]
[project.optional-dependencies]
diff --git a/requirements.txt b/requirements.txt
index c8b4836..5c62503 100644
--- a/requirements.txt
+++ b/requirements.txt
@@ -27,3 +27,7 @@ aiosqlite>=0.20.0 # Async SQLite for user data, sites, API keys
# User API key hashing (Track E.3)
bcrypt>=4.0.0 # Secure password hashing for user API keys
+
+# Media upload (F.5a): image optimization and MIME sniffing
+Pillow>=10.2.0 # Image resize / compress / EXIF strip
+python-magic>=0.4.27 # Magic-byte MIME sniffing (requires libmagic system package)
diff --git a/server.py b/server.py
index 5a930e7..1cffbc7 100644
--- a/server.py
+++ b/server.py
@@ -57,6 +57,7 @@ from core import (
get_tool_registry,
set_api_key_context,
)
+from core.capability_probe import api_site_capabilities, api_site_capabilities_badge
from core.dashboard.routes import (
# F.7b: Per-site tool visibility
api_bulk_toggle_site_tools,
@@ -74,6 +75,11 @@ from core.dashboard.routes import (
api_save_setting,
api_scope_presets,
api_set_site_tool_scope,
+ # F.18.8: Provider Keys Dashboard UI
+ api_site_provider_default_model,
+ api_site_provider_keys_delete,
+ api_site_provider_keys_list,
+ api_site_provider_keys_set,
api_test_site,
api_update_site,
# E.2: OAuth Social Login routes
@@ -130,6 +136,7 @@ from core.i18n import detect_language, get_all_translations
# OAuth and CSRF (Phase E)
from core.oauth import get_csrf_manager
+from plugins.ai_image.providers.openrouter import api_openrouter_models
# Configure logging
LOG_LEVEL = os.getenv("LOG_LEVEL", "INFO").upper()
@@ -4743,9 +4750,17 @@ def create_multi_endpoint_app(transport: str = "streamable-http"):
if hm:
await hm.start_background_checks(interval_seconds=60)
+ # F.5a.5: reap expired chunked-upload sessions
+ from core.upload_sessions import CleanupTask as _UploadCleanupTask
+
+ upload_cleanup = _UploadCleanupTask()
+ await upload_cleanup.start()
+ logger.info("Upload-session cleanup task started")
+
try:
yield
finally:
+ await upload_cleanup.stop()
# Stop health monitor background checks
if hm:
await hm.stop_background_checks()
@@ -4772,9 +4787,17 @@ def create_multi_endpoint_app(transport: str = "streamable-http"):
# Build routes
# Note: Order matters! More specific routes first
+ from core.companion_audit import handle_companion_audit_request
+
routes = [
# Health check
Route("/health", health_check, methods=["GET"]),
+ # F.18.7: Companion plugin audit webhook receiver.
+ Route(
+ "/api/companion-audit",
+ handle_companion_audit_request,
+ methods=["POST"],
+ ),
# Root redirect
Route("/", root_redirect, methods=["GET"]),
# Auth routes (E.2: OAuth Social Login)
@@ -4787,6 +4810,9 @@ def create_multi_endpoint_app(transport: str = "streamable-http"):
Route("/dashboard/login", dashboard_login_submit, methods=["POST"]),
Route("/dashboard/logout", dashboard_logout, methods=["GET", "POST"]),
Route("/dashboard/profile", dashboard_profile_page, methods=["GET"]),
+ # F.18.8 /dashboard/provider-keys removed in F.5a.9.x — replaced by
+ # per-site AI keys in /dashboard/sites/{id}. See per-site routes
+ # registered below under "Site Management API".
Route("/dashboard/sites/add", dashboard_sites_add, methods=["GET"]),
Route("/dashboard/sites/{id}/edit", dashboard_sites_edit, methods=["GET"]),
Route("/dashboard/sites/{id}", dashboard_sites_view, methods=["GET"]),
@@ -4874,6 +4900,46 @@ def create_multi_endpoint_app(transport: str = "streamable-http"):
Route("/api/sites/{id}/test", api_test_site, methods=["POST"]),
Route("/api/sites/{id}", api_delete_site, methods=["DELETE"]),
Route("/api/sites/{id}", api_update_site, methods=["PATCH"]),
+ # F.5a.9.x: per-site AI provider keys
+ Route(
+ "/api/sites/{id}/provider-keys",
+ api_site_provider_keys_list,
+ methods=["GET"],
+ ),
+ # F.7e: credential capability probe
+ Route(
+ "/api/sites/{id}/capabilities",
+ api_site_capabilities,
+ methods=["GET"],
+ ),
+ # F.X.fix #9: HTMX partial for the capability badge
+ Route(
+ "/api/sites/{id}/capabilities/badge",
+ api_site_capabilities_badge,
+ methods=["GET"],
+ ),
+ # F.X.fix #12: OpenRouter image-model discovery
+ Route(
+ "/api/providers/openrouter/models",
+ api_openrouter_models,
+ methods=["GET"],
+ ),
+ Route(
+ "/api/sites/{id}/provider-keys/{provider}",
+ api_site_provider_keys_set,
+ methods=["POST"],
+ ),
+ Route(
+ "/api/sites/{id}/provider-keys/{provider}",
+ api_site_provider_keys_delete,
+ methods=["DELETE"],
+ ),
+ # F.X.fix-pass3: per-site default image model
+ Route(
+ "/api/sites/{id}/provider-keys/{provider}/default-model",
+ api_site_provider_default_model,
+ methods=["PATCH"],
+ ),
# User API Key routes (E.3)
Route("/api/keys", api_list_keys, methods=["GET"]),
Route("/api/keys", api_create_key, methods=["POST"]),
diff --git a/tests/plugins/wordpress/test_ai_media.py b/tests/plugins/wordpress/test_ai_media.py
new file mode 100644
index 0000000..438a1b5
--- /dev/null
+++ b/tests/plugins/wordpress/test_ai_media.py
@@ -0,0 +1,387 @@
+"""Tests for F.5a.4 AI image generation + upload chain."""
+
+from __future__ import annotations
+
+import base64
+import json
+from unittest.mock import AsyncMock, patch
+
+import pytest
+
+from core.database import Database
+from plugins.ai_image.providers.base import (
+ GenerationRequest,
+ GenerationResult,
+ ProviderError,
+)
+from plugins.ai_image.providers.openai import OpenAIProvider
+from plugins.ai_image.registry import get_provider, list_providers
+from plugins.wordpress.client import WordPressClient
+from plugins.wordpress.handlers.ai_media import AIMediaHandler
+
+_TEST_KEY_B64 = base64.b64encode(b"0" * 32).decode()
+
+# 1x1 PNG
+_PNG_1x1 = base64.b64decode(
+ b"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNkYAAAAAYAAjCB0C8AAAAASUVORK5CYII="
+)
+
+
+@pytest.fixture
+def wp_client():
+ return WordPressClient(site_url="https://wp.example.com", username="u", app_password="p")
+
+
+@pytest.fixture
+async def db(tmp_path):
+ database = Database(str(tmp_path / "ai_test.db"))
+ await database.initialize()
+ yield database
+ await database.close()
+
+
+@pytest.fixture
+async def user_row(db):
+ return await db.create_user(
+ email="alice@example.com",
+ name="Alice",
+ provider="github",
+ provider_id="gh-ai",
+ )
+
+
+# --- Registry ---------------------------------------------------------------
+
+
+class TestRegistry:
+ def test_lists_expected_providers(self):
+ # Registry is the source of truth now that the per-user
+ # user_provider_keys module has been removed (F.5a.9.x).
+ # F.5a.9 partial: OpenRouter added.
+ assert set(list_providers()) == {"openai", "stability", "replicate", "openrouter"}
+
+ def test_get_provider_known(self):
+ assert get_provider("openai").name == "openai"
+
+ def test_get_provider_unknown_raises(self):
+ with pytest.raises(ProviderError) as e:
+ get_provider("midjourney")
+ assert e.value.code == "PROVIDER_UNKNOWN"
+
+
+# Per-user UserProviderKeyManager removed in F.5a.9.x. Per-site equivalents
+# live in ``tests/test_site_provider_keys.py``.
+
+
+# --- OpenAI provider --------------------------------------------------------
+
+
+def _mock_session_post(responses: list):
+ """Build a fake aiohttp.ClientSession whose .post() cycles through responses."""
+ iterator = iter(responses)
+
+ def _post(*_args, **_kwargs):
+ resp = next(iterator)
+ return AsyncMock(
+ __aenter__=AsyncMock(return_value=resp),
+ __aexit__=AsyncMock(return_value=False),
+ )
+
+ sess = AsyncMock()
+ sess.post = _post
+ sess.get = _post # reuse for URL fetches
+ return sess
+
+
+def _fake_resp(*, status: int, json_data=None, text_data=None, raw=None):
+ resp = AsyncMock()
+ resp.status = status
+ resp.text = AsyncMock(return_value=text_data or (json.dumps(json_data) if json_data else ""))
+ if json_data is not None:
+ resp.json = AsyncMock(return_value=json_data)
+ if raw is not None:
+ resp.read = AsyncMock(return_value=raw)
+ return resp
+
+
+class TestOpenAIProvider:
+ @pytest.mark.asyncio
+ async def test_dalle_url_fetched_and_returned_as_bytes(self):
+ provider = OpenAIProvider()
+ generate_resp = _fake_resp(
+ status=200,
+ json_data={
+ "data": [
+ {
+ "url": "https://oaidalle.example.com/img/abc.png",
+ "revised_prompt": "revised",
+ }
+ ]
+ },
+ )
+ fetch_resp = _fake_resp(status=200, raw=_PNG_1x1)
+ sess = _mock_session_post([generate_resp, fetch_resp])
+ with patch("plugins.ai_image.providers.openai.aiohttp.ClientSession") as cls:
+ cls.return_value = AsyncMock(
+ __aenter__=AsyncMock(return_value=sess),
+ __aexit__=AsyncMock(return_value=False),
+ )
+ result = await provider.generate(
+ "sk-test",
+ GenerationRequest(prompt="a cube", size="1024x1024"),
+ )
+ assert result.data == _PNG_1x1
+ assert result.mime == "image/png"
+ assert result.meta.get("revised_prompt") == "revised"
+ assert result.cost_usd == 0.040
+
+ @pytest.mark.asyncio
+ async def test_missing_key_raises_auth(self):
+ provider = OpenAIProvider()
+ with pytest.raises(ProviderError) as e:
+ await provider.generate("", GenerationRequest(prompt="x"))
+ assert e.value.code == "PROVIDER_AUTH"
+
+ @pytest.mark.asyncio
+ async def test_429_retried_then_succeeds(self):
+ provider = OpenAIProvider()
+ r429 = _fake_resp(status=429, text_data="slow down")
+ ok = _fake_resp(
+ status=200,
+ json_data={"data": [{"b64_json": base64.b64encode(_PNG_1x1).decode()}]},
+ )
+ sess = _mock_session_post([r429, ok])
+ with patch("plugins.ai_image.providers.openai.aiohttp.ClientSession") as cls:
+ cls.return_value = AsyncMock(
+ __aenter__=AsyncMock(return_value=sess),
+ __aexit__=AsyncMock(return_value=False),
+ )
+ with patch("plugins.ai_image.providers.openai.asyncio.sleep", AsyncMock()):
+ result = await provider.generate(
+ "sk-test",
+ GenerationRequest(prompt="x", model="gpt-image-1"),
+ )
+ assert result.data == _PNG_1x1
+
+ @pytest.mark.asyncio
+ async def test_persistent_5xx_becomes_unavailable(self):
+ provider = OpenAIProvider()
+ r500 = _fake_resp(status=500, text_data="boom")
+ sess = _mock_session_post([r500, r500, r500])
+ with patch("plugins.ai_image.providers.openai.aiohttp.ClientSession") as cls:
+ cls.return_value = AsyncMock(
+ __aenter__=AsyncMock(return_value=sess),
+ __aexit__=AsyncMock(return_value=False),
+ )
+ with patch("plugins.ai_image.providers.openai.asyncio.sleep", AsyncMock()):
+ with pytest.raises(ProviderError) as e:
+ await provider.generate("sk", GenerationRequest(prompt="x"))
+ assert e.value.code == "PROVIDER_UNAVAILABLE"
+
+
+# --- AIMediaHandler ---------------------------------------------------------
+
+
+class TestAIMediaHandler:
+ @pytest.mark.asyncio
+ async def test_missing_api_key_returns_typed_error(self, wp_client, monkeypatch):
+ monkeypatch.delenv("OPENAI_API_KEY", raising=False)
+ handler = AIMediaHandler(wp_client, user_id=None)
+
+ async def _noop(*_a, **_kw):
+ return None
+
+ # Force the key resolver to return None without needing a real DB.
+ monkeypatch.setattr(handler, "_resolve_api_key", AsyncMock(return_value=None))
+ out = await handler.generate_and_upload_image(provider="openai", prompt="x")
+ data = json.loads(out)
+ assert data["error_code"] == "NO_PROVIDER_KEY"
+
+ @pytest.mark.asyncio
+ async def test_happy_path_chains_to_upload(self, wp_client, monkeypatch):
+ handler = AIMediaHandler(wp_client, user_id="u1")
+ monkeypatch.setattr(handler, "_resolve_api_key", AsyncMock(return_value="sk"))
+
+ fake_result = GenerationResult(
+ data=_PNG_1x1,
+ mime="image/png",
+ filename="ai.png",
+ meta={"model": "dall-e-3"},
+ cost_usd=0.04,
+ )
+ monkeypatch.setattr(
+ "plugins.wordpress.handlers.ai_media.get_provider",
+ lambda name: _StubProvider(fake_result),
+ )
+ upload_mock = AsyncMock(
+ return_value={
+ "id": 42,
+ "title": {"rendered": "ai"},
+ "source_url": "https://wp.example.com/ai.png",
+ "mime_type": "image/png",
+ "media_type": "image",
+ }
+ )
+ monkeypatch.setattr("plugins.wordpress.handlers.ai_media.wp_raw_upload", upload_mock)
+
+ # Disable optimize (skip_optimize=True skips Pillow and keeps test hermetic).
+ out = await handler.generate_and_upload_image(
+ provider="openai", prompt="x", skip_optimize=True
+ )
+ data = json.loads(out)
+ assert data["id"] == 42
+ assert data["provider"] == "openai"
+ assert data["cost_usd"] == 0.04
+ assert upload_mock.await_count == 1
+
+ @pytest.mark.asyncio
+ async def test_provider_error_surfaced_with_code(self, wp_client, monkeypatch):
+ handler = AIMediaHandler(wp_client, user_id="u1")
+ monkeypatch.setattr(handler, "_resolve_api_key", AsyncMock(return_value="sk"))
+ monkeypatch.setattr(
+ "plugins.wordpress.handlers.ai_media.get_provider",
+ lambda name: _StubProvider(exc=ProviderError("PROVIDER_QUOTA", "rate")),
+ )
+ out = await handler.generate_and_upload_image(provider="openai", prompt="x")
+ data = json.loads(out)
+ assert data["error_code"] == "PROVIDER_QUOTA"
+
+ @pytest.mark.asyncio
+ async def test_audit_entry_emitted_on_success(self, wp_client, monkeypatch):
+ handler = AIMediaHandler(wp_client, user_id="u1")
+ monkeypatch.setattr(handler, "_resolve_api_key", AsyncMock(return_value="sk"))
+ monkeypatch.setattr(
+ "plugins.wordpress.handlers.ai_media.get_provider",
+ lambda name: _StubProvider(
+ GenerationResult(
+ data=_PNG_1x1,
+ mime="image/png",
+ filename="ai.png",
+ meta={"model": "dall-e-3"},
+ cost_usd=0.04,
+ )
+ ),
+ )
+ monkeypatch.setattr(
+ "plugins.wordpress.handlers.ai_media.wp_raw_upload",
+ AsyncMock(
+ return_value={
+ "id": 7,
+ "title": {"rendered": ""},
+ "source_url": "https://x/img.png",
+ "mime_type": "image/png",
+ "media_type": "image",
+ }
+ ),
+ )
+
+ calls: list[dict] = []
+
+ class _FakeAudit:
+ def log_tool_call(self, **kwargs):
+ calls.append(kwargs)
+
+ monkeypatch.setattr(
+ "core.audit_log.get_audit_logger",
+ lambda: _FakeAudit(),
+ )
+
+ await handler.generate_and_upload_image(provider="openai", prompt="x", skip_optimize=True)
+
+ # F.5a.6.4: AI uploads emit BOTH the legacy provider-cost entry and a
+ # unified media.upload entry. Find each by tool_name.
+ ai_call = next(c for c in calls if c["tool_name"] == "wordpress_generate_and_upload_image")
+ assert ai_call["params"]["provider"] == "openai"
+ assert ai_call["params"]["cost_usd"] == 0.04
+ assert ai_call["user_id"] == "u1"
+
+ media_call = next(c for c in calls if c["tool_name"] == "media.upload")
+ assert media_call["params"]["source"] == "ai:openai"
+ assert media_call["params"]["cost_usd"] == 0.04
+ assert media_call["params"]["media_id"] == 7
+
+
+class TestResolverSiteScope:
+ """F.5a.9.x: ``_resolve_api_key`` reads from the per-site key store when
+ ``site_id`` is set, and falls back to env vars only when it is None."""
+
+ @pytest.mark.asyncio
+ async def test_site_key_wins_over_env(self, wp_client, monkeypatch):
+ """With site_id set, the per-site key is returned even if env has one."""
+ monkeypatch.setenv("OPENAI_API_KEY", "sk-env-should-not-be-used")
+
+ async def _fake_get(site_id, provider):
+ assert site_id == "site-abc"
+ assert provider == "openai"
+ return "sk-site-win"
+
+ monkeypatch.setattr("core.site_api.get_site_provider_key", _fake_get)
+
+ handler = AIMediaHandler(wp_client, user_id="u1", site_id="site-abc")
+ assert await handler._resolve_api_key("openai") == "sk-site-win"
+
+ @pytest.mark.asyncio
+ async def test_site_set_but_no_key_returns_none(self, wp_client, monkeypatch):
+ """site_id set + no site key stored + env present → still None.
+ Env fallback is deliberately not applied on the per-site path."""
+ monkeypatch.setenv("OPENAI_API_KEY", "sk-env-ignored")
+
+ async def _fake_get(site_id, provider):
+ return None
+
+ monkeypatch.setattr("core.site_api.get_site_provider_key", _fake_get)
+
+ handler = AIMediaHandler(wp_client, user_id="u1", site_id="site-abc")
+ assert await handler._resolve_api_key("openai") is None
+
+ @pytest.mark.asyncio
+ async def test_no_site_id_falls_back_to_env(self, wp_client, monkeypatch):
+ """site_id=None (admin / master-key path): env var is used."""
+ monkeypatch.setenv("OPENAI_API_KEY", "sk-admin-env")
+
+ handler = AIMediaHandler(wp_client, user_id=None, site_id=None)
+ assert await handler._resolve_api_key("openai") == "sk-admin-env"
+
+ @pytest.mark.asyncio
+ async def test_no_site_id_no_env_returns_none(self, wp_client, monkeypatch):
+ monkeypatch.delenv("OPENAI_API_KEY", raising=False)
+ handler = AIMediaHandler(wp_client, user_id=None, site_id=None)
+ assert await handler._resolve_api_key("openai") is None
+
+ @pytest.mark.asyncio
+ async def test_missing_key_error_points_to_site_dashboard(self, wp_client, monkeypatch):
+ """When site_id is set and no key, error message points at the site
+ edit page, not the deprecated /dashboard/provider-keys page."""
+
+ async def _fake_get(site_id, provider):
+ return None
+
+ monkeypatch.setattr("core.site_api.get_site_provider_key", _fake_get)
+ monkeypatch.delenv("OPENAI_API_KEY", raising=False)
+
+ handler = AIMediaHandler(wp_client, user_id="u1", site_id="site-xyz")
+ out = await handler.generate_and_upload_image(provider="openai", prompt="x")
+ data = json.loads(out)
+ assert data["error_code"] == "NO_PROVIDER_KEY"
+ assert data["dashboard_url"] == "/dashboard/sites/site-xyz"
+ assert "/dashboard/provider-keys" not in json.dumps(data)
+
+
+class _StubProvider:
+ """Minimal provider stub used by AIMediaHandler tests."""
+
+ name = "stub"
+
+ def __init__(
+ self,
+ result: GenerationResult | None = None,
+ exc: ProviderError | None = None,
+ ):
+ self._result = result
+ self._exc = exc
+
+ async def generate(self, api_key: str, request: GenerationRequest) -> GenerationResult:
+ if self._exc is not None:
+ raise self._exc
+ assert self._result is not None
+ return self._result
diff --git a/tests/plugins/wordpress/test_ai_media_idempotency.py b/tests/plugins/wordpress/test_ai_media_idempotency.py
new file mode 100644
index 0000000..ecfdc2e
--- /dev/null
+++ b/tests/plugins/wordpress/test_ai_media_idempotency.py
@@ -0,0 +1,188 @@
+"""F.X.fix #7 — AI image upload sends Idempotency-Key to the companion.
+
+Regression: when the MCP client timed out mid-call the companion had
+already created the attachment. The client retried and the companion
+created a second ``-2.webp`` orphan, leaving admins to clean up by
+hand. Fix: generate a stable key per logical call, send it as an
+``Idempotency-Key`` header, and let the companion dedupe.
+
+These tests cover the Python side — the key is deterministic for the
+same inputs, differs when any input changes, and is actually put on
+the wire when ``_companion_upload_and_attach`` fires.
+"""
+
+from __future__ import annotations
+
+from unittest.mock import AsyncMock, patch
+
+import pytest
+
+from plugins.wordpress.client import WordPressClient
+from plugins.wordpress.handlers import _media_core
+from plugins.wordpress.handlers.ai_media import (
+ _content_sha,
+ _idempotency_key_for,
+)
+
+
+class TestIdempotencyKeyBuilder:
+ def test_matches_companion_regex(self):
+ key = _idempotency_key_for(
+ provider="openrouter",
+ model="google/gemini-2.5-flash-image",
+ prompt="a cat",
+ size="1024x1024",
+ attach_to_post=42,
+ set_featured=True,
+ site_url="https://blog.example.com",
+ user_id="user-1",
+ content_sha=_content_sha(b"bytes"),
+ )
+ # Companion PHP validates ^[A-Za-z0-9_-]{1,128}$ before use.
+ import re
+
+ assert re.match(r"^[A-Za-z0-9_\-]{1,128}$", key)
+ assert 1 <= len(key) <= 128
+
+ def test_deterministic_for_same_inputs(self):
+ kwargs = {
+ "provider": "openrouter",
+ "model": "google/gemini-2.5-flash-image",
+ "prompt": "a cat",
+ "size": "1024x1024",
+ "attach_to_post": 42,
+ "set_featured": True,
+ "site_url": "https://blog.example.com",
+ "user_id": "user-1",
+ "content_sha": "abc123",
+ }
+ assert _idempotency_key_for(**kwargs) == _idempotency_key_for(**kwargs)
+
+ def test_differs_when_prompt_changes(self):
+ base = {
+ "provider": "openrouter",
+ "model": "m",
+ "prompt": "cat",
+ "size": "1024x1024",
+ "attach_to_post": 0,
+ "set_featured": False,
+ "site_url": "https://x",
+ "user_id": None,
+ "content_sha": "a",
+ }
+ other = {**base, "prompt": "dog"}
+ assert _idempotency_key_for(**base) != _idempotency_key_for(**other)
+
+ def test_differs_when_attach_to_post_changes(self):
+ base = {
+ "provider": "openrouter",
+ "model": "m",
+ "prompt": "x",
+ "size": "1024x1024",
+ "attach_to_post": None,
+ "set_featured": False,
+ "site_url": "https://x",
+ "user_id": None,
+ "content_sha": "a",
+ }
+ other = {**base, "attach_to_post": 42}
+ assert _idempotency_key_for(**base) != _idempotency_key_for(**other)
+
+
+class TestHeaderPropagation:
+ @pytest.mark.asyncio
+ async def test_idempotency_key_reaches_companion_route(self):
+ """_companion_upload_and_attach must put the key in the header."""
+ client = WordPressClient("https://example.com", "u", "app_pw")
+
+ captured: dict = {}
+
+ class _FakeResp:
+ status = 200
+
+ async def text(self):
+ return "{}"
+
+ async def json(self, content_type=None):
+ return {"id": 99, "source_url": "https://example.com/a.png"}
+
+ def _request(url, data=None, headers=None, params=None):
+ captured["headers"] = headers
+ captured["params"] = params
+ return AsyncMock(
+ __aenter__=AsyncMock(return_value=_FakeResp()),
+ __aexit__=AsyncMock(return_value=False),
+ )
+
+ session = AsyncMock()
+ session.post = _request
+ with patch(
+ "plugins.wordpress.handlers._media_core.aiohttp.ClientSession",
+ return_value=AsyncMock(
+ __aenter__=AsyncMock(return_value=session),
+ __aexit__=AsyncMock(return_value=False),
+ ),
+ ):
+ await _media_core._companion_upload_and_attach(
+ client,
+ b"\x89PNG\r\n\x1a\n",
+ sniffed="image/png",
+ disposition='attachment; filename="a.png"',
+ attach_to_post=42,
+ set_featured=True,
+ title=None,
+ alt_text=None,
+ caption=None,
+ description=None,
+ idempotency_key="mcphub_ai_abc123",
+ )
+
+ assert captured["headers"]["Idempotency-Key"] == "mcphub_ai_abc123"
+
+ @pytest.mark.asyncio
+ async def test_idempotency_key_absent_header_when_none(self):
+ client = WordPressClient("https://example.com", "u", "app_pw")
+
+ captured: dict = {}
+
+ class _FakeResp:
+ status = 200
+
+ async def text(self):
+ return "{}"
+
+ async def json(self, content_type=None):
+ return {"id": 7}
+
+ def _request(url, data=None, headers=None, params=None):
+ captured["headers"] = headers
+ return AsyncMock(
+ __aenter__=AsyncMock(return_value=_FakeResp()),
+ __aexit__=AsyncMock(return_value=False),
+ )
+
+ session = AsyncMock()
+ session.post = _request
+ with patch(
+ "plugins.wordpress.handlers._media_core.aiohttp.ClientSession",
+ return_value=AsyncMock(
+ __aenter__=AsyncMock(return_value=session),
+ __aexit__=AsyncMock(return_value=False),
+ ),
+ ):
+ await _media_core._companion_upload_and_attach(
+ client,
+ b"x",
+ sniffed="image/png",
+ disposition='attachment; filename="a.png"',
+ attach_to_post=None,
+ set_featured=False,
+ title=None,
+ alt_text=None,
+ caption=None,
+ description=None,
+ idempotency_key=None,
+ )
+ # No Idempotency-Key header when the caller didn't supply one;
+ # the companion must still handle the request normally.
+ assert "Idempotency-Key" not in captured["headers"]
diff --git a/tests/plugins/wordpress/test_audit_hook.py b/tests/plugins/wordpress/test_audit_hook.py
new file mode 100644
index 0000000..958eb7f
--- /dev/null
+++ b/tests/plugins/wordpress/test_audit_hook.py
@@ -0,0 +1,224 @@
+"""F.18.7 — Tests for wordpress audit-hook tools."""
+
+from __future__ import annotations
+
+import json
+from unittest.mock import AsyncMock
+
+import pytest
+
+from plugins.wordpress.client import WordPressClient
+from plugins.wordpress.handlers.audit_hook import (
+ SUPPORTED_EVENTS,
+ AuditHookHandler,
+ _validate_configure,
+ get_tool_specifications,
+)
+
+
+@pytest.fixture
+def wp_client():
+ return WordPressClient(site_url="https://wp.example.com", username="u", app_password="p")
+
+
+@pytest.fixture
+def handler(wp_client):
+ return AuditHookHandler(wp_client)
+
+
+class TestValidateConfigure:
+ def test_all_none_ok(self):
+ assert (
+ _validate_configure(endpoint_url=None, secret=None, enabled=None, events=None) is None
+ )
+
+ def test_https_ok(self):
+ assert (
+ _validate_configure(
+ endpoint_url="https://mcp.example.com/api/companion-audit",
+ secret=None,
+ enabled=None,
+ events=None,
+ )
+ is None
+ )
+
+ def test_non_url_rejected(self):
+ err = _validate_configure(
+ endpoint_url="not-a-url",
+ secret=None,
+ enabled=None,
+ events=None,
+ )
+ assert err is not None
+ assert err["error"] == "invalid_endpoint_url"
+
+ def test_short_secret_rejected(self):
+ err = _validate_configure(
+ endpoint_url=None,
+ secret="short",
+ enabled=None,
+ events=None,
+ )
+ assert err is not None
+ assert err["error"] == "secret_too_short"
+
+ def test_empty_secret_allowed_to_clear(self):
+ assert _validate_configure(endpoint_url=None, secret="", enabled=None, events=None) is None
+
+ def test_unknown_event_rejected(self):
+ err = _validate_configure(
+ endpoint_url=None,
+ secret=None,
+ enabled=None,
+ events=["transition_post_status", "bogus"],
+ )
+ assert err is not None
+ assert err["error"] == "unknown_event"
+ assert "supported" in err
+
+ def test_non_list_events_rejected(self):
+ err = _validate_configure(
+ endpoint_url=None,
+ secret=None,
+ enabled=None,
+ events="not a list",
+ )
+ assert err is not None
+ assert err["error"] == "invalid_events"
+
+ def test_non_bool_enabled_rejected(self):
+ err = _validate_configure(
+ endpoint_url=None,
+ secret=None,
+ enabled="yes",
+ events=None,
+ )
+ assert err is not None
+ assert err["error"] == "invalid_enabled"
+
+
+@pytest.mark.asyncio
+async def test_status_happy_path(handler, wp_client, monkeypatch):
+ companion_response = {
+ "enabled": True,
+ "endpoint_url": "https://mcp.example.com/api/companion-audit",
+ "secret_set": True,
+ "secret_last4": "abcd",
+ "events": list(SUPPORTED_EVENTS),
+ "last_push_gmt": "2026-04-15T09:00:00Z",
+ "failure_count": 0,
+ "last_error": "",
+ "plugin_version": "2.7.0",
+ }
+ get_mock = AsyncMock(return_value=companion_response)
+ monkeypatch.setattr(wp_client, "get", get_mock)
+
+ out = json.loads(await handler.audit_hook_status())
+ assert out["ok"] is True
+ assert out["enabled"] is True
+ assert out["secret_last4"] == "abcd"
+ assert out["failure_count"] == 0
+
+ get_mock.assert_called_once_with("airano-mcp/v1/audit-hook", use_custom_namespace=True)
+
+
+@pytest.mark.asyncio
+async def test_status_unreachable(handler, wp_client, monkeypatch):
+ monkeypatch.setattr(wp_client, "get", AsyncMock(side_effect=RuntimeError("404 Not Found")))
+ out = json.loads(await handler.audit_hook_status())
+ assert out["ok"] is False
+ assert out["error"] == "companion_unreachable"
+
+
+@pytest.mark.asyncio
+async def test_configure_happy_path(handler, wp_client, monkeypatch):
+ captured: dict = {}
+
+ async def fake_post(endpoint, json_data=None, **kwargs):
+ captured["endpoint"] = endpoint
+ captured["body"] = json_data
+ return {
+ "enabled": True,
+ "endpoint_url": "https://mcp.example.com/api/companion-audit",
+ "secret_set": True,
+ "secret_last4": "wxyz",
+ "events": ["transition_post_status"],
+ "failure_count": 0,
+ }
+
+ monkeypatch.setattr(wp_client, "post", AsyncMock(side_effect=fake_post))
+
+ out = json.loads(
+ await handler.audit_hook_configure(
+ endpoint_url="https://mcp.example.com/api/companion-audit",
+ secret="a" * 32,
+ enabled=True,
+ events=["transition_post_status"],
+ )
+ )
+ assert out["ok"] is True
+ assert captured["endpoint"] == "airano-mcp/v1/audit-hook"
+ assert captured["body"]["endpoint_url"] == "https://mcp.example.com/api/companion-audit"
+ assert captured["body"]["secret"] == "a" * 32
+ assert captured["body"]["enabled"] is True
+ assert captured["body"]["events"] == ["transition_post_status"]
+
+
+@pytest.mark.asyncio
+async def test_configure_rejects_short_secret_without_network(handler, wp_client, monkeypatch):
+ post_mock = AsyncMock()
+ monkeypatch.setattr(wp_client, "post", post_mock)
+ out = json.loads(await handler.audit_hook_configure(secret="short"))
+ assert out["ok"] is False
+ assert out["error"] == "secret_too_short"
+ post_mock.assert_not_called()
+
+
+@pytest.mark.asyncio
+async def test_configure_no_fields_rejected(handler, wp_client, monkeypatch):
+ post_mock = AsyncMock()
+ monkeypatch.setattr(wp_client, "post", post_mock)
+ out = json.loads(await handler.audit_hook_configure())
+ assert out["ok"] is False
+ assert out["error"] == "no_fields"
+ post_mock.assert_not_called()
+
+
+@pytest.mark.asyncio
+async def test_disable_calls_delete(handler, wp_client, monkeypatch):
+ del_mock = AsyncMock(
+ return_value={
+ "enabled": False,
+ "endpoint_url": "",
+ "secret_set": False,
+ "cleared": True,
+ "plugin_version": "2.7.0",
+ }
+ )
+ monkeypatch.setattr(wp_client, "delete", del_mock)
+
+ out = json.loads(await handler.audit_hook_disable())
+ assert out["ok"] is True
+ assert out["cleared"] is True
+
+ del_mock.assert_called_once_with("airano-mcp/v1/audit-hook", use_custom_namespace=True)
+
+
+@pytest.mark.asyncio
+async def test_disable_unreachable(handler, wp_client, monkeypatch):
+ monkeypatch.setattr(wp_client, "delete", AsyncMock(side_effect=RuntimeError("boom")))
+ out = json.loads(await handler.audit_hook_disable())
+ assert out["ok"] is False
+ assert out["error"] == "companion_unreachable"
+
+
+def test_tool_specs_shape():
+ specs = get_tool_specifications()
+ assert len(specs) == 3
+ names = {s["name"] for s in specs}
+ assert names == {"audit_hook_status", "audit_hook_configure", "audit_hook_disable"}
+ scopes = {s["name"]: s["scope"] for s in specs}
+ assert scopes["audit_hook_status"] == "read"
+ assert scopes["audit_hook_configure"] == "admin"
+ assert scopes["audit_hook_disable"] == "admin"
diff --git a/tests/plugins/wordpress/test_bulk_meta.py b/tests/plugins/wordpress/test_bulk_meta.py
new file mode 100644
index 0000000..2fdbbac
--- /dev/null
+++ b/tests/plugins/wordpress/test_bulk_meta.py
@@ -0,0 +1,197 @@
+"""F.18.2 — Tests for wordpress_bulk_update_meta."""
+
+from __future__ import annotations
+
+import json
+from unittest.mock import AsyncMock
+
+import pytest
+
+from plugins.wordpress.client import WordPressClient
+from plugins.wordpress.handlers.bulk_meta import (
+ MAX_BULK_ITEMS,
+ BulkMetaHandler,
+ _validate_updates,
+ get_tool_specifications,
+)
+
+
+@pytest.fixture
+def wp_client():
+ return WordPressClient(site_url="https://wp.example.com", username="u", app_password="p")
+
+
+@pytest.fixture
+def handler(wp_client):
+ return BulkMetaHandler(wp_client)
+
+
+# ---------------------------------------------------------------------------
+# Client-side validation: should never hit the network.
+# ---------------------------------------------------------------------------
+
+
+def test_validate_rejects_non_list():
+ out = _validate_updates("not a list")
+ assert isinstance(out, dict)
+ assert out["error"] == "invalid_updates"
+
+
+def test_validate_rejects_empty_list():
+ out = _validate_updates([])
+ assert isinstance(out, dict)
+ assert out["error"] == "empty_updates"
+
+
+def test_validate_rejects_too_many_items():
+ too_many = [{"post_id": i + 1, "meta": {"k": "v"}} for i in range(MAX_BULK_ITEMS + 1)]
+ out = _validate_updates(too_many)
+ assert isinstance(out, dict)
+ assert out["error"] == "too_many_items"
+
+
+def test_validate_rejects_non_object_item():
+ out = _validate_updates(["not a dict"])
+ assert isinstance(out, dict)
+ assert out["error"] == "invalid_item"
+ assert out["index"] == 0
+
+
+def test_validate_rejects_non_positive_post_id():
+ out = _validate_updates([{"post_id": 0, "meta": {"k": "v"}}])
+ assert isinstance(out, dict)
+ assert out["error"] == "invalid_post_id"
+
+
+def test_validate_rejects_non_dict_meta():
+ out = _validate_updates([{"post_id": 1, "meta": "not a dict"}])
+ assert isinstance(out, dict)
+ assert out["error"] == "invalid_meta"
+
+
+def test_validate_passes_well_formed_input():
+ out = _validate_updates(
+ [
+ {"post_id": 1, "meta": {"a": 1}},
+ {"post_id": 2, "meta": {"b": None}},
+ ]
+ )
+ assert isinstance(out, list)
+ assert out == [
+ {"post_id": 1, "meta": {"a": 1}},
+ {"post_id": 2, "meta": {"b": None}},
+ ]
+
+
+# ---------------------------------------------------------------------------
+# Handler behaviour: network errors and happy paths.
+# ---------------------------------------------------------------------------
+
+
+@pytest.mark.asyncio
+async def test_client_side_reject_does_not_hit_network(handler, wp_client, monkeypatch):
+ post_mock = AsyncMock()
+ monkeypatch.setattr(wp_client, "post", post_mock)
+
+ out_json = await handler.bulk_update_meta(updates="not a list")
+ out = json.loads(out_json)
+ assert out["ok"] is False
+ assert out["error"] == "invalid_updates"
+ post_mock.assert_not_called()
+
+
+@pytest.mark.asyncio
+async def test_companion_unreachable_returns_structured_error(handler, wp_client, monkeypatch):
+ monkeypatch.setattr(wp_client, "post", AsyncMock(side_effect=RuntimeError("404 Not Found")))
+ out = json.loads(await handler.bulk_update_meta(updates=[{"post_id": 1, "meta": {"k": "v"}}]))
+ assert out["ok"] is False
+ assert out["error"] == "companion_unreachable"
+ assert "probe_capabilities" in out["hint"]
+ assert out["total"] == 1
+
+
+@pytest.mark.asyncio
+async def test_happy_path_passes_updates_as_list(handler, wp_client, monkeypatch):
+ companion_response = {
+ "total": 2,
+ "updated": 2,
+ "failed": 0,
+ "skipped": 0,
+ "results": [
+ {"index": 0, "post_id": 1, "status": "ok", "updated_keys": ["a"]},
+ {"index": 1, "post_id": 2, "status": "ok", "updated_keys": ["b"]},
+ ],
+ }
+ post_mock = AsyncMock(return_value=companion_response)
+ monkeypatch.setattr(wp_client, "post", post_mock)
+
+ updates = [
+ {"post_id": 1, "meta": {"a": "1"}},
+ {"post_id": 2, "meta": {"b": "2"}},
+ ]
+ out = json.loads(await handler.bulk_update_meta(updates=updates))
+ assert out["ok"] is True
+ assert out["total"] == 2
+ assert out["updated"] == 2
+ assert out["failed"] == 0
+ assert len(out["results"]) == 2
+
+ post_mock.assert_called_once_with(
+ "airano-mcp/v1/bulk-meta",
+ json_data={"updates": updates},
+ use_custom_namespace=True,
+ )
+
+
+@pytest.mark.asyncio
+async def test_mixed_success_and_skip_passed_through(handler, wp_client, monkeypatch):
+ companion_response = {
+ "total": 3,
+ "updated": 1,
+ "failed": 0,
+ "skipped": 2,
+ "results": [
+ {"index": 0, "post_id": 1, "status": "ok", "updated_keys": ["a"]},
+ {"index": 1, "post_id": 999, "status": "not_found", "error": "post_not_found"},
+ {"index": 2, "post_id": 2, "status": "forbidden", "error": "cannot_edit_post"},
+ ],
+ }
+ monkeypatch.setattr(wp_client, "post", AsyncMock(return_value=companion_response))
+
+ out = json.loads(
+ await handler.bulk_update_meta(
+ updates=[
+ {"post_id": 1, "meta": {"a": "1"}},
+ {"post_id": 999, "meta": {"b": "2"}},
+ {"post_id": 2, "meta": {"c": "3"}},
+ ]
+ )
+ )
+ assert out["ok"] is True
+ assert out["updated"] == 1
+ assert out["skipped"] == 2
+ assert out["results"][1]["status"] == "not_found"
+ assert out["results"][2]["status"] == "forbidden"
+
+
+@pytest.mark.asyncio
+async def test_null_meta_value_survives_payload(handler, wp_client, monkeypatch):
+ """null values delete keys in PHP — we must preserve them in the JSON payload."""
+ captured: dict = {}
+
+ async def fake_post(endpoint, json_data=None, **kwargs):
+ captured["json_data"] = json_data
+ return {"total": 1, "updated": 1, "failed": 0, "skipped": 0, "results": []}
+
+ monkeypatch.setattr(wp_client, "post", AsyncMock(side_effect=fake_post))
+
+ await handler.bulk_update_meta(updates=[{"post_id": 1, "meta": {"rank_math_title": None}}])
+ assert captured["json_data"]["updates"][0]["meta"] == {"rank_math_title": None}
+
+
+def test_tool_spec_is_write_scope():
+ specs = get_tool_specifications()
+ assert len(specs) == 1
+ assert specs[0]["name"] == "bulk_update_meta"
+ assert specs[0]["scope"] == "write"
+ assert "updates" in specs[0]["schema"]["properties"]
diff --git a/tests/plugins/wordpress/test_cache_purge.py b/tests/plugins/wordpress/test_cache_purge.py
new file mode 100644
index 0000000..fe2b5e8
--- /dev/null
+++ b/tests/plugins/wordpress/test_cache_purge.py
@@ -0,0 +1,128 @@
+"""F.18.4 — Tests for wordpress_cache_purge."""
+
+from __future__ import annotations
+
+import json
+from unittest.mock import AsyncMock
+
+import pytest
+
+from plugins.wordpress.client import WordPressClient
+from plugins.wordpress.handlers.cache_purge import (
+ CachePurgeHandler,
+ get_tool_specifications,
+)
+
+
+@pytest.fixture
+def wp_client():
+ return WordPressClient(site_url="https://wp.example.com", username="u", app_password="p")
+
+
+@pytest.fixture
+def handler(wp_client):
+ return CachePurgeHandler(wp_client)
+
+
+@pytest.mark.asyncio
+async def test_happy_path(handler, wp_client, monkeypatch):
+ companion_response = {
+ "detected": ["wp_rocket", "litespeed"],
+ "purged": ["wp_rocket_all", "litespeed_all", "object_cache"],
+ "skipped": [],
+ "errors": [],
+ "ok": True,
+ "plugin_version": "2.4.0",
+ }
+ post_mock = AsyncMock(return_value=companion_response)
+ monkeypatch.setattr(wp_client, "post", post_mock)
+
+ out = json.loads(await handler.cache_purge())
+ assert out["ok"] is True
+ assert out["detected"] == ["wp_rocket", "litespeed"]
+ assert "object_cache" in out["purged"]
+ assert out["plugin_version"] == "2.4.0"
+
+ post_mock.assert_called_once_with(
+ "airano-mcp/v1/cache-purge",
+ json_data={},
+ use_custom_namespace=True,
+ )
+
+
+@pytest.mark.asyncio
+async def test_no_cache_plugins_detected(handler, wp_client, monkeypatch):
+ companion_response = {
+ "detected": [],
+ "purged": ["object_cache"],
+ "skipped": [],
+ "errors": [],
+ "ok": True,
+ "plugin_version": "2.4.0",
+ }
+ monkeypatch.setattr(wp_client, "post", AsyncMock(return_value=companion_response))
+
+ out = json.loads(await handler.cache_purge())
+ assert out["ok"] is True
+ assert out["detected"] == []
+ # Object cache still flushed unconditionally.
+ assert "object_cache" in out["purged"]
+
+
+@pytest.mark.asyncio
+async def test_partial_errors_propagated(handler, wp_client, monkeypatch):
+ companion_response = {
+ "detected": ["wp_rocket"],
+ "purged": ["object_cache"],
+ "skipped": [],
+ "errors": [{"plugin": "wp_rocket", "message": "rocket_clean_domain exploded"}],
+ "ok": False,
+ "plugin_version": "2.4.0",
+ }
+ monkeypatch.setattr(wp_client, "post", AsyncMock(return_value=companion_response))
+
+ out = json.loads(await handler.cache_purge())
+ assert out["ok"] is False
+ assert len(out["errors"]) == 1
+ assert out["errors"][0]["plugin"] == "wp_rocket"
+
+
+@pytest.mark.asyncio
+async def test_companion_unreachable(handler, wp_client, monkeypatch):
+ monkeypatch.setattr(wp_client, "post", AsyncMock(side_effect=RuntimeError("404 Not Found")))
+
+ out = json.loads(await handler.cache_purge())
+ assert out["ok"] is False
+ assert out["error"] == "companion_unreachable"
+ assert "manage_options" in out["hint"]
+
+
+@pytest.mark.asyncio
+async def test_non_dict_response(handler, wp_client, monkeypatch):
+ monkeypatch.setattr(wp_client, "post", AsyncMock(return_value="not a dict"))
+
+ out = json.loads(await handler.cache_purge())
+ assert out["ok"] is False
+ assert out["error"] == "invalid_response"
+
+
+@pytest.mark.asyncio
+async def test_ok_inferred_from_empty_errors_if_missing(handler, wp_client, monkeypatch):
+ """Older companion builds might not emit an `ok` flag; derive from errors list."""
+ companion_response = {
+ "detected": ["w3_total_cache"],
+ "purged": ["w3_total_cache_all", "object_cache"],
+ "errors": [],
+ "plugin_version": "2.4.0",
+ }
+ monkeypatch.setattr(wp_client, "post", AsyncMock(return_value=companion_response))
+
+ out = json.loads(await handler.cache_purge())
+ assert out["ok"] is True
+
+
+def test_tool_spec_is_admin_scope():
+ specs = get_tool_specifications()
+ assert len(specs) == 1
+ assert specs[0]["name"] == "cache_purge"
+ assert specs[0]["scope"] == "admin"
diff --git a/tests/plugins/wordpress/test_capabilities.py b/tests/plugins/wordpress/test_capabilities.py
new file mode 100644
index 0000000..ee0058a
--- /dev/null
+++ b/tests/plugins/wordpress/test_capabilities.py
@@ -0,0 +1,239 @@
+"""F.18.1 — Tests for wordpress_probe_capabilities."""
+
+from __future__ import annotations
+
+import json
+from unittest.mock import AsyncMock
+
+import pytest
+
+from plugins.wordpress.client import WordPressClient
+from plugins.wordpress.handlers.capabilities import (
+ _EXPECTED_CAPS,
+ _EXPECTED_ROUTES,
+ CapabilitiesHandler,
+ _CapabilitiesCache,
+ get_cached_capabilities,
+ get_tool_specifications,
+)
+
+
+@pytest.fixture
+def wp_client():
+ return WordPressClient(site_url="https://wp.example.com", username="u", app_password="p")
+
+
+@pytest.fixture
+def cache():
+ return _CapabilitiesCache(ttl=24 * 3600)
+
+
+@pytest.fixture
+def companion_payload():
+ return {
+ "plugin_version": "2.1.0",
+ "companion": True,
+ "user": {
+ "id": 1,
+ "login": "mcphub",
+ "roles": ["administrator"],
+ "capabilities": {
+ "upload_files": True,
+ "edit_posts": True,
+ "publish_posts": True,
+ "edit_others_posts": True,
+ "delete_posts": True,
+ "edit_pages": True,
+ "publish_pages": True,
+ "manage_categories": True,
+ "moderate_comments": True,
+ "manage_options": True,
+ "edit_users": True,
+ "list_users": True,
+ "manage_woocommerce": False,
+ "edit_shop_orders": False,
+ "edit_products": False,
+ },
+ },
+ "features": {
+ "rank_math": True,
+ "yoast": False,
+ "woocommerce": False,
+ "multisite": False,
+ },
+ "routes": {
+ "seo_meta": True,
+ "upload_limits": True,
+ "upload_chunk": True,
+ "capabilities": True,
+ "bulk_meta": False,
+ "export": False,
+ "cache_purge": False,
+ "transient_flush": False,
+ "site_health": False,
+ "audit_hook": False,
+ },
+ "wordpress": {
+ "version": "6.5.3",
+ "php_version": "8.1.27",
+ "rest_enabled": True,
+ },
+ }
+
+
+@pytest.mark.asyncio
+async def test_companion_endpoint_parses_and_caches(
+ wp_client, monkeypatch, cache, companion_payload
+):
+ handler = CapabilitiesHandler(wp_client, cache=cache)
+ get_mock = AsyncMock(return_value=companion_payload)
+ monkeypatch.setattr(wp_client, "get", get_mock)
+
+ out_json = await handler.probe_capabilities()
+ out = json.loads(out_json)
+
+ assert out["companion_available"] is True
+ assert out["plugin_version"] == "2.1.0"
+ assert out["cached"] is False
+ assert out["user"]["login"] == "mcphub"
+ assert out["user"]["capabilities"]["upload_files"] is True
+ assert out["user"]["capabilities"]["manage_woocommerce"] is False
+ assert out["routes"]["upload_chunk"] is True
+ assert out["routes"]["bulk_meta"] is False
+ assert out["features"]["rank_math"] is True
+ assert out["wordpress"]["version"] == "6.5.3"
+
+ get_mock.assert_called_once_with("airano-mcp/v1/capabilities", use_custom_namespace=True)
+
+
+@pytest.mark.asyncio
+async def test_second_call_is_served_from_cache(wp_client, monkeypatch, cache, companion_payload):
+ handler = CapabilitiesHandler(wp_client, cache=cache)
+ get_mock = AsyncMock(return_value=companion_payload)
+ monkeypatch.setattr(wp_client, "get", get_mock)
+
+ await handler.probe_capabilities()
+ out2 = json.loads(await handler.probe_capabilities())
+
+ assert out2["cached"] is True
+ assert get_mock.call_count == 1
+
+
+@pytest.mark.asyncio
+async def test_cache_expires_after_ttl(wp_client, monkeypatch, companion_payload):
+ cache = _CapabilitiesCache(ttl=0.0)
+ handler = CapabilitiesHandler(wp_client, cache=cache)
+ get_mock = AsyncMock(return_value=companion_payload)
+ monkeypatch.setattr(wp_client, "get", get_mock)
+
+ await handler.probe_capabilities()
+ await handler.probe_capabilities()
+ assert get_mock.call_count == 2
+
+
+@pytest.mark.asyncio
+async def test_companion_missing_returns_false_shape(wp_client, monkeypatch, cache):
+ handler = CapabilitiesHandler(wp_client, cache=cache)
+ monkeypatch.setattr(wp_client, "get", AsyncMock(side_effect=RuntimeError("404 Not Found")))
+
+ out = json.loads(await handler.probe_capabilities())
+ assert out["companion_available"] is False
+ assert out["plugin_version"] is None
+ assert out["user"] is None
+ assert out["features"] is None
+ # All routes default to False so downstream code can still index safely.
+ assert all(v is False for v in out["routes"].values())
+ assert set(out["routes"].keys()) == set(_EXPECTED_ROUTES)
+ assert "companion_unreachable" in out["reason"]
+
+
+@pytest.mark.asyncio
+async def test_non_dict_payload_treated_as_missing(wp_client, monkeypatch, cache):
+ handler = CapabilitiesHandler(wp_client, cache=cache)
+ monkeypatch.setattr(wp_client, "get", AsyncMock(return_value=["not", "a", "dict"]))
+
+ out = json.loads(await handler.probe_capabilities())
+ assert out["companion_available"] is False
+ assert out["reason"] == "companion_returned_non_dict"
+
+
+@pytest.mark.asyncio
+async def test_missing_caps_default_to_false(wp_client, monkeypatch, cache):
+ """If the plugin drops a cap key (e.g. older plugin build), we fill with False."""
+ handler = CapabilitiesHandler(wp_client, cache=cache)
+ partial = {
+ "plugin_version": "2.1.0",
+ "user": {
+ "id": 1,
+ "login": "x",
+ "roles": ["editor"],
+ "capabilities": {"upload_files": True, "edit_posts": True},
+ },
+ "routes": {"upload_limits": True, "upload_chunk": True},
+ "features": {"rank_math": False},
+ "wordpress": {"version": "6.4"},
+ }
+ monkeypatch.setattr(wp_client, "get", AsyncMock(return_value=partial))
+
+ out = json.loads(await handler.probe_capabilities())
+ assert out["companion_available"] is True
+ caps = out["user"]["capabilities"]
+ assert caps["upload_files"] is True
+ assert caps["manage_options"] is False
+ assert set(caps.keys()) == set(_EXPECTED_CAPS)
+ routes = out["routes"]
+ assert routes["upload_limits"] is True
+ assert routes["bulk_meta"] is False
+ assert set(routes.keys()) == set(_EXPECTED_ROUTES)
+
+
+@pytest.mark.asyncio
+async def test_extra_caps_preserved_under_extra_key(wp_client, monkeypatch, cache):
+ """Caps the plugin sends that aren't in our known list end up under extra_capabilities."""
+ handler = CapabilitiesHandler(wp_client, cache=cache)
+ payload = {
+ "plugin_version": "2.9.0", # future plugin with more caps
+ "user": {
+ "id": 1,
+ "login": "x",
+ "roles": ["administrator"],
+ "capabilities": {
+ "upload_files": True,
+ "some_future_cap": True,
+ },
+ },
+ "routes": {},
+ "features": {},
+ "wordpress": {},
+ }
+ monkeypatch.setattr(wp_client, "get", AsyncMock(return_value=payload))
+
+ out = json.loads(await handler.probe_capabilities())
+ assert out["user"]["capabilities"]["upload_files"] is True
+ assert out["user"]["extra_capabilities"] == {"some_future_cap": True}
+
+
+@pytest.mark.asyncio
+async def test_get_cached_capabilities_returns_none_before_probe(wp_client):
+ cache = _CapabilitiesCache(ttl=24 * 3600)
+ assert await get_cached_capabilities(wp_client, cache=cache) is None
+
+
+@pytest.mark.asyncio
+async def test_get_cached_capabilities_after_probe(
+ wp_client, monkeypatch, cache, companion_payload
+):
+ handler = CapabilitiesHandler(wp_client, cache=cache)
+ monkeypatch.setattr(wp_client, "get", AsyncMock(return_value=companion_payload))
+ await handler.probe_capabilities()
+
+ got = await get_cached_capabilities(wp_client, cache=cache)
+ assert got is not None
+ assert got["companion_available"] is True
+
+
+def test_tool_spec_is_read_scope():
+ specs = get_tool_specifications()
+ assert len(specs) == 1
+ assert specs[0]["name"] == "probe_capabilities"
+ assert specs[0]["scope"] == "read"
diff --git a/tests/plugins/wordpress/test_client_fast_fail.py b/tests/plugins/wordpress/test_client_fast_fail.py
new file mode 100644
index 0000000..98a4973
--- /dev/null
+++ b/tests/plugins/wordpress/test_client_fast_fail.py
@@ -0,0 +1,173 @@
+"""F.X.fix #3 — WordPressClient fast-fails on unreachable sites.
+
+Regression: tools hung 35-85s on DNS-dead / TCP-refused hosts because
+the client did not set a connect timeout. Fix adds connect=5 and maps
+every TCP/DNS/SSL failure to SiteUnreachableError with a structured
+install_hint. Budget: total wall-clock < 10s per request.
+"""
+
+from __future__ import annotations
+
+import socket
+import time
+from unittest.mock import AsyncMock, patch
+
+import aiohttp
+import pytest
+
+from plugins.wordpress.client import (
+ _CONNECT_TIMEOUT,
+ _REQUEST_TIMEOUT,
+ SiteUnreachableError,
+ WordPressClient,
+)
+
+
+@pytest.fixture
+def client() -> WordPressClient:
+ return WordPressClient("https://dead.example.invalid", "user", "app_pw")
+
+
+def _patch_session_raising(exc: Exception):
+ """Patch aiohttp.ClientSession so ``session.request(...)`` raises ``exc``.
+
+ The production code uses ``async with session.request(...) as resp``,
+ so we mock ``request`` as a plain callable returning an
+ ``AsyncMock`` whose ``__aenter__`` raises — matching the real
+ ``aiohttp.ClientConnector*`` error path.
+ """
+ session = AsyncMock()
+
+ def _request(*_args, **_kwargs):
+ ctx = AsyncMock()
+ ctx.__aenter__ = AsyncMock(side_effect=exc)
+ ctx.__aexit__ = AsyncMock(return_value=False)
+ return ctx
+
+ session.request = _request
+ return patch(
+ "plugins.wordpress.client.aiohttp.ClientSession",
+ return_value=AsyncMock(
+ __aenter__=AsyncMock(return_value=session),
+ __aexit__=AsyncMock(return_value=False),
+ ),
+ )
+
+
+def _conn_key(site_url: str) -> aiohttp.client_reqrep.ConnectionKey:
+ # Minimal ConnectionKey required by aiohttp error constructors.
+ return aiohttp.client_reqrep.ConnectionKey(
+ host=site_url.split("://")[-1],
+ port=443,
+ is_ssl=site_url.startswith("https"),
+ ssl=None,
+ proxy=None,
+ proxy_auth=None,
+ proxy_headers_hash=None,
+ )
+
+
+class TestConnectTimeoutConfigured:
+ def test_connect_timeout_is_five_seconds(self):
+ # Sanity: the module-level constant is what the roadmap asked for.
+ assert _CONNECT_TIMEOUT == 5
+ assert _REQUEST_TIMEOUT == 30
+
+
+class TestSiteUnreachableMapping:
+ @pytest.mark.asyncio
+ async def test_dns_error_maps_to_site_unreachable(self, client):
+ dns_exc = aiohttp.ClientConnectorDNSError(
+ _conn_key(client.site_url), OSError("Name or service not known")
+ )
+ with _patch_session_raising(dns_exc):
+ with patch("plugins.wordpress.client.asyncio.sleep", AsyncMock()):
+ with pytest.raises(SiteUnreachableError) as e:
+ await client.request("GET", "posts")
+ assert e.value.error_code == "SITE_UNREACHABLE"
+ assert e.value.reason == "site_dns_error"
+ assert e.value.install_hint is not None
+ assert e.value.install_hint["companion_min_version"]
+ assert e.value.install_hint["install_url"].startswith("http")
+
+ @pytest.mark.asyncio
+ async def test_gaierror_via_connector_also_maps_to_dns_error(self, client):
+ gai = socket.gaierror("Name or service not known")
+ conn_exc = aiohttp.ClientConnectorError(_conn_key(client.site_url), gai)
+ with _patch_session_raising(conn_exc):
+ with patch("plugins.wordpress.client.asyncio.sleep", AsyncMock()):
+ with pytest.raises(SiteUnreachableError) as e:
+ await client.request("GET", "posts")
+ assert e.value.reason == "site_dns_error"
+
+ @pytest.mark.asyncio
+ async def test_connection_refused_maps_to_site_unreachable(self, client):
+ os_err = OSError(111, "Connection refused")
+ conn_exc = aiohttp.ClientConnectorError(_conn_key(client.site_url), os_err)
+ with _patch_session_raising(conn_exc):
+ with patch("plugins.wordpress.client.asyncio.sleep", AsyncMock()):
+ with pytest.raises(SiteUnreachableError) as e:
+ await client.request("GET", "posts")
+ assert e.value.reason == "site_connection_refused"
+ assert e.value.install_hint is not None
+
+ @pytest.mark.asyncio
+ async def test_ssl_error_maps_to_site_unreachable(self, client):
+ import ssl
+
+ ssl_exc = aiohttp.ClientConnectorCertificateError(
+ _conn_key(client.site_url),
+ ssl.SSLCertVerificationError("bad cert"),
+ )
+ with _patch_session_raising(ssl_exc):
+ with pytest.raises(SiteUnreachableError) as e:
+ await client.request("GET", "posts")
+ assert e.value.reason == "site_ssl_error"
+
+ @pytest.mark.asyncio
+ async def test_invalid_url_maps_to_site_unreachable(self):
+ client = WordPressClient("not-a-url", "u", "p")
+ invalid = aiohttp.InvalidURL("not-a-url")
+ with _patch_session_raising(invalid):
+ with pytest.raises(SiteUnreachableError) as e:
+ await client.request("GET", "posts")
+ assert e.value.reason == "site_invalid_url"
+
+ @pytest.mark.asyncio
+ async def test_timeout_after_retries_maps_to_site_unreachable(self, client):
+ with _patch_session_raising(TimeoutError("connect")):
+ with patch("plugins.wordpress.client.asyncio.sleep", AsyncMock()):
+ with pytest.raises(SiteUnreachableError) as e:
+ await client.request("GET", "posts")
+ assert e.value.reason == "site_timeout"
+ assert e.value.install_hint is not None
+
+
+class TestFastFailBudget:
+ """Guards the 10s assertion promised in the F.X.retest matrix."""
+
+ @pytest.mark.asyncio
+ async def test_dns_error_fails_under_ten_seconds(self, client):
+ dns_exc = aiohttp.ClientConnectorDNSError(_conn_key(client.site_url), OSError("dead"))
+ with _patch_session_raising(dns_exc):
+ start = time.monotonic()
+ with pytest.raises(SiteUnreachableError):
+ await client.request("GET", "posts")
+ elapsed = time.monotonic() - start
+ # DNS errors are non-retryable, so this is essentially instant;
+ # give a loose 10s bound to match the F.X.retest assertion shape.
+ assert elapsed < 10.0
+
+ @pytest.mark.asyncio
+ async def test_timeout_with_retries_fails_under_ten_seconds(self, client):
+ # Two retries × _CONNECT_TIMEOUT + retry backoff. With mocked
+ # sleep this should return immediately; real-world budget is
+ # 3 × 5s = 15s worst-case, but only on the first request; follow-
+ # ups hit the cache.
+ with _patch_session_raising(TimeoutError()):
+ with patch("plugins.wordpress.client.asyncio.sleep", AsyncMock()):
+ start = time.monotonic()
+ with pytest.raises(SiteUnreachableError):
+ await client.request("GET", "posts")
+ elapsed = time.monotonic() - start
+ assert elapsed < 10.0
diff --git a/tests/plugins/wordpress/test_companion_install_hint.py b/tests/plugins/wordpress/test_companion_install_hint.py
new file mode 100644
index 0000000..6aa7544
--- /dev/null
+++ b/tests/plugins/wordpress/test_companion_install_hint.py
@@ -0,0 +1,158 @@
+"""Tests for the shared ``companion_install_hint`` helper + its use in
+companion-backed handlers' ``companion_unreachable`` error payloads.
+
+The helper's job is to give the caller enough information to actually
+install / configure the companion plugin rather than just saying
+"companion unreachable". Every handler that routes through the
+companion now emits this dict alongside the existing human-readable
+``hint`` string.
+"""
+
+from __future__ import annotations
+
+import json
+from unittest.mock import AsyncMock
+
+import pytest
+
+from plugins.wordpress.client import WordPressClient
+from plugins.wordpress.handlers._companion_hint import (
+ COMPANION_DOWNLOAD_URL,
+ companion_install_hint,
+)
+
+
+@pytest.fixture
+def wp_client():
+ c = WordPressClient(site_url="https://wp.example.com", username="u", app_password="p")
+ c.post = AsyncMock(side_effect=RuntimeError("rest_no_route: 404"))
+ c.get = AsyncMock(side_effect=RuntimeError("rest_no_route: 404"))
+ return c
+
+
+# ---------------------------------------------------------------------------
+# Helper-level tests
+# ---------------------------------------------------------------------------
+
+
+class TestCompanionInstallHintHelper:
+ @pytest.mark.unit
+ def test_download_url_points_at_github_raw(self):
+ assert COMPANION_DOWNLOAD_URL.startswith("https://github.com/airano-ir/mcphub/raw/main/")
+ assert COMPANION_DOWNLOAD_URL.endswith("airano-mcp-bridge.zip")
+
+ @pytest.mark.unit
+ def test_returns_required_keys(self):
+ hint = companion_install_hint(min_version="2.4.0")
+ assert set(hint.keys()) == {
+ "install_url",
+ "install_instructions",
+ "required_capability",
+ "companion_min_version",
+ }
+ assert hint["companion_min_version"] == "2.4.0"
+ assert hint["required_capability"] == "manage_options"
+
+ @pytest.mark.unit
+ def test_capability_override(self):
+ hint = companion_install_hint(min_version="2.3.0", required_capability="edit_posts")
+ assert hint["required_capability"] == "edit_posts"
+ assert "edit_posts" in hint["install_instructions"]
+
+ @pytest.mark.unit
+ def test_route_hint_appended_when_provided(self):
+ hint = companion_install_hint(min_version="2.8.0", route="airano-mcp/v1/foo")
+ assert hint["route"] == "airano-mcp/v1/foo"
+
+ @pytest.mark.unit
+ def test_install_url_is_stable(self):
+ hint_a = companion_install_hint(min_version="2.0.0")
+ hint_b = companion_install_hint(min_version="3.0.0")
+ # The download URL itself is version-agnostic — it always points
+ # at the latest zip on main.
+ assert hint_a["install_url"] == hint_b["install_url"] == COMPANION_DOWNLOAD_URL
+
+
+# ---------------------------------------------------------------------------
+# End-to-end: a handler emits install_hint when the companion is missing.
+# ---------------------------------------------------------------------------
+
+
+class TestHandlerEmitsInstallHint:
+ @pytest.mark.asyncio
+ async def test_cache_purge_emits_install_hint_on_failure(self, wp_client):
+ from plugins.wordpress.handlers.cache_purge import CachePurgeHandler
+
+ handler = CachePurgeHandler(wp_client)
+ out = json.loads(await handler.cache_purge())
+ assert out["ok"] is False
+ assert out["error"] == "companion_unreachable"
+ assert "install_hint" in out
+ assert out["install_hint"]["install_url"] == COMPANION_DOWNLOAD_URL
+ assert out["install_hint"]["companion_min_version"] == "2.4.0"
+ assert out["install_hint"]["required_capability"] == "manage_options"
+ assert out["install_hint"]["route"] == "airano-mcp/v1/cache-purge"
+
+ @pytest.mark.asyncio
+ async def test_regenerate_thumbnails_emits_install_hint(self, wp_client):
+ from plugins.wordpress.handlers.regenerate_thumbnails import (
+ RegenerateThumbnailsHandler,
+ )
+
+ handler = RegenerateThumbnailsHandler(wp_client)
+ out = json.loads(await handler.regenerate_thumbnails(ids=[1, 2]))
+ assert out["error"] == "companion_unreachable"
+ assert out["install_hint"]["companion_min_version"] == "2.8.0"
+ # Writes need upload_files, not manage_options.
+ assert out["install_hint"]["required_capability"] == "upload_files"
+
+ @pytest.mark.asyncio
+ async def test_bulk_meta_emits_install_hint(self, wp_client):
+ from plugins.wordpress.handlers.bulk_meta import BulkMetaHandler
+
+ handler = BulkMetaHandler(wp_client)
+ out = json.loads(
+ await handler.bulk_update_meta(updates=[{"post_id": 1, "meta": {"k": "v"}}])
+ )
+ assert out["error"] == "companion_unreachable"
+ assert out["install_hint"]["companion_min_version"] == "2.2.0"
+
+ @pytest.mark.asyncio
+ async def test_export_emits_install_hint(self, wp_client):
+ from plugins.wordpress.handlers.export import ExportHandler
+
+ handler = ExportHandler(wp_client)
+ out = json.loads(await handler.export_content())
+ assert out["error"] == "companion_unreachable"
+ assert out["install_hint"]["companion_min_version"] == "2.3.0"
+ # Export uses edit_posts, not manage_options.
+ assert out["install_hint"]["required_capability"] == "edit_posts"
+
+ @pytest.mark.asyncio
+ async def test_site_health_emits_install_hint(self, wp_client):
+ from plugins.wordpress.handlers.site_health import SiteHealthHandler
+
+ handler = SiteHealthHandler(wp_client)
+ out = json.loads(await handler.site_health())
+ assert out["error"] == "companion_unreachable"
+ assert out["install_hint"]["companion_min_version"] == "2.6.0"
+
+ @pytest.mark.asyncio
+ async def test_transient_flush_emits_install_hint(self, wp_client):
+ from plugins.wordpress.handlers.transient_flush import TransientFlushHandler
+
+ handler = TransientFlushHandler(wp_client)
+ out = json.loads(await handler.transient_flush())
+ assert out["error"] == "companion_unreachable"
+ assert out["install_hint"]["companion_min_version"] == "2.5.0"
+
+ @pytest.mark.asyncio
+ async def test_capabilities_probe_embeds_install_hint_when_empty(self, wp_client):
+ """``_empty_capabilities_payload`` is the fallback shape every
+ companion probe returns on failure; the install hint must be in
+ there too so the UI can link-to-install without a second lookup."""
+ from plugins.wordpress.handlers.capabilities import _empty_capabilities_payload
+
+ payload = _empty_capabilities_payload("https://wp.example.com", reason="test")
+ assert "install_hint" in payload
+ assert payload["install_hint"]["install_url"] == COMPANION_DOWNLOAD_URL
diff --git a/tests/plugins/wordpress/test_export.py b/tests/plugins/wordpress/test_export.py
new file mode 100644
index 0000000..1462a8e
--- /dev/null
+++ b/tests/plugins/wordpress/test_export.py
@@ -0,0 +1,263 @@
+"""F.18.3 — Tests for wordpress_export_content."""
+
+from __future__ import annotations
+
+import json
+from unittest.mock import AsyncMock
+
+import pytest
+
+from plugins.wordpress.client import WordPressClient
+from plugins.wordpress.handlers.export import (
+ EXPORT_DEFAULT_LIMIT,
+ EXPORT_MAX_LIMIT,
+ ExportHandler,
+ _build_query_params,
+ _normalise_bool,
+ get_tool_specifications,
+)
+
+
+@pytest.fixture
+def wp_client():
+ return WordPressClient(site_url="https://wp.example.com", username="u", app_password="p")
+
+
+@pytest.fixture
+def handler(wp_client):
+ return ExportHandler(wp_client)
+
+
+# ---------------------------------------------------------------------------
+# Parameter normalisation.
+# ---------------------------------------------------------------------------
+
+
+class TestNormaliseBool:
+ def test_none_defaults(self):
+ assert _normalise_bool(None, True) is True
+ assert _normalise_bool(None, False) is False
+
+ def test_bool_passthrough(self):
+ assert _normalise_bool(True, False) is True
+ assert _normalise_bool(False, True) is False
+
+ def test_numeric(self):
+ assert _normalise_bool(1, False) is True
+ assert _normalise_bool(0, True) is False
+
+ def test_strings(self):
+ assert _normalise_bool("true", False) is True
+ assert _normalise_bool("False", True) is False
+ assert _normalise_bool("yes", False) is True
+ assert _normalise_bool("no", True) is False
+ assert _normalise_bool("garbage", True) is True # fallback
+
+
+class TestBuildQueryParams:
+ def test_defaults(self):
+ out = _build_query_params(
+ post_type=None,
+ status=None,
+ since=None,
+ limit=None,
+ offset=None,
+ include_media=True,
+ include_terms=True,
+ include_meta=True,
+ )
+ assert out["post_type"] == "post"
+ assert out["status"] == "publish"
+ assert out["limit"] == EXPORT_DEFAULT_LIMIT
+ assert out["offset"] == 0
+ assert out["include_media"] == "true"
+ assert out["include_terms"] == "true"
+ assert out["include_meta"] == "true"
+ assert "since" not in out
+
+ def test_limit_clamped_to_max(self):
+ out = _build_query_params(
+ post_type=None,
+ status=None,
+ since=None,
+ limit=EXPORT_MAX_LIMIT * 10,
+ offset=None,
+ include_media=True,
+ include_terms=True,
+ include_meta=True,
+ )
+ assert out["limit"] == EXPORT_MAX_LIMIT
+
+ def test_negative_offset_floored_to_zero(self):
+ out = _build_query_params(
+ post_type=None,
+ status=None,
+ since=None,
+ limit=None,
+ offset=-5,
+ include_media=True,
+ include_terms=True,
+ include_meta=True,
+ )
+ assert out["offset"] == 0
+
+ def test_since_passed_through(self):
+ out = _build_query_params(
+ post_type="post",
+ status="publish",
+ since="2026-04-01T00:00:00Z",
+ limit=10,
+ offset=0,
+ include_media=False,
+ include_terms=True,
+ include_meta=False,
+ )
+ assert out["since"] == "2026-04-01T00:00:00Z"
+ assert out["include_media"] == "false"
+ assert out["include_meta"] == "false"
+ assert out["limit"] == 10
+
+ def test_comma_separated_types(self):
+ out = _build_query_params(
+ post_type="post,page,product",
+ status="publish,draft",
+ since=None,
+ limit=None,
+ offset=None,
+ include_media=True,
+ include_terms=True,
+ include_meta=True,
+ )
+ assert out["post_type"] == "post,page,product"
+ assert out["status"] == "publish,draft"
+
+
+# ---------------------------------------------------------------------------
+# Handler behaviour.
+# ---------------------------------------------------------------------------
+
+
+@pytest.mark.asyncio
+async def test_happy_path_forwards_to_companion(handler, wp_client, monkeypatch):
+ companion_response = {
+ "post_types": ["post"],
+ "status": ["publish"],
+ "since": None,
+ "limit": 100,
+ "offset": 0,
+ "returned": 2,
+ "total_matching": 5,
+ "has_more": True,
+ "next_offset": 2,
+ "include_media": True,
+ "include_terms": True,
+ "include_meta": True,
+ "posts": [
+ {"id": 1, "title": "First", "post_type": "post"},
+ {"id": 2, "title": "Second", "post_type": "post"},
+ ],
+ "media": [],
+ "exported_at_gmt": "2026-04-15T09:00:00Z",
+ "plugin_version": "2.3.0",
+ }
+ get_mock = AsyncMock(return_value=companion_response)
+ monkeypatch.setattr(wp_client, "get", get_mock)
+
+ out = json.loads(await handler.export_content())
+ assert out["ok"] is True
+ assert out["returned"] == 2
+ assert out["has_more"] is True
+ assert out["next_offset"] == 2
+ assert len(out["posts"]) == 2
+ assert out["plugin_version"] == "2.3.0"
+
+ # Verify the endpoint was called with default params.
+ call_args = get_mock.call_args
+ assert call_args.args[0] == "airano-mcp/v1/export"
+ assert call_args.kwargs["use_custom_namespace"] is True
+ params = call_args.kwargs["params"]
+ assert params["post_type"] == "post"
+ assert params["status"] == "publish"
+ assert params["limit"] == EXPORT_DEFAULT_LIMIT
+
+
+@pytest.mark.asyncio
+async def test_all_params_forwarded(handler, wp_client, monkeypatch):
+ captured: dict = {}
+
+ async def fake_get(endpoint, params=None, **kwargs):
+ captured["endpoint"] = endpoint
+ captured["params"] = params
+ return {"posts": [], "media": [], "returned": 0, "total_matching": 0}
+
+ monkeypatch.setattr(wp_client, "get", AsyncMock(side_effect=fake_get))
+
+ await handler.export_content(
+ post_type="post,page",
+ status="publish,draft",
+ since="2026-01-01T00:00:00Z",
+ limit=50,
+ offset=10,
+ include_media=False,
+ include_terms=False,
+ include_meta=True,
+ )
+ assert captured["params"]["post_type"] == "post,page"
+ assert captured["params"]["status"] == "publish,draft"
+ assert captured["params"]["since"] == "2026-01-01T00:00:00Z"
+ assert captured["params"]["limit"] == 50
+ assert captured["params"]["offset"] == 10
+ assert captured["params"]["include_media"] == "false"
+ assert captured["params"]["include_terms"] == "false"
+ assert captured["params"]["include_meta"] == "true"
+
+
+@pytest.mark.asyncio
+async def test_companion_unreachable_returns_structured_error(handler, wp_client, monkeypatch):
+ monkeypatch.setattr(wp_client, "get", AsyncMock(side_effect=RuntimeError("404")))
+ out = json.loads(await handler.export_content(post_type="post"))
+ assert out["ok"] is False
+ assert out["error"] == "companion_unreachable"
+ assert "probe_capabilities" in out["hint"]
+ assert "params" in out
+
+
+@pytest.mark.asyncio
+async def test_non_dict_companion_response(handler, wp_client, monkeypatch):
+ monkeypatch.setattr(wp_client, "get", AsyncMock(return_value=["not", "a", "dict"]))
+ out = json.loads(await handler.export_content())
+ assert out["ok"] is False
+ assert out["error"] == "invalid_response"
+
+
+@pytest.mark.asyncio
+async def test_limit_clamping_before_network(handler, wp_client, monkeypatch):
+ captured: dict = {}
+
+ async def fake_get(endpoint, params=None, **kwargs):
+ captured["params"] = params
+ return {"posts": [], "media": []}
+
+ monkeypatch.setattr(wp_client, "get", AsyncMock(side_effect=fake_get))
+
+ await handler.export_content(limit=10_000)
+ assert captured["params"]["limit"] == EXPORT_MAX_LIMIT
+
+
+def test_tool_spec_is_read_scope():
+ specs = get_tool_specifications()
+ assert len(specs) == 1
+ assert specs[0]["name"] == "export_content"
+ assert specs[0]["scope"] == "read"
+ props = specs[0]["schema"]["properties"]
+ for key in (
+ "post_type",
+ "status",
+ "since",
+ "limit",
+ "offset",
+ "include_media",
+ "include_terms",
+ "include_meta",
+ ):
+ assert key in props
diff --git a/tests/plugins/wordpress/test_get_post_fields.py b/tests/plugins/wordpress/test_get_post_fields.py
new file mode 100644
index 0000000..335d7e7
--- /dev/null
+++ b/tests/plugins/wordpress/test_get_post_fields.py
@@ -0,0 +1,153 @@
+"""F.X.fix #4 — get_post default projection + strict ``fields=`` allow-list.
+
+Regression: ``get_post`` dropped ``featured_media``, ``slug``, and
+``featured_media_url`` from its response even when callers asked for
+them explicitly via ``fields=``. Fix restores the defaults and makes
+the ``fields`` parameter a strict allow-list rather than a subset of a
+hard-coded projection.
+"""
+
+from __future__ import annotations
+
+import json
+from unittest.mock import AsyncMock
+
+import pytest
+
+from plugins.wordpress.handlers.posts import PostsHandler
+
+SAMPLE_POST = {
+ "id": 42,
+ "slug": "mcp-hub-launch",
+ "featured_media": 77,
+ "title": {"rendered": "MCP Hub Launch"},
+ "content": {"rendered": "Hello world
"},
+ "excerpt": {"rendered": "short
"},
+ "status": "publish",
+ "date": "2026-04-17T10:00:00",
+ "modified": "2026-04-17T10:05:00",
+ "categories": [3],
+ "tags": [9],
+ "link": "https://example.com/mcp-hub-launch",
+ "_embedded": {
+ "author": [{"name": "Ali"}],
+ "wp:featuredmedia": [
+ {
+ "id": 77,
+ "source_url": "https://example.com/wp-content/uploads/hero.webp",
+ }
+ ],
+ },
+}
+
+
+@pytest.fixture
+def handler_with(post_payload):
+ client = AsyncMock()
+ client.get = AsyncMock(return_value=post_payload)
+ return PostsHandler(client), client
+
+
+class TestDefaultProjection:
+ @pytest.mark.asyncio
+ async def test_default_response_includes_featured_media_slug_and_url(self):
+ client = AsyncMock()
+ client.get = AsyncMock(return_value=SAMPLE_POST)
+ handler = PostsHandler(client)
+
+ raw = await handler.get_post(post_id=42)
+ data = json.loads(raw)
+
+ assert data["id"] == 42
+ assert data["slug"] == "mcp-hub-launch"
+ assert data["featured_media"] == 77
+ assert data["featured_media_url"] == "https://example.com/wp-content/uploads/hero.webp"
+
+ @pytest.mark.asyncio
+ async def test_featured_media_url_empty_when_no_embedded_media(self):
+ post = {**SAMPLE_POST, "featured_media": 0, "_embedded": {"author": [{"name": "x"}]}}
+ client = AsyncMock()
+ client.get = AsyncMock(return_value=post)
+ handler = PostsHandler(client)
+
+ raw = await handler.get_post(post_id=42)
+ data = json.loads(raw)
+
+ assert data["featured_media"] == 0
+ assert data["featured_media_url"] == ""
+
+ @pytest.mark.asyncio
+ async def test_default_still_embeds_metadata(self):
+ client = AsyncMock()
+ client.get = AsyncMock(return_value=SAMPLE_POST)
+ handler = PostsHandler(client)
+
+ await handler.get_post(post_id=42)
+ # ``_embed=true`` must remain in query so featured_media_url can
+ # be derived from _embedded.wp:featuredmedia.
+ call = client.get.call_args
+ params = call.kwargs.get("params") or call.args[1]
+ assert params.get("_embed") == "true"
+
+
+class TestStrictFieldsAllowList:
+ @pytest.mark.asyncio
+ async def test_fields_limits_to_requested_plus_id(self):
+ client = AsyncMock()
+ client.get = AsyncMock(return_value=SAMPLE_POST)
+ handler = PostsHandler(client)
+
+ raw = await handler.get_post(post_id=42, fields="slug,featured_media")
+ data = json.loads(raw)
+ # id is always preserved for identification.
+ assert set(data.keys()) == {"id", "slug", "featured_media"}
+
+ @pytest.mark.asyncio
+ async def test_fields_title_only_excludes_slug_by_design(self):
+ client = AsyncMock()
+ client.get = AsyncMock(return_value=SAMPLE_POST)
+ handler = PostsHandler(client)
+
+ raw = await handler.get_post(post_id=42, fields="title")
+ data = json.loads(raw)
+ # Previous behaviour implicitly included slug even when NOT
+ # requested — strict allow-list must drop it.
+ assert "slug" not in data
+ assert "featured_media" not in data
+ assert set(data.keys()) == {"id", "title"}
+
+ @pytest.mark.asyncio
+ async def test_fields_featured_media_url_honoured(self):
+ client = AsyncMock()
+ client.get = AsyncMock(return_value=SAMPLE_POST)
+ handler = PostsHandler(client)
+
+ raw = await handler.get_post(post_id=42, fields="featured_media_url")
+ data = json.loads(raw)
+ assert set(data.keys()) == {"id", "featured_media_url"}
+ assert data["featured_media_url"].startswith("https://")
+
+ @pytest.mark.asyncio
+ async def test_unknown_field_name_is_ignored(self):
+ client = AsyncMock()
+ client.get = AsyncMock(return_value=SAMPLE_POST)
+ handler = PostsHandler(client)
+
+ raw = await handler.get_post(post_id=42, fields="slug,does_not_exist")
+ data = json.loads(raw)
+ # Unknown name silently dropped; requested known names plus id.
+ assert set(data.keys()) == {"id", "slug"}
+
+ @pytest.mark.asyncio
+ async def test_fields_passes_wp_fields_query_param(self):
+ client = AsyncMock()
+ client.get = AsyncMock(return_value=SAMPLE_POST)
+ handler = PostsHandler(client)
+
+ await handler.get_post(post_id=42, fields="featured_media,slug")
+ call = client.get.call_args
+ params = call.kwargs.get("params") or call.args[1]
+ wp_fields = set((params.get("_fields") or "").split(","))
+ assert "featured_media" in wp_fields
+ assert "slug" in wp_fields
+ assert "id" in wp_fields
diff --git a/tests/plugins/wordpress/test_media_attach.py b/tests/plugins/wordpress/test_media_attach.py
new file mode 100644
index 0000000..5bf7de3
--- /dev/null
+++ b/tests/plugins/wordpress/test_media_attach.py
@@ -0,0 +1,217 @@
+"""Tests for F.5a.3 WooCommerce attachment + featured-image tool."""
+
+from __future__ import annotations
+
+import base64
+import json
+from unittest.mock import AsyncMock, patch
+
+import pytest
+
+from plugins.wordpress.client import WordPressClient
+from plugins.wordpress.handlers.media_attach import (
+ MediaAttachHandler,
+ _merge_product_images,
+)
+
+_PNG_1x1 = base64.b64decode(
+ b"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNkYAAAAAYAAjCB0C8AAAAASUVORK5CYII="
+)
+
+
+def _client():
+ return WordPressClient(site_url="https://wp.example.com", username="u", app_password="p")
+
+
+# --- Pure merge logic -------------------------------------------------------
+
+
+class TestMergeImages:
+ def test_append_gallery_preserves_existing(self):
+ out = _merge_product_images([{"id": 1}, {"id": 2}], [3, 4], role="gallery", mode="append")
+ assert [i["id"] for i in out] == [1, 2, 3, 4]
+
+ def test_append_gallery_dedupes(self):
+ out = _merge_product_images([{"id": 1}, {"id": 2}], [2, 3], role="gallery", mode="append")
+ assert [i["id"] for i in out] == [1, 2, 3]
+
+ def test_replace_gallery_keeps_main(self):
+ out = _merge_product_images(
+ [{"id": 1}, {"id": 2}, {"id": 3}], [9], role="gallery", mode="replace"
+ )
+ assert [i["id"] for i in out] == [1, 9]
+
+ def test_replace_main_wipes_all(self):
+ out = _merge_product_images([{"id": 1}, {"id": 2}], [9], role="main", mode="replace")
+ assert [i["id"] for i in out] == [9]
+
+ def test_append_main_promotes_new_to_index0(self):
+ out = _merge_product_images([{"id": 1}, {"id": 2}], [9], role="main", mode="append")
+ assert out[0]["id"] == 9
+ # Existing images demoted but preserved
+ assert {i["id"] for i in out} == {9, 1, 2}
+
+
+# --- Handler integration ----------------------------------------------------
+
+
+class TestMediaAttachHandler:
+ @pytest.mark.asyncio
+ async def test_attach_happy_path(self):
+ handler = MediaAttachHandler(_client())
+ with (
+ patch.object(handler.client, "get", new=AsyncMock()) as mock_get,
+ patch.object(handler.client, "put", new=AsyncMock()) as mock_put,
+ ):
+ # media validation GETs + product GET
+ mock_get.side_effect = [
+ {"id": 10}, # media/10 exists
+ {"id": 11}, # media/11 exists
+ {"id": 50, "images": [{"id": 1}]}, # products/50
+ ]
+ mock_put.return_value = {
+ "id": 50,
+ "images": [{"id": 1, "src": "a"}, {"id": 10, "src": "b"}, {"id": 11, "src": "c"}],
+ }
+ out = await handler.attach_media_to_product(50, [10, 11], role="gallery", mode="append")
+ parsed = json.loads(out)
+ assert parsed["product_id"] == 50
+ assert [i["id"] for i in parsed["images"]] == [1, 10, 11]
+ # Verify PUT body
+ put_call = mock_put.await_args
+ assert put_call.kwargs["json_data"]["images"] == [{"id": 1}, {"id": 10}, {"id": 11}]
+ assert put_call.kwargs["use_woocommerce"] is True
+
+ @pytest.mark.asyncio
+ async def test_attach_rejects_missing_media(self):
+ handler = MediaAttachHandler(_client())
+ with patch.object(handler.client, "get", new=AsyncMock(side_effect=Exception("404"))):
+ out = await handler.attach_media_to_product(50, [999])
+ assert json.loads(out)["error_code"] == "MEDIA_NOT_FOUND"
+
+ @pytest.mark.asyncio
+ async def test_attach_rejects_bad_role(self):
+ handler = MediaAttachHandler(_client())
+ out = await handler.attach_media_to_product(50, [1], role="invalid")
+ assert json.loads(out)["error_code"] == "BAD_ROLE"
+
+ @pytest.mark.asyncio
+ async def test_attach_rejects_empty_media_ids(self):
+ handler = MediaAttachHandler(_client())
+ out = await handler.attach_media_to_product(50, [])
+ assert json.loads(out)["error_code"] == "MISSING_FIELD"
+
+ @pytest.mark.asyncio
+ async def test_upload_and_attach_base64(self):
+ handler = MediaAttachHandler(_client())
+ wp_media = {
+ "id": 77,
+ "title": {"rendered": "x"},
+ "source_url": "https://wp.example.com/x.png",
+ "mime_type": "image/png",
+ "media_type": "image",
+ }
+ with (
+ patch(
+ "plugins.wordpress.handlers.media_attach.wp_raw_upload",
+ new=AsyncMock(return_value=wp_media),
+ ),
+ patch(
+ "plugins.wordpress.handlers.media_attach.wp_update_media_metadata",
+ new=AsyncMock(),
+ ),
+ patch.object(
+ handler.client, "get", new=AsyncMock(return_value={"id": 77, "images": []})
+ ),
+ patch.object(
+ handler.client,
+ "put",
+ new=AsyncMock(return_value={"id": 50, "images": [{"id": 77, "src": "s"}]}),
+ ),
+ ):
+ out = await handler.upload_and_attach_to_product(
+ product_id=50,
+ source="base64",
+ filename="x.png",
+ data=base64.b64encode(_PNG_1x1).decode(),
+ role="main",
+ )
+ parsed = json.loads(out)
+ assert parsed["media_id"] == 77
+ assert parsed["product_id"] == 50
+
+ @pytest.mark.asyncio
+ async def test_set_featured_image_post(self):
+ """F.X.fix-pass5 — auto-detects "this is not a WC product",
+ falls through to /wp/v2/posts/{id} for regular posts/pages."""
+ handler = MediaAttachHandler(_client())
+
+ async def _fake_get(path, **kwargs):
+ if kwargs.get("use_woocommerce"):
+ raise Exception("404 — not a product")
+ return {"id": 9} # media exists
+
+ with (
+ patch.object(handler.client, "get", side_effect=_fake_get),
+ patch.object(
+ handler.client,
+ "post",
+ new=AsyncMock(return_value={"id": 100, "featured_media": 9}),
+ ) as mock_post,
+ ):
+ out = await handler.set_featured_image(post_id=100, media_id=9)
+ parsed = json.loads(out)
+ assert parsed["post_id"] == 100
+ assert parsed["featured_media"] == 9
+ assert parsed["context"] == "post"
+ mock_post.assert_awaited_once_with("posts/100", json_data={"featured_media": 9})
+
+ @pytest.mark.asyncio
+ async def test_set_featured_image_wc_product(self):
+ """F.X.fix-pass5 — when post_id is a WC product, route through
+ WC API (PUT /wc/v3/products/{id} with images[]) instead of
+ /wp/v2/posts/{id} which 404s for products."""
+ handler = MediaAttachHandler(_client())
+
+ async def _fake_get(path, **kwargs):
+ if kwargs.get("use_woocommerce") and path == "products/77":
+ return {"id": 77, "images": [{"id": 5}, {"id": 6}]}
+ return {"id": 9} # media exists
+
+ with (
+ patch.object(handler.client, "get", side_effect=_fake_get),
+ patch.object(
+ handler.client,
+ "put",
+ new=AsyncMock(return_value={"id": 77, "images": [{"id": 9}, {"id": 5}, {"id": 6}]}),
+ ) as mock_put,
+ patch.object(handler.client, "post", new=AsyncMock()) as mock_post,
+ ):
+ out = await handler.set_featured_image(post_id=77, media_id=9)
+ parsed = json.loads(out)
+ assert parsed["post_id"] == 77
+ assert parsed["featured_media"] == 9
+ assert parsed["context"] == "product"
+ # WC PUT was used, NOT /wp/v2/posts.
+ mock_put.assert_awaited_once()
+ assert mock_put.await_args.kwargs["use_woocommerce"] is True
+ assert mock_post.await_count == 0
+
+ @pytest.mark.asyncio
+ async def test_upload_and_attach_missing_data(self):
+ handler = MediaAttachHandler(_client())
+ out = await handler.upload_and_attach_to_product(
+ product_id=1, source="base64", filename="x.png"
+ )
+ assert json.loads(out)["error_code"] == "MISSING_FIELD"
+
+ @pytest.mark.asyncio
+ async def test_upload_and_attach_ssrf_blocked(self):
+ handler = MediaAttachHandler(_client())
+ out = await handler.upload_and_attach_to_product(
+ product_id=1,
+ source="url",
+ filename="x.png",
+ url="http://127.0.0.1/x.png",
+ )
+ assert json.loads(out)["error_code"] == "SSRF"
diff --git a/tests/plugins/wordpress/test_media_audit.py b/tests/plugins/wordpress/test_media_audit.py
new file mode 100644
index 0000000..8eb6104
--- /dev/null
+++ b/tests/plugins/wordpress/test_media_audit.py
@@ -0,0 +1,161 @@
+"""F.5a.6.4 — media.upload audit-log emission tests."""
+
+from __future__ import annotations
+
+import base64
+import json
+from unittest.mock import MagicMock
+
+import pytest
+
+from plugins.wordpress.client import WordPressClient
+from plugins.wordpress.handlers.media import MediaHandler
+
+# 1x1 PNG — passes magic-byte sniff and size validation.
+_PNG_1x1 = base64.b64decode(
+ b"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNkYAAAAAYAAjCB0C8AAAAASUVORK5CYII="
+)
+
+
+@pytest.fixture
+def wp_client():
+ return WordPressClient(site_url="https://wp.example.com", username="u", app_password="p")
+
+
+@pytest.fixture
+def fake_audit(monkeypatch):
+ """Replace the singleton audit logger with a MagicMock."""
+ fake = MagicMock()
+ fake.log_tool_call = MagicMock()
+ monkeypatch.setattr("core.audit_log.get_audit_logger", lambda: fake)
+ return fake
+
+
+def _patch_wp_upload(monkeypatch, *, media_id=42, mime="image/png"):
+ """Stub wp_raw_upload at its import site inside media.py."""
+
+ async def fake_upload(client, data, *, filename, mime_hint=None, **kw):
+ return {
+ "id": media_id,
+ "mime_type": mime,
+ "media_type": "image",
+ "source_url": f"https://wp.example.com/wp-content/uploads/{filename}",
+ "title": {"rendered": filename},
+ "media_details": {"filesize": len(data)},
+ }
+
+ monkeypatch.setattr("plugins.wordpress.handlers.media.wp_raw_upload", fake_upload)
+
+
+@pytest.mark.asyncio
+async def test_base64_upload_emits_one_audit_entry(wp_client, fake_audit, monkeypatch):
+ _patch_wp_upload(monkeypatch)
+ handler = MediaHandler(wp_client, user_id="alice")
+
+ out = json.loads(
+ await handler.upload_media_from_base64(
+ data=base64.b64encode(_PNG_1x1).decode(),
+ filename="x.png",
+ skip_optimize=True,
+ )
+ )
+ assert out["id"] == 42
+
+ assert fake_audit.log_tool_call.call_count == 1
+ call = fake_audit.log_tool_call.call_args
+ assert call.kwargs["tool_name"] == "media.upload"
+ assert call.kwargs["user_id"] == "alice"
+ p = call.kwargs["params"]
+ assert p["source"] == "base64"
+ assert p["mime"] == "image/png"
+ assert p["media_id"] == 42
+ assert p["size_bytes"] == len(_PNG_1x1)
+ assert p["site"] == "https://wp.example.com"
+ assert "cost_usd" not in p
+
+
+@pytest.mark.asyncio
+async def test_base64_failure_writes_no_audit_entry(wp_client, fake_audit, monkeypatch):
+ """Decode failure short-circuits before upload — no audit entry."""
+ handler = MediaHandler(wp_client, user_id="alice")
+ out = json.loads(
+ await handler.upload_media_from_base64(
+ data="!!! not valid base64 !!!",
+ filename="x.png",
+ )
+ )
+ assert out["error_code"] == "BAD_BASE64"
+ assert fake_audit.log_tool_call.call_count == 0
+
+
+@pytest.mark.asyncio
+async def test_url_upload_emits_one_audit_entry(wp_client, fake_audit, monkeypatch):
+ _patch_wp_upload(monkeypatch)
+ handler = MediaHandler(wp_client, user_id=None) # admin/env
+
+ # Bypass SSRF + network: stub fetch_url_bytes.
+ async def fake_fetch(url, **kw):
+ return _PNG_1x1, "image/png", "remote.png"
+
+ monkeypatch.setattr("plugins.wordpress.handlers.media.fetch_url_bytes", fake_fetch)
+ monkeypatch.setattr(
+ "plugins.wordpress.handlers.media.ssrf_check",
+ lambda url: type("S", (), {"allowed": True, "reason": None})(),
+ )
+
+ out = json.loads(
+ await handler.upload_media_from_url(url="https://cdn.example.com/x.png", skip_optimize=True)
+ )
+ assert out["id"] == 42
+
+ assert fake_audit.log_tool_call.call_count == 1
+ call = fake_audit.log_tool_call.call_args
+ assert call.kwargs["params"]["source"] == "url"
+ assert call.kwargs["user_id"] is None # admin
+
+
+@pytest.mark.asyncio
+async def test_chunked_finish_emits_one_audit_entry(wp_client, fake_audit, monkeypatch):
+ """Chunked finish should emit a media.upload entry with source='chunked'."""
+ from plugins.wordpress.handlers.media_chunked import MediaChunkedHandler
+
+ _patch_wp_upload(monkeypatch)
+
+ # Stub the chunked store's finalize() to return assembled bytes.
+ class _FakeSession:
+ filename = "big.png"
+ mime = "image/png"
+
+ class _FakeStore:
+ async def finalize(self, sid):
+ return _FakeSession(), _PNG_1x1
+
+ monkeypatch.setattr(
+ "plugins.wordpress.handlers.media_chunked.wp_raw_upload",
+ lambda *a, **k: _patch_wp_upload, # not used; media.wp_raw_upload is patched
+ )
+
+ # The chunked handler calls wp_raw_upload imported into media_chunked.py
+ async def fake_upload(client, data, *, filename, mime_hint=None, **kw):
+ return {
+ "id": 99,
+ "mime_type": "image/png",
+ "media_type": "image",
+ "source_url": "https://wp.example.com/wp-content/uploads/big.png",
+ "title": {"rendered": "big.png"},
+ "media_details": {"filesize": len(data)},
+ }
+
+ monkeypatch.setattr("plugins.wordpress.handlers.media_chunked.wp_raw_upload", fake_upload)
+
+ handler = MediaChunkedHandler(wp_client, user_id="bob", store=_FakeStore())
+ out = json.loads(
+ await handler.upload_media_chunked_finish(session_id="sid", skip_optimize=True)
+ )
+ assert out["id"] == 99
+
+ assert fake_audit.log_tool_call.call_count == 1
+ call = fake_audit.log_tool_call.call_args
+ assert call.kwargs["params"]["source"] == "chunked"
+ assert call.kwargs["params"]["media_id"] == 99
+ assert call.kwargs["user_id"] == "bob"
diff --git a/tests/plugins/wordpress/test_media_bulk.py b/tests/plugins/wordpress/test_media_bulk.py
new file mode 100644
index 0000000..6bbeea9
--- /dev/null
+++ b/tests/plugins/wordpress/test_media_bulk.py
@@ -0,0 +1,216 @@
+"""F.5a.8.3 — Tests for bulk_delete_media + bulk_reassign_media."""
+
+from __future__ import annotations
+
+import json
+from unittest.mock import AsyncMock
+
+import pytest
+
+from plugins.wordpress.client import WordPressClient
+from plugins.wordpress.handlers.media_bulk import (
+ MediaBulkHandler,
+ get_tool_specifications,
+)
+
+
+@pytest.fixture
+def wp_client():
+ return WordPressClient(site_url="https://wp.example.com", username="u", app_password="p")
+
+
+@pytest.fixture
+def handler(wp_client):
+ return MediaBulkHandler(wp_client)
+
+
+# ---------------------------------------------------------------------------
+# Spec
+# ---------------------------------------------------------------------------
+
+
+def test_specs_expose_both_tools():
+ specs = get_tool_specifications()
+ names = {s["name"] for s in specs}
+ assert names == {"bulk_delete_media", "bulk_reassign_media"}
+
+ delete_spec = next(s for s in specs if s["name"] == "bulk_delete_media")
+ assert delete_spec["scope"] == "admin"
+ assert delete_spec["schema"]["properties"]["media_ids"]["maxItems"] == 100
+
+ reassign_spec = next(s for s in specs if s["name"] == "bulk_reassign_media")
+ assert reassign_spec["scope"] == "write"
+ assert "target_post" in reassign_spec["schema"]["required"]
+
+
+# ---------------------------------------------------------------------------
+# Input normalization
+# ---------------------------------------------------------------------------
+
+
+def test_normalize_ids_dedupes_and_caps(handler):
+ huge = [5, 5, 5] + list(range(1, 200))
+ out = handler._normalize_ids(huge)
+ assert len(out) == 100
+ assert len(out) == len(set(out))
+
+
+def test_normalize_ids_drops_non_positive_and_garbage(handler):
+ out = handler._normalize_ids([1, 0, -5, "abc", None, 2, 3])
+ assert out == [1, 2, 3]
+
+
+# ---------------------------------------------------------------------------
+# bulk_delete_media
+# ---------------------------------------------------------------------------
+
+
+@pytest.mark.asyncio
+async def test_delete_rejects_empty_list(handler, wp_client, monkeypatch):
+ delete_mock = AsyncMock()
+ monkeypatch.setattr(wp_client, "delete", delete_mock)
+
+ out = json.loads(await handler.bulk_delete_media([]))
+ assert out["ok"] is False
+ assert out["error"] == "invalid_request"
+ delete_mock.assert_not_called()
+
+
+@pytest.mark.asyncio
+async def test_delete_happy_path(handler, wp_client, monkeypatch):
+ calls: list[tuple[str, dict]] = []
+
+ async def _delete(path, params=None):
+ calls.append((path, params or {}))
+ return {"deleted": True}
+
+ monkeypatch.setattr(wp_client, "delete", _delete)
+
+ out = json.loads(await handler.bulk_delete_media([10, 20, 30], force=True))
+ assert out["ok"] is True
+ assert out["total"] == 3
+ assert out["processed"] == 3
+ assert out["errors"] == []
+ assert out["force"] is True
+ assert len(calls) == 3
+ # force flag propagated as string
+ assert all(c[1].get("force") == "true" for c in calls)
+ paths = sorted(c[0] for c in calls)
+ assert paths == ["media/10", "media/20", "media/30"]
+
+
+@pytest.mark.asyncio
+async def test_delete_partial_failure(handler, wp_client, monkeypatch):
+ async def _delete(path, params=None):
+ if path == "media/20":
+ raise RuntimeError("HTTP 404 not found")
+ return {"deleted": True}
+
+ monkeypatch.setattr(wp_client, "delete", _delete)
+
+ out = json.loads(await handler.bulk_delete_media([10, 20, 30]))
+ assert out["ok"] is False
+ assert out["total"] == 3
+ assert out["processed"] == 2
+ assert len(out["errors"]) == 1
+ assert out["errors"][0]["id"] == 20
+ assert "404" in out["errors"][0]["error"]
+
+
+@pytest.mark.asyncio
+async def test_delete_force_false_moves_to_trash(handler, wp_client, monkeypatch):
+ captured: list[dict] = []
+
+ async def _delete(path, params=None):
+ captured.append(params or {})
+ return {}
+
+ monkeypatch.setattr(wp_client, "delete", _delete)
+ await handler.bulk_delete_media([1], force=False)
+ assert captured[0]["force"] == "false"
+
+
+# ---------------------------------------------------------------------------
+# bulk_reassign_media
+# ---------------------------------------------------------------------------
+
+
+@pytest.mark.asyncio
+async def test_reassign_rejects_empty_ids(handler, wp_client, monkeypatch):
+ post_mock = AsyncMock()
+ monkeypatch.setattr(wp_client, "post", post_mock)
+
+ out = json.loads(await handler.bulk_reassign_media([], target_post=5))
+ assert out["ok"] is False
+ assert out["error"] == "invalid_request"
+ post_mock.assert_not_called()
+
+
+@pytest.mark.asyncio
+async def test_reassign_rejects_negative_target(handler, wp_client, monkeypatch):
+ post_mock = AsyncMock()
+ monkeypatch.setattr(wp_client, "post", post_mock)
+
+ out = json.loads(await handler.bulk_reassign_media([1], target_post=-1))
+ assert out["ok"] is False
+ assert out["error"] == "invalid_request"
+ post_mock.assert_not_called()
+
+
+@pytest.mark.asyncio
+async def test_reassign_rejects_non_integer_target(handler, wp_client, monkeypatch):
+ post_mock = AsyncMock()
+ monkeypatch.setattr(wp_client, "post", post_mock)
+
+ out = json.loads(await handler.bulk_reassign_media([1], target_post="abc")) # type: ignore[arg-type]
+ assert out["ok"] is False
+ assert out["error"] == "invalid_request"
+ post_mock.assert_not_called()
+
+
+@pytest.mark.asyncio
+async def test_reassign_happy_path(handler, wp_client, monkeypatch):
+ calls: list[tuple[str, dict]] = []
+
+ async def _post(path, json_data=None, **kw):
+ calls.append((path, json_data or {}))
+ return {"id": 1, "post": 42}
+
+ monkeypatch.setattr(wp_client, "post", _post)
+
+ out = json.loads(await handler.bulk_reassign_media([1, 2, 3], target_post=42))
+ assert out["ok"] is True
+ assert out["processed"] == 3
+ assert out["total"] == 3
+ assert out["target_post"] == 42
+ assert len(calls) == 3
+ assert all(c[1] == {"post": 42} for c in calls)
+
+
+@pytest.mark.asyncio
+async def test_reassign_detach_target_zero(handler, wp_client, monkeypatch):
+ calls: list[dict] = []
+
+ async def _post(path, json_data=None, **kw):
+ calls.append(json_data or {})
+ return {}
+
+ monkeypatch.setattr(wp_client, "post", _post)
+ out = json.loads(await handler.bulk_reassign_media([1, 2], target_post=0))
+ assert out["ok"] is True
+ assert all(c == {"post": 0} for c in calls)
+
+
+@pytest.mark.asyncio
+async def test_reassign_partial_failure_surfaces_errors(handler, wp_client, monkeypatch):
+ async def _post(path, json_data=None, **kw):
+ if path == "media/7":
+ raise RuntimeError("permission denied")
+ return {}
+
+ monkeypatch.setattr(wp_client, "post", _post)
+ out = json.loads(await handler.bulk_reassign_media([6, 7, 8], target_post=100))
+ assert out["ok"] is False
+ assert out["processed"] == 2
+ assert len(out["errors"]) == 1
+ assert out["errors"][0]["id"] == 7
diff --git a/tests/plugins/wordpress/test_media_chunked.py b/tests/plugins/wordpress/test_media_chunked.py
new file mode 100644
index 0000000..2691ef3
--- /dev/null
+++ b/tests/plugins/wordpress/test_media_chunked.py
@@ -0,0 +1,264 @@
+"""Tests for F.5a.5 chunked media upload (session store + WP handler)."""
+
+from __future__ import annotations
+
+import asyncio
+import base64 as _b64
+import hashlib
+import json
+from datetime import UTC, datetime, timedelta
+from pathlib import Path
+from unittest.mock import AsyncMock, patch
+
+import pytest
+
+from core.database import Database
+from core.upload_sessions import (
+ CleanupTask,
+ UploadSessionError,
+ UploadSessionStore,
+ make_session_id,
+ set_upload_session_store,
+)
+from plugins.wordpress.client import WordPressClient
+from plugins.wordpress.handlers.media_chunked import MediaChunkedHandler
+
+_PNG_1x1 = _b64.b64decode(
+ b"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNkYAAAAAYAAjCB0C8AAAAASUVORK5CYII="
+)
+
+
+def _client() -> WordPressClient:
+ return WordPressClient(site_url="https://wp.example.com", username="u", app_password="p")
+
+
+@pytest.fixture
+async def db(tmp_path: Path):
+ d = Database(str(tmp_path / "test.db"))
+ await d.initialize()
+ try:
+ yield d
+ finally:
+ await d.close()
+
+
+@pytest.fixture
+async def store(tmp_path: Path, db: Database) -> UploadSessionStore:
+ s = UploadSessionStore(db=db, spill_dir=tmp_path / "spill")
+ set_upload_session_store(s)
+ try:
+ yield s
+ finally:
+ set_upload_session_store(None)
+
+
+@pytest.fixture
+def handler(store: UploadSessionStore) -> MediaChunkedHandler:
+ return MediaChunkedHandler(_client(), user_id="alice", store=store)
+
+
+def _wp_response_mock(wp_response: dict):
+ mock_resp = AsyncMock()
+ mock_resp.status = 201
+ mock_resp.text = AsyncMock(return_value=json.dumps(wp_response))
+ mock_resp.json = AsyncMock(return_value=wp_response)
+ mock_sess = AsyncMock()
+ mock_sess.post = lambda *a, **kw: AsyncMock(
+ __aenter__=AsyncMock(return_value=mock_resp),
+ __aexit__=AsyncMock(return_value=False),
+ )
+ cls_mock = AsyncMock(
+ __aenter__=AsyncMock(return_value=mock_sess),
+ __aexit__=AsyncMock(return_value=False),
+ )
+ return cls_mock
+
+
+class TestSessionStoreBasics:
+ @pytest.mark.asyncio
+ async def test_deterministic_session_id(self):
+ a = make_session_id("u1", "a.bin", 100, None, None)
+ b = make_session_id("u1", "a.bin", 100, None, None)
+ c = make_session_id("u2", "a.bin", 100, None, None)
+ assert a == b
+ assert a != c
+
+ @pytest.mark.asyncio
+ async def test_start_creates_row_and_spill(self, store: UploadSessionStore):
+ sess = await store.start(user_id="alice", filename="f.bin", total_bytes=10)
+ assert sess.status == "open"
+ assert sess.spill_path.exists()
+ assert sess.received_bytes == 0
+
+ @pytest.mark.asyncio
+ async def test_start_is_idempotent_for_same_tuple(self, store: UploadSessionStore):
+ s1 = await store.start(user_id="alice", filename="f.bin", total_bytes=10)
+ s2 = await store.start(user_id="alice", filename="f.bin", total_bytes=10)
+ assert s1.id == s2.id
+
+ @pytest.mark.asyncio
+ async def test_hard_cap_rejects(self, store: UploadSessionStore):
+ store.max_session_bytes = 1024
+ with pytest.raises(UploadSessionError) as e:
+ await store.start(user_id="alice", filename="big.bin", total_bytes=2048)
+ assert e.value.code == "SESSION_TOO_LARGE"
+
+
+class TestAppendAndFinalize:
+ @pytest.mark.asyncio
+ async def test_round_trip_two_chunks(self, store: UploadSessionStore):
+ payload = b"abcdefghij" # 10 bytes
+ sess = await store.start(user_id="alice", filename="f.bin", total_bytes=10)
+ await store.append_chunk(sess.id, 0, payload[:6])
+ await store.append_chunk(sess.id, 1, payload[6:])
+ sess2, data = await store.finalize(sess.id)
+ assert data == payload
+ assert not sess2.spill_path.exists()
+
+ @pytest.mark.asyncio
+ async def test_chunk_out_of_order(self, store: UploadSessionStore):
+ sess = await store.start(user_id="alice", filename="f.bin", total_bytes=10)
+ await store.append_chunk(sess.id, 0, b"abcde")
+ with pytest.raises(UploadSessionError) as e:
+ await store.append_chunk(sess.id, 2, b"fgh")
+ assert e.value.code == "CHUNK_ORDER"
+
+ @pytest.mark.asyncio
+ async def test_chunk_overflow(self, store: UploadSessionStore):
+ sess = await store.start(user_id="alice", filename="f.bin", total_bytes=5)
+ with pytest.raises(UploadSessionError) as e:
+ await store.append_chunk(sess.id, 0, b"toolongdata")
+ assert e.value.code == "CHUNK_OVERFLOW"
+
+ @pytest.mark.asyncio
+ async def test_finalize_sha_mismatch_keeps_session(self, store: UploadSessionStore):
+ payload = b"hello world"
+ wrong = hashlib.sha256(b"other").hexdigest()
+ sess = await store.start(
+ user_id="alice",
+ filename="f.bin",
+ total_bytes=len(payload),
+ sha256=wrong,
+ )
+ await store.append_chunk(sess.id, 0, payload)
+ with pytest.raises(UploadSessionError) as e:
+ await store.finalize(sess.id)
+ assert e.value.code == "CHECKSUM_MISMATCH"
+ # Session still present — spill file still on disk
+ again = await store.get(sess.id)
+ assert again is not None
+ assert again.spill_path.exists()
+
+ @pytest.mark.asyncio
+ async def test_finalize_sha_match_passes(self, store: UploadSessionStore):
+ payload = b"hello world"
+ digest = hashlib.sha256(payload).hexdigest()
+ sess = await store.start(
+ user_id="alice",
+ filename="f.bin",
+ total_bytes=len(payload),
+ sha256=digest,
+ )
+ await store.append_chunk(sess.id, 0, payload)
+ _, data = await store.finalize(sess.id)
+ assert data == payload
+
+ @pytest.mark.asyncio
+ async def test_abort_removes_spill(self, store: UploadSessionStore):
+ sess = await store.start(user_id="alice", filename="f.bin", total_bytes=10)
+ spill = sess.spill_path
+ assert spill.exists()
+ assert await store.abort(sess.id) is True
+ assert not spill.exists()
+ assert await store.get(sess.id) is None
+
+
+class TestQuotaAndCleanup:
+ @pytest.mark.asyncio
+ async def test_quota_rejects_11th_session(self, store: UploadSessionStore):
+ store.max_concurrent_per_user = 10
+ ids = set()
+ for i in range(10):
+ s = await store.start(user_id="bob", filename=f"f{i}.bin", total_bytes=10)
+ ids.add(s.id)
+ assert len(ids) == 10
+ with pytest.raises(UploadSessionError) as e:
+ await store.start(user_id="bob", filename="f10.bin", total_bytes=10)
+ assert e.value.code == "QUOTA_EXCEEDED"
+
+ @pytest.mark.asyncio
+ async def test_cleanup_reaps_expired(self, store: UploadSessionStore, db: Database):
+ sess = await store.start(user_id="alice", filename="f.bin", total_bytes=10)
+ spill = sess.spill_path
+ # Force-expire via DB update
+ past = (datetime.now(UTC) - timedelta(hours=2)).isoformat()
+ await db.execute("UPDATE upload_sessions SET expires_at = ? WHERE id = ?", (past, sess.id))
+ reaped = await store.cleanup_expired()
+ assert reaped == 1
+ assert not spill.exists()
+ assert await store.get(sess.id) is None
+
+ @pytest.mark.asyncio
+ async def test_cleanup_task_runs(self, store: UploadSessionStore, db: Database):
+ sess = await store.start(user_id="alice", filename="f.bin", total_bytes=10)
+ past = (datetime.now(UTC) - timedelta(hours=2)).isoformat()
+ await db.execute("UPDATE upload_sessions SET expires_at = ? WHERE id = ?", (past, sess.id))
+ task = CleanupTask(store=store, interval_seconds=60)
+ await task.start()
+ # Give the loop a tick to run its first iteration.
+ for _ in range(20):
+ if await store.get(sess.id) is None:
+ break
+ await asyncio.sleep(0.05)
+ await task.stop()
+ assert await store.get(sess.id) is None
+
+
+class TestHandlerIntegration:
+ @pytest.mark.asyncio
+ async def test_handler_round_trip_calls_wp_once(self, handler: MediaChunkedHandler):
+ payload = _PNG_1x1
+ # Start
+ start_out = json.loads(
+ await handler.upload_media_chunked_start(filename="photo.png", total_bytes=len(payload))
+ )
+ sid = start_out["session_id"]
+ half = len(payload) // 2
+ c0 = _b64.b64encode(payload[:half]).decode()
+ c1 = _b64.b64encode(payload[half:]).decode()
+ await handler.upload_media_chunked_chunk(sid, 0, c0)
+ await handler.upload_media_chunked_chunk(sid, 1, c1)
+
+ wp_response = {
+ "id": 999,
+ "title": {"rendered": "photo"},
+ "source_url": "https://wp.example.com/photo.png",
+ "mime_type": "image/png",
+ "media_type": "image",
+ }
+ with patch("plugins.wordpress.handlers._media_core.aiohttp.ClientSession") as mock_cls:
+ mock_cls.return_value = _wp_response_mock(wp_response)
+ out = json.loads(await handler.upload_media_chunked_finish(session_id=sid))
+ # No metadata/attach args → only the raw-upload ClientSession() call
+ assert mock_cls.call_count == 1
+ assert out["id"] == 999
+ assert out["source"] == "chunked"
+
+ @pytest.mark.asyncio
+ async def test_handler_abort(self, handler: MediaChunkedHandler):
+ start_out = json.loads(
+ await handler.upload_media_chunked_start(filename="f.bin", total_bytes=10)
+ )
+ sid = start_out["session_id"]
+ out = json.loads(await handler.upload_media_chunked_abort(sid))
+ assert out["aborted"] is True
+
+ @pytest.mark.asyncio
+ async def test_handler_chunk_order_returns_error_json(self, handler: MediaChunkedHandler):
+ start_out = json.loads(
+ await handler.upload_media_chunked_start(filename="f.bin", total_bytes=10)
+ )
+ sid = start_out["session_id"]
+ chunk_b64 = _b64.b64encode(b"abc").decode()
+ out = json.loads(await handler.upload_media_chunked_chunk(sid, 5, chunk_b64))
+ assert out["error_code"] == "CHUNK_ORDER"
diff --git a/tests/plugins/wordpress/test_media_chunked_status.py b/tests/plugins/wordpress/test_media_chunked_status.py
new file mode 100644
index 0000000..4b760b3
--- /dev/null
+++ b/tests/plugins/wordpress/test_media_chunked_status.py
@@ -0,0 +1,129 @@
+"""F.5a.8.4 — resumable chunked upload status tool.
+
+Verifies the new ``upload_media_chunked_status`` tool returns the
+expected ``received_bytes`` / ``next_chunk`` for active sessions and
+yields a ``NOT_FOUND`` error for unknown / finalised / aborted ones.
+"""
+
+from __future__ import annotations
+
+import base64
+import json
+from pathlib import Path
+
+import pytest
+
+from core.database import Database
+from core.upload_sessions import (
+ UploadSessionStore,
+ set_upload_session_store,
+)
+from plugins.wordpress.client import WordPressClient
+from plugins.wordpress.handlers.media_chunked import (
+ MediaChunkedHandler,
+ get_tool_specifications,
+)
+
+
+@pytest.fixture
+async def _db(tmp_path: Path):
+ db = Database(str(tmp_path / "status.db"))
+ await db.initialize()
+ yield db
+ await db.close()
+
+
+@pytest.fixture
+async def _store(tmp_path: Path, _db: Database):
+ s = UploadSessionStore(db=_db, spill_dir=tmp_path / "spill")
+ set_upload_session_store(s)
+ try:
+ yield s
+ finally:
+ set_upload_session_store(None)
+
+
+@pytest.fixture
+def _handler(_store: UploadSessionStore) -> MediaChunkedHandler:
+ client = WordPressClient(site_url="https://wp.example.com", username="u", app_password="p")
+ return MediaChunkedHandler(client, user_id="alice", store=_store)
+
+
+class TestStatusToolSpec:
+ @pytest.mark.unit
+ def test_status_tool_is_registered(self):
+ specs = get_tool_specifications()
+ names = [s["name"] for s in specs]
+ assert "upload_media_chunked_status" in names
+
+ @pytest.mark.unit
+ def test_status_tool_is_read_scope(self):
+ specs = get_tool_specifications()
+ status = next(s for s in specs if s["name"] == "upload_media_chunked_status")
+ assert status["scope"] == "read"
+ assert status["schema"]["required"] == ["session_id"]
+
+
+class TestStatusLookup:
+ @pytest.mark.asyncio
+ async def test_unknown_session_returns_no_session(self, _handler):
+ out = json.loads(await _handler.upload_media_chunked_status(session_id="does-not-exist"))
+ # NO_SESSION is the documented taxonomy code used by the rest of
+ # the chunked-session surface for exactly this case.
+ assert out["error_code"] == "NO_SESSION"
+ assert out["session_id"] == "does-not-exist"
+
+ @pytest.mark.asyncio
+ async def test_fresh_session_reports_zero_received(self, _handler):
+ started = json.loads(
+ await _handler.upload_media_chunked_start(filename="big.mp4", total_bytes=1_000_000)
+ )
+ sid = started["session_id"]
+
+ out = json.loads(await _handler.upload_media_chunked_status(session_id=sid))
+ assert out["session_id"] == sid
+ assert out["received_bytes"] == 0
+ assert out["next_chunk"] == 0
+ assert out["total_bytes"] == 1_000_000
+
+ @pytest.mark.asyncio
+ async def test_partial_session_reports_progress(self, _handler):
+ started = json.loads(
+ await _handler.upload_media_chunked_start(filename="big.mp4", total_bytes=100)
+ )
+ sid = started["session_id"]
+
+ chunk = base64.b64encode(b"A" * 40).decode()
+ await _handler.upload_media_chunked_chunk(session_id=sid, index=0, data_b64=chunk)
+
+ out = json.loads(await _handler.upload_media_chunked_status(session_id=sid))
+ assert out["received_bytes"] == 40
+ assert out["next_chunk"] == 1
+ # Still open for more chunks.
+ assert out["status"] == "open"
+
+ @pytest.mark.asyncio
+ async def test_resume_flow_via_status_then_chunk(self, _handler):
+ """End-to-end resume: start → chunk → (simulate disconnect) →
+ status → chunk from next_chunk → progress should advance."""
+ started = json.loads(
+ await _handler.upload_media_chunked_start(filename="big.bin", total_bytes=60)
+ )
+ sid = started["session_id"]
+
+ first = base64.b64encode(b"x" * 20).decode()
+ await _handler.upload_media_chunked_chunk(session_id=sid, index=0, data_b64=first)
+
+ # Simulate client coming back from a disconnect.
+ status = json.loads(await _handler.upload_media_chunked_status(session_id=sid))
+ resume_idx = status["next_chunk"]
+ assert resume_idx == 1
+
+ second = base64.b64encode(b"y" * 25).decode()
+ progressed = json.loads(
+ await _handler.upload_media_chunked_chunk(
+ session_id=sid, index=resume_idx, data_b64=second
+ )
+ )
+ assert progressed["received_bytes"] == 45
+ assert progressed["next_chunk"] == 2
diff --git a/tests/plugins/wordpress/test_media_companion_upload.py b/tests/plugins/wordpress/test_media_companion_upload.py
new file mode 100644
index 0000000..819ef5d
--- /dev/null
+++ b/tests/plugins/wordpress/test_media_companion_upload.py
@@ -0,0 +1,235 @@
+"""F.5a.7 — Companion upload route selection tests.
+
+Validates `_media_core.wp_raw_upload` behaviour when the
+`airano-mcp/v1/upload-limits` probe advertises the companion plugin:
+
+- helper-present + size > advertised limit → POST to companion upload-chunk
+- helper-present + size < advertised limit → POST to standard /wp/v2/media
+- helper-absent → always POST to /wp/v2/media (no probe = no companion)
+- companion 4xx → fall back to /wp/v2/media (never regress default path)
+"""
+
+from __future__ import annotations
+
+import base64
+import json
+from contextlib import asynccontextmanager
+from unittest.mock import patch
+
+import pytest
+
+from plugins.wordpress.client import WordPressClient
+from plugins.wordpress.handlers import _media_core as media_core
+from plugins.wordpress.handlers._media_core import wp_raw_upload
+from plugins.wordpress.handlers.media_probe import _ProbeCache
+
+# A minimal valid 1x1 PNG.
+_PNG_1x1 = base64.b64decode(
+ b"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNkYAAAAAYAAjCB0C8AAAAASUVORK5CYII="
+)
+
+
+def _client() -> WordPressClient:
+ return WordPressClient(site_url="https://wp.example.com", username="u", app_password="p")
+
+
+class _FakeResponse:
+ def __init__(self, status: int, payload: dict | None, *, text: str | None = None) -> None:
+ self.status = status
+ self._payload = payload
+ self._text = text if text is not None else json.dumps(payload or {})
+
+ async def text(self) -> str:
+ return self._text
+
+ async def json(self, content_type=None): # noqa: D401, ANN001
+ return self._payload
+
+ async def __aenter__(self):
+ return self
+
+ async def __aexit__(self, *exc): # noqa: ANN002
+ return False
+
+
+class _RecordingSession:
+ """aiohttp.ClientSession stand-in that records every POST target."""
+
+ def __init__(self, responses_by_url: dict[str, _FakeResponse]) -> None:
+ self._responses_by_url = responses_by_url
+ self.posts: list[str] = []
+
+ def post(self, url, data=None, headers=None): # noqa: ANN001
+ self.posts.append(url)
+ resp = self._responses_by_url.get(url)
+ if resp is None:
+ raise AssertionError(f"unexpected POST to {url!r}")
+ return resp
+
+ async def __aenter__(self):
+ return self
+
+ async def __aexit__(self, *exc): # noqa: ANN002
+ return False
+
+
+@asynccontextmanager
+async def _patched_session(session: _RecordingSession):
+ with patch("plugins.wordpress.handlers._media_core.aiohttp.ClientSession") as cls:
+ cls.return_value = session
+ yield cls
+
+
+# WP attachment JSON shape (identical across /wp/v2/media and companion route).
+_WP_MEDIA_JSON = {
+ "id": 101,
+ "title": {"rendered": "photo"},
+ "mime_type": "image/png",
+ "media_type": "image",
+ "source_url": "https://wp.example.com/wp-content/uploads/photo.png",
+}
+
+
+def _rest_url() -> str:
+ return "https://wp.example.com/wp-json/wp/v2/media"
+
+
+def _companion_url() -> str:
+ return "https://wp.example.com/wp-json/airano-mcp/v1/upload-chunk"
+
+
+async def _seed_probe_cache(
+ client: WordPressClient,
+ *,
+ companion: bool,
+ upload_max_filesize_bytes: int | None = None,
+) -> _ProbeCache:
+ """Inject a cached probe result so wp_raw_upload sees it synchronously."""
+ cache = _ProbeCache(ttl=3600)
+ limits = {
+ "upload_max_filesize": None,
+ "post_max_size": None,
+ "memory_limit": None,
+ "max_input_time": None,
+ "wp_max_upload_size": None,
+ }
+ if upload_max_filesize_bytes is not None:
+ limits["upload_max_filesize"] = upload_max_filesize_bytes # already bytes
+ payload = {
+ "site_url": client.site_url,
+ "source": "companion" if companion else "rest_index",
+ "companion_available": companion,
+ "limits": limits,
+ "limits_bytes": {
+ "upload_max_filesize": upload_max_filesize_bytes,
+ "post_max_size": None,
+ "wp_max_upload_size": None,
+ "effective_ceiling": upload_max_filesize_bytes,
+ },
+ }
+ await cache.set((client.site_url, client.username), payload)
+ return cache
+
+
+@pytest.mark.asyncio
+async def test_companion_preferred_when_size_exceeds_limit(monkeypatch):
+ client = _client()
+ cache = await _seed_probe_cache(
+ client, companion=True, upload_max_filesize_bytes=4 # 4 bytes → our 1x1 PNG is larger
+ )
+ monkeypatch.setattr("plugins.wordpress.handlers.media_probe._cache", cache)
+
+ session = _RecordingSession(
+ {_companion_url(): _FakeResponse(201, _WP_MEDIA_JSON)},
+ )
+ async with _patched_session(session):
+ result = await wp_raw_upload(client, _PNG_1x1, filename="photo.png")
+
+ assert session.posts == [_companion_url()]
+ assert result["id"] == 101
+ assert result.get("_upload_route") == "companion"
+
+
+@pytest.mark.asyncio
+async def test_rest_used_when_size_under_limit(monkeypatch):
+ client = _client()
+ # Advertise a huge limit so the PNG slips under it.
+ cache = await _seed_probe_cache(client, companion=True, upload_max_filesize_bytes=10 * 1024**2)
+ monkeypatch.setattr("plugins.wordpress.handlers.media_probe._cache", cache)
+
+ session = _RecordingSession(
+ {_rest_url(): _FakeResponse(201, _WP_MEDIA_JSON)},
+ )
+ async with _patched_session(session):
+ result = await wp_raw_upload(client, _PNG_1x1, filename="photo.png")
+
+ assert session.posts == [_rest_url()]
+ assert result["id"] == 101
+ assert result.get("_upload_route") == "rest"
+
+
+@pytest.mark.asyncio
+async def test_rest_used_when_companion_absent(monkeypatch):
+ client = _client()
+ cache = await _seed_probe_cache(client, companion=False)
+ monkeypatch.setattr("plugins.wordpress.handlers.media_probe._cache", cache)
+
+ session = _RecordingSession(
+ {_rest_url(): _FakeResponse(201, _WP_MEDIA_JSON)},
+ )
+ async with _patched_session(session):
+ await wp_raw_upload(client, _PNG_1x1, filename="photo.png")
+
+ assert session.posts == [_rest_url()]
+
+
+@pytest.mark.asyncio
+async def test_rest_used_when_cache_empty(monkeypatch):
+ """Cold cache = no companion hint available = take the standard route."""
+ client = _client()
+ empty_cache = _ProbeCache(ttl=3600)
+ monkeypatch.setattr("plugins.wordpress.handlers.media_probe._cache", empty_cache)
+
+ session = _RecordingSession(
+ {_rest_url(): _FakeResponse(201, _WP_MEDIA_JSON)},
+ )
+ async with _patched_session(session):
+ await wp_raw_upload(client, _PNG_1x1, filename="photo.png")
+
+ assert session.posts == [_rest_url()]
+
+
+@pytest.mark.asyncio
+async def test_companion_4xx_falls_back_to_rest(monkeypatch):
+ client = _client()
+ cache = await _seed_probe_cache(client, companion=True, upload_max_filesize_bytes=4)
+ monkeypatch.setattr("plugins.wordpress.handlers.media_probe._cache", cache)
+
+ session = _RecordingSession(
+ {
+ _companion_url(): _FakeResponse(500, None, text='{"code":"sideload_failed"}'),
+ _rest_url(): _FakeResponse(201, _WP_MEDIA_JSON),
+ }
+ )
+ async with _patched_session(session):
+ result = await wp_raw_upload(client, _PNG_1x1, filename="photo.png")
+
+ # Companion tried first, then the REST fallback.
+ assert session.posts == [_companion_url(), _rest_url()]
+ assert result["id"] == 101
+ assert result.get("_upload_route") == "rest"
+
+
+@pytest.mark.asyncio
+async def test_should_use_companion_is_defensive(monkeypatch):
+ """If the probe lookup raises, we must not crash the upload path."""
+ client = _client()
+
+ async def boom(_client):
+ raise RuntimeError("probe blew up")
+
+ monkeypatch.setattr(
+ "plugins.wordpress.handlers.media_probe.get_cached_limits",
+ boom,
+ )
+ assert await media_core._should_use_companion(client, 10 * 1024**2) is False
diff --git a/tests/plugins/wordpress/test_media_error_taxonomy.py b/tests/plugins/wordpress/test_media_error_taxonomy.py
new file mode 100644
index 0000000..3b8feba
--- /dev/null
+++ b/tests/plugins/wordpress/test_media_error_taxonomy.py
@@ -0,0 +1,120 @@
+"""F.5a.6.2 — Error-code taxonomy stability test.
+
+Walks every raise-site in the media stack, extracts the literal error
+code, and asserts each one is in the documented
+:data:`core.media_error_codes.MEDIA_ERROR_CODES` set (or matches the
+dynamic ``WP_`` pattern).
+
+If this test fails, you're either:
+ - Introducing a new code — add it to ``MEDIA_ERROR_CODES`` *and*
+ ``docs/media-error-codes.md``, or
+ - Removing a documented code — make sure it's actually unused and drop
+ it from both the set and the docs.
+"""
+
+from __future__ import annotations
+
+import re
+from pathlib import Path
+
+import pytest
+
+from core.media_error_codes import MEDIA_ERROR_CODES, is_valid_code
+
+REPO_ROOT = Path(__file__).resolve().parents[3]
+
+# Files to scan for error-code literals.
+SCANNED_FILES = [
+ "plugins/wordpress/handlers/_media_security.py",
+ "plugins/wordpress/handlers/_media_core.py",
+ "plugins/wordpress/handlers/media.py",
+ "plugins/wordpress/handlers/media_attach.py",
+ "plugins/wordpress/handlers/media_chunked.py",
+ "plugins/wordpress/handlers/ai_media.py",
+ "core/upload_sessions.py",
+ "core/tool_rate_limiter.py",
+ "plugins/ai_image/providers/base.py",
+ "plugins/ai_image/providers/openai.py",
+ "plugins/ai_image/providers/stability.py",
+ "plugins/ai_image/providers/replicate.py",
+ "plugins/ai_image/registry.py",
+]
+
+# Literal raise-site patterns. Multiline to span wrapped calls.
+_RAISE_RE = re.compile(
+ r'raise\s+(?:UploadError|UploadSessionError|ProviderError)\s*\(\s*"([A-Z][A-Z0-9_]+)"',
+ re.MULTILINE,
+)
+# Formatted (f-string) codes like f"WP_{response.status}" — we assert the
+# pattern prefix is a valid dynamic code.
+_FSTRING_RE = re.compile(
+ r"raise\s+(?:UploadError|UploadSessionError|ProviderError)\s*\(\s*" r'f"([A-Z_]+)\{',
+ re.MULTILINE,
+)
+# Hard-coded error_code dict literals like {"error_code": "INTERNAL", ...}
+_ERROR_CODE_RE = re.compile(r'"error_code"\s*:\s*"([A-Z][A-Z0-9_]+)"')
+
+
+def _collect_codes() -> tuple[set[str], set[str]]:
+ literal_codes: set[str] = set()
+ fstring_prefixes: set[str] = set()
+ for rel in SCANNED_FILES:
+ path = REPO_ROOT / rel
+ text = path.read_text(encoding="utf-8")
+ literal_codes.update(_RAISE_RE.findall(text))
+ literal_codes.update(_ERROR_CODE_RE.findall(text))
+ fstring_prefixes.update(_FSTRING_RE.findall(text))
+ return literal_codes, fstring_prefixes
+
+
+def test_every_raise_site_uses_documented_code():
+ literal_codes, fstring_prefixes = _collect_codes()
+ assert literal_codes, "taxonomy test found no raise sites — scanner broken?"
+
+ undocumented = {c for c in literal_codes if not is_valid_code(c)}
+ assert not undocumented, (
+ f"Undocumented error codes in raise sites: {sorted(undocumented)}. "
+ f"Add them to core.media_error_codes.MEDIA_ERROR_CODES and "
+ f"docs/media-error-codes.md, or remove the raise site."
+ )
+
+ # f-string codes must have a documented prefix (e.g. "WP_" for WP_{status}
+ # or "COMPANION_" for COMPANION_{status} from the F.5a.7 upload-chunk route).
+ allowed_prefixes = {"WP_", "COMPANION_"}
+ for prefix in fstring_prefixes:
+ assert prefix in allowed_prefixes, (
+ f"Unknown dynamic error-code prefix '{prefix}'. "
+ f"Allowed prefixes: {sorted(allowed_prefixes)} (see is_valid_code)."
+ )
+
+
+def test_documented_codes_are_all_used():
+ """Stability guard: every documented code has at least one raise/use site.
+
+ Prevents the documented set from drifting with dead entries. A small
+ allow-list exists for codes that are reserved for upcoming work.
+ """
+ literal_codes, _ = _collect_codes()
+ reserved: set[str] = set() # None currently reserved.
+ unused = MEDIA_ERROR_CODES - literal_codes - reserved
+ # WP_ is dynamic; its fixed siblings (WP_413, WP_AUTH, WP_BAD_RESPONSE)
+ # must be explicitly raised.
+ assert not unused, (
+ f"Documented codes with no raise site: {sorted(unused)}. "
+ "Either wire them up, move to the 'reserved' set, or remove them."
+ )
+
+
+@pytest.mark.parametrize("code", sorted(MEDIA_ERROR_CODES))
+def test_is_valid_code_accepts_documented(code):
+ assert is_valid_code(code)
+
+
+@pytest.mark.parametrize("code", ["WP_400", "WP_500", "WP_418"])
+def test_is_valid_code_accepts_dynamic_wp_status(code):
+ assert is_valid_code(code)
+
+
+@pytest.mark.parametrize("code", ["UNKNOWN", "wp_413", "WP_", "WP_X", "WP_41"])
+def test_is_valid_code_rejects_others(code):
+ assert not is_valid_code(code)
diff --git a/tests/plugins/wordpress/test_media_optimize.py b/tests/plugins/wordpress/test_media_optimize.py
new file mode 100644
index 0000000..e107dda
--- /dev/null
+++ b/tests/plugins/wordpress/test_media_optimize.py
@@ -0,0 +1,180 @@
+"""Tests for F.5a.2 image optimization pipeline."""
+
+from __future__ import annotations
+
+import io
+
+import pytest
+
+PIL = pytest.importorskip("PIL")
+from PIL import Image # noqa: E402
+
+from plugins.wordpress.handlers._media_optimize import optimize # noqa: E402
+
+
+def _make_png(w: int, h: int, mode: str = "RGB") -> bytes:
+ import os as _os
+
+ img = Image.new(mode, (w, h))
+ # Fill with noise so solid-color PNG compression doesn't undercut JPEG
+ img.frombytes(_os.urandom(w * h * len(mode)))
+ buf = io.BytesIO()
+ img.save(buf, format="PNG")
+ return buf.getvalue()
+
+
+def _make_jpeg(w: int, h: int, quality: int = 95) -> bytes:
+ img = Image.new("RGB", (w, h), color=(200, 100, 50))
+ buf = io.BytesIO()
+ img.save(buf, format="JPEG", quality=quality)
+ return buf.getvalue()
+
+
+def test_large_jpeg_is_resized():
+ data = _make_jpeg(4000, 3000)
+ new_data, new_mime = optimize(data, "image/jpeg", max_edge=1024)
+ assert new_mime == "image/jpeg"
+ img = Image.open(io.BytesIO(new_data))
+ assert max(img.size) == 1024
+ assert len(new_data) < len(data)
+
+
+def test_opaque_png_converted_to_jpeg():
+ data = _make_png(800, 600, mode="RGB")
+ new_data, new_mime = optimize(data, "image/png", max_edge=2560)
+ assert new_mime == "image/jpeg"
+ assert len(new_data) < len(data)
+
+
+def test_transparent_png_stays_png():
+ data = _make_png(800, 600, mode="RGBA")
+ new_data, new_mime = optimize(data, "image/png", max_edge=2560)
+ assert new_mime == "image/png"
+
+
+def test_small_image_returns_original_bytes():
+ # Tiny image that cannot be made smaller
+ data = _make_jpeg(8, 8, quality=50)
+ new_data, new_mime = optimize(data, "image/jpeg", max_edge=2560)
+ # Either unchanged or still JPEG
+ assert new_mime in (None, "image/jpeg")
+ assert len(new_data) <= len(data) + 1024
+
+
+def test_pdf_passthrough():
+ data = b"%PDF-1.4\n%garbage\n%%EOF"
+ new_data, new_mime = optimize(data, "application/pdf")
+ assert new_data == data
+ assert new_mime == "application/pdf"
+
+
+def test_video_passthrough():
+ data = b"\x00" * 32
+ new_data, new_mime = optimize(data, "video/mp4")
+ assert new_data == data
+ assert new_mime == "video/mp4"
+
+
+def test_unknown_mime_tries_anyway_but_fails_gracefully():
+ # Garbage bytes with no PIL-recognizable format → passthrough
+ data = b"not an image at all"
+ new_data, new_mime = optimize(data, None)
+ assert new_data == data
+
+
+# ---------------------------------------------------------------------------
+# F.5a.8.1 — convert_to override (WebP / AVIF)
+# ---------------------------------------------------------------------------
+
+
+def _peek_format(data: bytes) -> str:
+ return (Image.open(io.BytesIO(data)).format or "").upper()
+
+
+def test_convert_png_to_webp():
+ data = _make_png(64, 64, mode="RGB")
+ new_data, new_mime = optimize(data, "image/png", convert_to="webp")
+ assert new_mime == "image/webp"
+ assert _peek_format(new_data) == "WEBP"
+
+
+def test_convert_jpeg_to_webp():
+ data = _make_jpeg(64, 64)
+ new_data, new_mime = optimize(data, "image/jpeg", convert_to="webp")
+ assert new_mime == "image/webp"
+ assert _peek_format(new_data) == "WEBP"
+
+
+def test_convert_preserves_alpha_on_webp():
+ data = _make_png(64, 64, mode="RGBA")
+ new_data, new_mime = optimize(data, "image/png", convert_to="webp")
+ assert new_mime == "image/webp"
+ img = Image.open(io.BytesIO(new_data))
+ # Either RGBA or palette-with-alpha is acceptable
+ assert "A" in img.mode or "A" in "".join(img.getbands())
+
+
+def test_convert_avif_falls_back_to_webp_when_unsupported():
+ from plugins.wordpress.handlers._media_optimize import _avif_supported
+
+ data = _make_png(32, 32, mode="RGB")
+ new_data, new_mime = optimize(data, "image/png", convert_to="avif")
+ if _avif_supported():
+ assert new_mime == "image/avif"
+ assert _peek_format(new_data) == "AVIF"
+ else:
+ assert new_mime == "image/webp"
+ assert _peek_format(new_data) == "WEBP"
+
+
+def test_convert_to_env_default(monkeypatch):
+ import plugins.wordpress.handlers._media_optimize as opt_mod
+
+ monkeypatch.setattr(opt_mod, "_DEFAULT_CONVERT_TO", "webp")
+ data = _make_png(48, 48, mode="RGB")
+ new_data, new_mime = optimize(data, "image/png")
+ assert new_mime == "image/webp"
+ assert _peek_format(new_data) == "WEBP"
+
+
+def test_convert_wins_over_size_guard():
+ # 1×1 images re-encode to formats that carry larger container overhead.
+ # The explicit convert_to request MUST still win — the size guard only
+ # applies to the implicit recompression path.
+ tiny = _make_png(1, 1, mode="RGB")
+ new_data, new_mime = optimize(tiny, "image/png", convert_to="webp")
+ assert new_mime == "image/webp"
+ assert _peek_format(new_data) == "WEBP"
+
+
+def test_convert_unknown_value_falls_through_to_heuristic():
+ # "jpg" isn't in the convert_to map → optimizer uses source-format heuristic.
+ data = _make_jpeg(64, 64)
+ _, new_mime = optimize(data, "image/jpeg", convert_to="jpg")
+ assert new_mime == "image/jpeg"
+
+
+def test_convert_to_does_not_touch_pdf():
+ data = b"%PDF-1.4\n%fake\n"
+ new_data, new_mime = optimize(data, "application/pdf", convert_to="webp")
+ assert new_data == data
+ assert new_mime == "application/pdf"
+
+
+def test_maybe_optimize_forwards_convert_to():
+ from plugins.wordpress.handlers.media import _maybe_optimize
+
+ out, mime = _maybe_optimize(
+ _make_png(48, 48, mode="RGB"), "image/png", skip=False, convert_to="webp"
+ )
+ assert mime == "image/webp"
+ assert _peek_format(out) == "WEBP"
+
+
+def test_maybe_optimize_skip_overrides_convert_to():
+ from plugins.wordpress.handlers.media import _maybe_optimize
+
+ src = _make_png(48, 48, mode="RGB")
+ out, mime = _maybe_optimize(src, "image/png", skip=True, convert_to="webp")
+ assert out == src
+ assert mime == "image/png"
diff --git a/tests/plugins/wordpress/test_media_post_parent.py b/tests/plugins/wordpress/test_media_post_parent.py
new file mode 100644
index 0000000..87d50bf
--- /dev/null
+++ b/tests/plugins/wordpress/test_media_post_parent.py
@@ -0,0 +1,96 @@
+"""F.X.fix #6 — get_media / list_media expose post_parent.
+
+Regression: neither endpoint returned the parent post, so MCP clients
+had no way to verify "is media X attached to post Y" without a second
+round trip. Fix adds ``post_parent`` (int, 0 when unattached).
+"""
+
+from __future__ import annotations
+
+import json
+from unittest.mock import AsyncMock
+
+import pytest
+
+from plugins.wordpress.handlers.media import MediaHandler
+
+
+def _make_media(media_id: int, *, post: int = 0) -> dict:
+ return {
+ "id": media_id,
+ "title": {"rendered": f"media-{media_id}"},
+ "mime_type": "image/webp",
+ "media_type": "image",
+ "source_url": f"https://example.com/uploads/{media_id}.webp",
+ "date": "2026-04-17T10:00:00",
+ "alt_text": "",
+ "link": f"https://example.com/?attachment_id={media_id}",
+ "post": post,
+ "caption": {"rendered": ""},
+ "description": {"rendered": ""},
+ "media_details": {},
+ }
+
+
+@pytest.fixture
+def handler():
+ client = AsyncMock()
+ return MediaHandler(client), client
+
+
+class TestGetMediaPostParent:
+ @pytest.mark.asyncio
+ async def test_get_media_returns_attached_parent(self, handler):
+ h, client = handler
+ client.get = AsyncMock(return_value=_make_media(77, post=42))
+ raw = await h.get_media(media_id=77)
+ data = json.loads(raw)
+ assert data["post_parent"] == 42
+
+ @pytest.mark.asyncio
+ async def test_get_media_returns_zero_when_unattached(self, handler):
+ h, client = handler
+ client.get = AsyncMock(return_value=_make_media(78, post=0))
+ raw = await h.get_media(media_id=78)
+ data = json.loads(raw)
+ # WP's own REST returns 0 for unattached; we preserve that.
+ assert data["post_parent"] == 0
+
+ @pytest.mark.asyncio
+ async def test_get_media_handles_missing_post_key(self, handler):
+ h, client = handler
+ payload = _make_media(79, post=0)
+ del payload["post"] # older WP installs or partial fields=
+ client.get = AsyncMock(return_value=payload)
+ raw = await h.get_media(media_id=79)
+ data = json.loads(raw)
+ assert data["post_parent"] == 0
+
+
+class TestListMediaPostParent:
+ @pytest.mark.asyncio
+ async def test_list_media_shape_includes_post_parent(self, handler):
+ h, client = handler
+ client.get = AsyncMock(
+ return_value=[
+ _make_media(1, post=42),
+ _make_media(2, post=0),
+ _make_media(3, post=99),
+ ]
+ )
+ raw = await h.list_media()
+ data = json.loads(raw)
+ parents = [m["post_parent"] for m in data["media"]]
+ assert parents == [42, 0, 99]
+
+ @pytest.mark.asyncio
+ async def test_list_media_unattached_reports_zero_not_null(self, handler):
+ h, client = handler
+ item = _make_media(5, post=0)
+ item["post"] = None # Some older WP payloads send null
+ client.get = AsyncMock(return_value=[item])
+ raw = await h.list_media()
+ data = json.loads(raw)
+ # MCP consumers expect an int they can compare against; None
+ # makes type-strict callers crash. Normalise to 0.
+ assert data["media"][0]["post_parent"] == 0
diff --git a/tests/plugins/wordpress/test_media_probe.py b/tests/plugins/wordpress/test_media_probe.py
new file mode 100644
index 0000000..d1d0478
--- /dev/null
+++ b/tests/plugins/wordpress/test_media_probe.py
@@ -0,0 +1,177 @@
+"""F.5a.6.3 — Tests for wordpress_probe_upload_limits."""
+
+from __future__ import annotations
+
+import json
+from unittest.mock import AsyncMock
+
+import pytest
+
+from plugins.wordpress.client import WordPressClient
+from plugins.wordpress.handlers.media_probe import ProbeHandler, _ProbeCache
+
+
+@pytest.fixture
+def wp_client():
+ return WordPressClient(site_url="https://wp.example.com", username="u", app_password="p")
+
+
+@pytest.fixture
+def cache():
+ return _ProbeCache(ttl=24 * 3600)
+
+
+@pytest.mark.asyncio
+async def test_companion_endpoint_parses_and_caches(wp_client, monkeypatch, cache):
+ handler = ProbeHandler(wp_client, cache=cache)
+ payload = {
+ "upload_max_filesize": "64M",
+ "post_max_size": "128M",
+ "memory_limit": "256M",
+ "max_input_time": "300",
+ "wp_max_upload_size": 67108864,
+ }
+ get_mock = AsyncMock(return_value=payload)
+ monkeypatch.setattr(wp_client, "get", get_mock)
+
+ out_json = await handler.probe_upload_limits()
+ out = json.loads(out_json)
+
+ assert out["source"] == "companion"
+ assert out["limits"] == payload
+ assert out["cached"] is False
+ # F.5a.7: companion payload exposes byte-parsed limits + companion flag.
+ assert out["companion_available"] is True
+ assert out["limits_bytes"]["upload_max_filesize"] == 64 * 1024**2
+ assert out["limits_bytes"]["post_max_size"] == 128 * 1024**2
+ assert out["limits_bytes"]["wp_max_upload_size"] == 67108864
+ # The effective ceiling is the smallest of the byte-valued keys.
+ assert out["limits_bytes"]["effective_ceiling"] == 64 * 1024**2
+ # First call hit the network exactly once.
+ assert get_mock.call_count == 1
+ get_mock.assert_called_with("airano-mcp/v1/upload-limits", use_custom_namespace=True)
+
+
+@pytest.mark.asyncio
+async def test_second_call_is_served_from_cache(wp_client, monkeypatch, cache):
+ handler = ProbeHandler(wp_client, cache=cache)
+ payload = {"upload_max_filesize": "64M"}
+ get_mock = AsyncMock(return_value=payload)
+ monkeypatch.setattr(wp_client, "get", get_mock)
+
+ await handler.probe_upload_limits()
+ out2 = json.loads(await handler.probe_upload_limits())
+
+ assert out2["cached"] is True
+ # Network only hit once across two probe calls.
+ assert get_mock.call_count == 1
+
+
+@pytest.mark.asyncio
+async def test_cache_expires_after_ttl(wp_client, monkeypatch):
+ cache = _ProbeCache(ttl=0.0) # immediate expiry
+ handler = ProbeHandler(wp_client, cache=cache)
+ get_mock = AsyncMock(return_value={"upload_max_filesize": "64M"})
+ monkeypatch.setattr(wp_client, "get", get_mock)
+
+ await handler.probe_upload_limits()
+ await handler.probe_upload_limits()
+ # ttl=0 → both calls re-fetch.
+ assert get_mock.call_count == 2
+
+
+@pytest.mark.asyncio
+async def test_companion_failure_falls_back_to_rest_index(wp_client, monkeypatch, cache):
+ handler = ProbeHandler(wp_client, cache=cache)
+
+ async def fake_get(endpoint, **kwargs):
+ if endpoint == "airano-mcp/v1/upload-limits":
+ raise RuntimeError("404")
+ if endpoint == "":
+ return {"wp_max_upload_size": 4 * 1024 * 1024}
+ raise AssertionError(f"unexpected endpoint: {endpoint}")
+
+ monkeypatch.setattr(wp_client, "get", AsyncMock(side_effect=fake_get))
+
+ out = json.loads(await handler.probe_upload_limits())
+ assert out["source"] == "rest_index"
+ assert out["limits"]["wp_max_upload_size"] == 4 * 1024 * 1024
+
+
+@pytest.mark.asyncio
+async def test_total_failure_returns_empty_limits(wp_client, monkeypatch, cache):
+ handler = ProbeHandler(wp_client, cache=cache)
+ monkeypatch.setattr(wp_client, "get", AsyncMock(side_effect=RuntimeError("totally offline")))
+ out = json.loads(await handler.probe_upload_limits())
+ assert out["source"] == "unknown"
+ assert all(v is None for v in out["limits"].values())
+
+
+def test_tool_spec_is_read_scope():
+ from plugins.wordpress.handlers.media_probe import get_tool_specifications
+
+ specs = get_tool_specifications()
+ assert len(specs) == 1
+ assert specs[0]["name"] == "probe_upload_limits"
+ assert specs[0]["scope"] == "read"
+
+
+# F.5a.7: byte parser helpers ------------------------------------------------
+
+
+class TestParsePhpSize:
+ @staticmethod
+ def _parse(v):
+ from plugins.wordpress.handlers.media_probe import parse_php_size
+
+ return parse_php_size(v)
+
+ def test_none(self):
+ assert self._parse(None) is None
+
+ def test_empty_string(self):
+ assert self._parse("") is None
+
+ def test_bare_integer_string(self):
+ assert self._parse("1024") == 1024
+
+ def test_megabytes_upper(self):
+ assert self._parse("64M") == 64 * 1024**2
+
+ def test_megabytes_lower(self):
+ assert self._parse("8m") == 8 * 1024**2
+
+ def test_gigabytes(self):
+ assert self._parse("2G") == 2 * 1024**3
+
+ def test_numeric_int(self):
+ assert self._parse(4096) == 4096
+
+ def test_unlimited_minus_one(self):
+ # PHP "-1" means no limit; treat as unknown.
+ assert self._parse("-1") is None
+ assert self._parse(-1) is None
+
+ def test_invalid(self):
+ assert self._parse("garbage") is None
+
+
+class TestEffectiveCeiling:
+ def test_returns_min_of_populated(self):
+ from plugins.wordpress.handlers.media_probe import effective_upload_ceiling
+
+ limits = {
+ "upload_max_filesize": "8M",
+ "post_max_size": "64M",
+ "memory_limit": "256M",
+ "max_input_time": "60",
+ "wp_max_upload_size": 8388608,
+ }
+ # min(8MB, 64MB, 8388608 bytes) = 8388608 bytes
+ assert effective_upload_ceiling(limits) == 8 * 1024**2
+
+ def test_none_when_empty(self):
+ from plugins.wordpress.handlers.media_probe import effective_upload_ceiling
+
+ assert effective_upload_ceiling({}) is None
+ assert effective_upload_ceiling(None) is None
diff --git a/tests/plugins/wordpress/test_media_upload.py b/tests/plugins/wordpress/test_media_upload.py
new file mode 100644
index 0000000..20936a5
--- /dev/null
+++ b/tests/plugins/wordpress/test_media_upload.py
@@ -0,0 +1,362 @@
+"""Tests for F.5a.1 media upload primitives and handler."""
+
+from __future__ import annotations
+
+import base64
+import json
+from unittest.mock import AsyncMock, patch
+
+import pytest
+
+from plugins.wordpress.client import WordPressClient
+from plugins.wordpress.handlers._media_core import (
+ wp_raw_upload,
+ wp_set_featured_media,
+ wp_update_media_metadata,
+)
+from plugins.wordpress.handlers._media_security import (
+ ALLOWED_MIMES,
+ UploadError,
+ content_disposition,
+ safe_filename,
+ sniff_mime,
+ ssrf_check,
+ validate_mime,
+ validate_size,
+)
+from plugins.wordpress.handlers.media import MediaHandler
+
+# A minimal valid PNG (1x1, transparent)
+_PNG_1x1 = base64.b64decode(
+ b"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNkYAAAAAYAAjCB0C8AAAAASUVORK5CYII="
+)
+
+
+def _client():
+ return WordPressClient(site_url="https://wp.example.com", username="u", app_password="p")
+
+
+# --- Security primitives ----------------------------------------------------
+
+
+class TestSecurity:
+ def test_sniff_mime_detects_png(self):
+ assert sniff_mime(_PNG_1x1) == "image/png"
+
+ def test_sniff_mime_falls_back_to_hint(self):
+ # Random ASCII with no image/video/audio magic signature. Different
+ # libmagic builds disagree on whether short human-readable bytes are
+ # "application/octet-stream" (conservative) or "text/plain" (aggressive).
+ # Either is acceptable; what matters is that the function doesn't crash
+ # and returns *something* usable when the hint can't be trusted.
+ result = sniff_mime(b"not a real file", hint="image/png")
+ assert result in {
+ "image/png",
+ "application/octet-stream",
+ "text/plain",
+ }
+
+ def test_validate_size_empty(self):
+ with pytest.raises(UploadError) as e:
+ validate_size(b"")
+ assert e.value.code == "EMPTY_FILE"
+
+ def test_validate_size_too_large(self):
+ with pytest.raises(UploadError) as e:
+ validate_size(b"x" * 10, max_bytes=5)
+ assert e.value.code == "TOO_LARGE"
+
+ def test_validate_mime_rejects_unknown(self):
+ with pytest.raises(UploadError) as e:
+ validate_mime("application/x-msdownload")
+ assert e.value.code == "MIME_REJECTED"
+
+ def test_validate_mime_allows_jpeg(self):
+ validate_mime("image/jpeg") # no raise
+
+ def test_safe_filename_ascii_preserved(self):
+ name, encoded = safe_filename("photo.jpg", mime="image/jpeg")
+ assert name == "photo.jpg"
+ assert encoded is None
+
+ def test_safe_filename_sanitizes_path(self):
+ name, _ = safe_filename("../../etc/passwd.jpg", mime="image/jpeg")
+ assert "/" not in name and ".." not in name
+ assert name.endswith(".jpg")
+
+ def test_safe_filename_unicode(self):
+ name, encoded = safe_filename("عکس.jpg", mime="image/jpeg")
+ assert encoded is not None
+ assert encoded.startswith("UTF-8''")
+ # ASCII filename still has extension
+ assert name.endswith(".jpg")
+
+ def test_safe_filename_adds_extension_from_mime(self):
+ name, _ = safe_filename("noext", mime="image/png")
+ assert name.endswith(".png")
+
+ def test_content_disposition_ascii_only(self):
+ h = content_disposition("a.jpg", None)
+ assert h == 'attachment; filename="a.jpg"'
+
+ def test_content_disposition_rfc5987(self):
+ h = content_disposition("a.jpg", "UTF-8''%D8%B9.jpg")
+ assert "filename*=UTF-8''" in h
+
+
+class TestSSRF:
+ def test_blocks_localhost(self):
+ r = ssrf_check("http://127.0.0.1/x", allow_http=True)
+ assert not r.allowed
+
+ def test_blocks_metadata_host(self):
+ r = ssrf_check("http://169.254.169.254/latest/meta-data", allow_http=True)
+ assert not r.allowed
+
+ def test_blocks_http_by_default(self):
+ r = ssrf_check("http://example.com/x")
+ assert not r.allowed
+
+ def test_blocks_bad_scheme(self):
+ r = ssrf_check("file:///etc/passwd")
+ assert not r.allowed
+
+ def test_blocks_metadata_hostname(self):
+ r = ssrf_check("https://metadata.google.internal/")
+ assert not r.allowed
+
+ def test_blocks_private_range(self):
+ # 10.x resolves without DNS via literal IP
+ r = ssrf_check("https://10.0.0.1/x")
+ assert not r.allowed
+
+
+# --- wp_raw_upload ----------------------------------------------------------
+
+
+class TestRawUpload:
+ @pytest.mark.asyncio
+ async def test_happy_path_png(self):
+ client = _client()
+ wp_response = {
+ "id": 123,
+ "title": {"rendered": "photo"},
+ "source_url": "https://wp.example.com/wp-content/uploads/photo.png",
+ "mime_type": "image/png",
+ "media_type": "image",
+ }
+ mock_resp = AsyncMock()
+ mock_resp.status = 201
+ mock_resp.text = AsyncMock(return_value=json.dumps(wp_response))
+ mock_resp.json = AsyncMock(return_value=wp_response)
+
+ mock_sess = AsyncMock()
+ mock_sess.post = lambda *a, **kw: AsyncMock(
+ __aenter__=AsyncMock(return_value=mock_resp),
+ __aexit__=AsyncMock(return_value=False),
+ )
+ with patch("plugins.wordpress.handlers._media_core.aiohttp.ClientSession") as mock_cls:
+ mock_cls.return_value = AsyncMock(
+ __aenter__=AsyncMock(return_value=mock_sess),
+ __aexit__=AsyncMock(return_value=False),
+ )
+ result = await wp_raw_upload(client, _PNG_1x1, filename="photo.png")
+ assert result["id"] == 123
+
+ @pytest.mark.asyncio
+ async def test_413_raises_typed_error(self):
+ client = _client()
+ mock_resp = AsyncMock()
+ mock_resp.status = 413
+ mock_resp.text = AsyncMock(return_value="Payload Too Large")
+
+ mock_sess = AsyncMock()
+ mock_sess.post = lambda *a, **kw: AsyncMock(
+ __aenter__=AsyncMock(return_value=mock_resp),
+ __aexit__=AsyncMock(return_value=False),
+ )
+ with patch("plugins.wordpress.handlers._media_core.aiohttp.ClientSession") as mock_cls:
+ mock_cls.return_value = AsyncMock(
+ __aenter__=AsyncMock(return_value=mock_sess),
+ __aexit__=AsyncMock(return_value=False),
+ )
+ with pytest.raises(UploadError) as e:
+ await wp_raw_upload(client, _PNG_1x1, filename="photo.png")
+ assert e.value.code == "WP_413"
+
+ @pytest.mark.asyncio
+ async def test_401_raises_auth_error(self):
+ client = _client()
+ mock_resp = AsyncMock()
+ mock_resp.status = 401
+ mock_resp.text = AsyncMock(return_value='{"code":"invalid_auth"}')
+
+ mock_sess = AsyncMock()
+ mock_sess.post = lambda *a, **kw: AsyncMock(
+ __aenter__=AsyncMock(return_value=mock_resp),
+ __aexit__=AsyncMock(return_value=False),
+ )
+ with patch("plugins.wordpress.handlers._media_core.aiohttp.ClientSession") as mock_cls:
+ mock_cls.return_value = AsyncMock(
+ __aenter__=AsyncMock(return_value=mock_sess),
+ __aexit__=AsyncMock(return_value=False),
+ )
+ with pytest.raises(UploadError) as e:
+ await wp_raw_upload(client, _PNG_1x1, filename="photo.png")
+ assert e.value.code == "WP_AUTH"
+
+ @pytest.mark.asyncio
+ async def test_rejects_disallowed_mime(self):
+ client = _client()
+ # Binary that will be sniffed as something unusual; enforce via hint list
+ with pytest.raises(UploadError) as e:
+ await wp_raw_upload(
+ client,
+ b"MZ\x90\x00" + b"\x00" * 100,
+ filename="evil.exe",
+ mime_hint="application/x-msdownload",
+ allowed_mimes={"image/png"},
+ )
+ assert e.value.code == "MIME_REJECTED"
+
+
+# --- MediaHandler integration ----------------------------------------------
+
+
+class TestMediaHandler:
+ @pytest.mark.asyncio
+ async def test_upload_from_base64_happy_path(self):
+ handler = MediaHandler(_client())
+ wp_media = {
+ "id": 42,
+ "title": {"rendered": "hi"},
+ "source_url": "https://wp.example.com/wp-content/uploads/hi.png",
+ "mime_type": "image/png",
+ "media_type": "image",
+ }
+ with (
+ patch(
+ "plugins.wordpress.handlers.media.wp_raw_upload",
+ new=AsyncMock(return_value=wp_media),
+ ),
+ patch(
+ "plugins.wordpress.handlers.media.wp_update_media_metadata",
+ new=AsyncMock(return_value={}),
+ ),
+ ):
+ out = await handler.upload_media_from_base64(
+ data=base64.b64encode(_PNG_1x1).decode(),
+ filename="hi.png",
+ alt_text="hi",
+ )
+ parsed = json.loads(out)
+ assert parsed["id"] == 42
+ assert parsed["mime_type"] == "image/png"
+
+ @pytest.mark.asyncio
+ async def test_upload_from_base64_rejects_bad_payload(self):
+ handler = MediaHandler(_client())
+ out = await handler.upload_media_from_base64(data="!!!!not-base64", filename="x.png")
+ parsed = json.loads(out)
+ assert parsed["error_code"] == "BAD_BASE64"
+
+ @pytest.mark.asyncio
+ async def test_upload_from_base64_strips_data_url_prefix(self):
+ handler = MediaHandler(_client())
+ wp_media = {
+ "id": 9,
+ "title": {"rendered": "x"},
+ "source_url": "https://wp.example.com/x.png",
+ "mime_type": "image/png",
+ "media_type": "image",
+ }
+ prefixed = "data:image/png;base64," + base64.b64encode(_PNG_1x1).decode()
+ with (
+ patch(
+ "plugins.wordpress.handlers.media.wp_raw_upload",
+ new=AsyncMock(return_value=wp_media),
+ ),
+ patch(
+ "plugins.wordpress.handlers.media.wp_update_media_metadata",
+ new=AsyncMock(return_value={}),
+ ),
+ ):
+ out = await handler.upload_media_from_base64(data=prefixed, filename="x.png")
+ assert json.loads(out)["id"] == 9
+
+ @pytest.mark.asyncio
+ async def test_upload_from_url_ssrf_blocked(self):
+ handler = MediaHandler(_client())
+ out = await handler.upload_media_from_url(url="http://127.0.0.1/image.png")
+ parsed = json.loads(out)
+ assert parsed["error_code"] == "SSRF"
+
+ @pytest.mark.asyncio
+ async def test_upload_sets_featured_when_attached(self):
+ handler = MediaHandler(_client())
+ wp_media = {
+ "id": 7,
+ "title": {"rendered": "hi"},
+ "source_url": "https://wp.example.com/hi.png",
+ "mime_type": "image/png",
+ "media_type": "image",
+ }
+ featured_mock = AsyncMock(return_value={"id": 100, "featured_media": 7})
+ with (
+ patch(
+ "plugins.wordpress.handlers.media.wp_raw_upload",
+ new=AsyncMock(return_value=wp_media),
+ ),
+ patch(
+ "plugins.wordpress.handlers.media.wp_update_media_metadata",
+ new=AsyncMock(return_value={}),
+ ),
+ patch(
+ "plugins.wordpress.handlers.media.wp_set_featured_media",
+ new=featured_mock,
+ ),
+ ):
+ await handler.upload_media_from_base64(
+ data=base64.b64encode(_PNG_1x1).decode(),
+ filename="hi.png",
+ attach_to_post=100,
+ set_featured=True,
+ )
+ featured_mock.assert_awaited_once()
+ args = featured_mock.await_args
+ assert args.args[1] == 100 # post_id
+ assert args.args[2] == 7 # media_id
+
+
+# --- metadata helpers -------------------------------------------------------
+
+
+class TestMetadataHelpers:
+ @pytest.mark.asyncio
+ async def test_update_media_metadata_skips_when_nothing_to_update(self):
+ client = _client()
+ with patch.object(client, "post", new=AsyncMock()) as mock_post:
+ result = await wp_update_media_metadata(client, 1)
+ assert result == {}
+ mock_post.assert_not_awaited()
+
+ @pytest.mark.asyncio
+ async def test_update_media_metadata_sends_only_set_fields(self):
+ client = _client()
+ with patch.object(client, "post", new=AsyncMock(return_value={"id": 1})) as mock_post:
+ await wp_update_media_metadata(client, 1, alt_text="hi")
+ mock_post.assert_awaited_once()
+ call = mock_post.await_args
+ assert call.kwargs["json_data"] == {"alt_text": "hi"}
+
+ @pytest.mark.asyncio
+ async def test_set_featured_media(self):
+ client = _client()
+ with patch.object(client, "post", new=AsyncMock(return_value={"id": 5})) as mock_post:
+ await wp_set_featured_media(client, 5, 9)
+ mock_post.assert_awaited_once_with("posts/5", json_data={"featured_media": 9})
+
+
+def test_allowed_mimes_includes_common_types():
+ for m in ("image/jpeg", "image/png", "image/webp", "application/pdf"):
+ assert m in ALLOWED_MIMES
diff --git a/tests/plugins/wordpress/test_openrouter_model_discovery.py b/tests/plugins/wordpress/test_openrouter_model_discovery.py
new file mode 100644
index 0000000..e5fc60e
--- /dev/null
+++ b/tests/plugins/wordpress/test_openrouter_model_discovery.py
@@ -0,0 +1,192 @@
+"""F.X.fix #12 — OpenRouter /v1/models discovery + 1h cache."""
+
+from __future__ import annotations
+
+from unittest.mock import AsyncMock, patch
+
+import pytest
+
+from plugins.ai_image.providers import openrouter as or_mod
+from plugins.ai_image.providers.openrouter import OpenRouterProvider
+
+
+def _fake_resp(*, status, json_data=None, headers=None):
+ resp = AsyncMock()
+ resp.status = status
+ resp.json = AsyncMock(return_value=json_data or {})
+ resp.headers = headers or {}
+ return resp
+
+
+def _mock_session(responses):
+ iterator = iter(responses)
+
+ def _request(*_a, **_kw):
+ resp = next(iterator)
+ return AsyncMock(
+ __aenter__=AsyncMock(return_value=resp),
+ __aexit__=AsyncMock(return_value=False),
+ )
+
+ sess = AsyncMock()
+ sess.get = _request
+ sess.post = _request
+ return sess
+
+
+def _reset_cache():
+ or_mod._models_cache["fetched_at"] = 0.0
+ or_mod._models_cache["payload"] = None
+
+
+@pytest.fixture(autouse=True)
+def reset_cache():
+ _reset_cache()
+ yield
+ _reset_cache()
+
+
+CATALOG_PAYLOAD = {
+ "data": [
+ {
+ "id": "google/gemini-2.5-flash-image",
+ "name": "Gemini 2.5 Flash Image",
+ "description": "Google's GA image model",
+ "context_length": 1000000,
+ "architecture": {
+ "input_modalities": ["text", "image"],
+ "output_modalities": ["image", "text"],
+ "modality": "text+image->image+text",
+ },
+ },
+ {
+ "id": "google/gemini-2.5-flash-image-preview",
+ "name": "Gemini 2.5 Flash Image (Preview)",
+ "architecture": {
+ "output_modalities": ["image"],
+ },
+ },
+ {
+ "id": "openai/gpt-4o",
+ "name": "GPT-4o",
+ "architecture": {
+ "input_modalities": ["text", "image"],
+ "output_modalities": ["text"],
+ },
+ },
+ {
+ "id": "openai/dall-e-3",
+ "name": "DALL-E 3",
+ "architecture": {"output_modalities": ["image"]},
+ },
+ {
+ "id": "meta/llama-3",
+ "name": "Llama 3",
+ "description": "chat only",
+ "architecture": {"output_modalities": ["text"]},
+ },
+ ]
+}
+
+
+class TestListImageModels:
+ @pytest.mark.asyncio
+ async def test_filters_to_image_output_only(self):
+ provider = OpenRouterProvider()
+ resp = _fake_resp(status=200, json_data=CATALOG_PAYLOAD)
+ sess = _mock_session([resp])
+ with patch("plugins.ai_image.providers.openrouter.aiohttp.ClientSession") as cls:
+ cls.return_value = AsyncMock(
+ __aenter__=AsyncMock(return_value=sess),
+ __aexit__=AsyncMock(return_value=False),
+ )
+ models = await provider.list_image_models()
+ ids = {m["id"] for m in models}
+ # Kept — image output
+ assert "google/gemini-2.5-flash-image" in ids
+ assert "openai/dall-e-3" in ids
+ # Dropped — text-only
+ assert "openai/gpt-4o" not in ids
+ assert "meta/llama-3" not in ids
+ # Dropped — deprecated alias (we refuse to surface it)
+ assert "google/gemini-2.5-flash-image-preview" not in ids
+
+ @pytest.mark.asyncio
+ async def test_shape_includes_pricing_and_modalities(self):
+ provider = OpenRouterProvider()
+ resp = _fake_resp(status=200, json_data=CATALOG_PAYLOAD)
+ sess = _mock_session([resp])
+ with patch("plugins.ai_image.providers.openrouter.aiohttp.ClientSession") as cls:
+ cls.return_value = AsyncMock(
+ __aenter__=AsyncMock(return_value=sess),
+ __aexit__=AsyncMock(return_value=False),
+ )
+ models = await provider.list_image_models()
+ gemini = next(m for m in models if m["id"] == "google/gemini-2.5-flash-image")
+ assert gemini["name"]
+ assert "image" in gemini["output_modalities"]
+ # F.X.fix #11 integration — catalog exposes price per image for
+ # UI display alongside the model list.
+ assert gemini["price_per_image_usd"] is not None
+
+ @pytest.mark.asyncio
+ async def test_cache_hits_second_call(self):
+ provider = OpenRouterProvider()
+ resp = _fake_resp(status=200, json_data=CATALOG_PAYLOAD)
+ sess = _mock_session([resp]) # only ONE response
+ with patch("plugins.ai_image.providers.openrouter.aiohttp.ClientSession") as cls:
+ cls.return_value = AsyncMock(
+ __aenter__=AsyncMock(return_value=sess),
+ __aexit__=AsyncMock(return_value=False),
+ )
+ first = await provider.list_image_models()
+ second = await provider.list_image_models() # cache hit
+ assert first == second
+ # Second call should not have tried a second HTTP request (mock
+ # session would raise StopIteration if it did).
+
+ @pytest.mark.asyncio
+ async def test_force_bypasses_cache(self):
+ provider = OpenRouterProvider()
+ resp1 = _fake_resp(status=200, json_data=CATALOG_PAYLOAD)
+ resp2 = _fake_resp(status=200, json_data={"data": []})
+ sess = _mock_session([resp1, resp2])
+ with patch("plugins.ai_image.providers.openrouter.aiohttp.ClientSession") as cls:
+ cls.return_value = AsyncMock(
+ __aenter__=AsyncMock(return_value=sess),
+ __aexit__=AsyncMock(return_value=False),
+ )
+ first = await provider.list_image_models()
+ second = await provider.list_image_models(force=True)
+ assert len(first) > 0
+ assert second == []
+
+ @pytest.mark.asyncio
+ async def test_upstream_error_returns_previous_cache(self):
+ provider = OpenRouterProvider()
+ ok = _fake_resp(status=200, json_data=CATALOG_PAYLOAD)
+ err = _fake_resp(status=500, json_data={"error": "boom"})
+ sess = _mock_session([ok, err])
+ with patch("plugins.ai_image.providers.openrouter.aiohttp.ClientSession") as cls:
+ cls.return_value = AsyncMock(
+ __aenter__=AsyncMock(return_value=sess),
+ __aexit__=AsyncMock(return_value=False),
+ )
+ first = await provider.list_image_models()
+ second = await provider.list_image_models(force=True)
+ # First populated cache, second upstream failed — we serve the
+ # cached snapshot rather than an empty list.
+ assert second == first
+
+ @pytest.mark.asyncio
+ async def test_upstream_error_with_empty_cache_returns_empty(self):
+ provider = OpenRouterProvider()
+ err = _fake_resp(status=500, json_data={})
+ sess = _mock_session([err])
+ with patch("plugins.ai_image.providers.openrouter.aiohttp.ClientSession") as cls:
+ cls.return_value = AsyncMock(
+ __aenter__=AsyncMock(return_value=sess),
+ __aexit__=AsyncMock(return_value=False),
+ )
+ models = await provider.list_image_models()
+ assert models == []
diff --git a/tests/plugins/wordpress/test_openrouter_pricing.py b/tests/plugins/wordpress/test_openrouter_pricing.py
new file mode 100644
index 0000000..c8bc8a4
--- /dev/null
+++ b/tests/plugins/wordpress/test_openrouter_pricing.py
@@ -0,0 +1,157 @@
+"""F.X.fix #11 — OpenRouter pricing table for cost_usd attribution."""
+
+from __future__ import annotations
+
+import base64
+import json
+from unittest.mock import AsyncMock, patch
+
+import pytest
+
+from plugins.ai_image.providers.base import GenerationRequest, ProviderError
+from plugins.ai_image.providers.openrouter import (
+ _MODEL_PRICING,
+ OpenRouterProvider,
+ _cost_for,
+ _pricing_table,
+)
+
+_PNG_1x1 = base64.b64decode(
+ b"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNkYAAAAAYAAjCB0C8AAAAASUVORK5CYII="
+)
+
+
+def _fake_resp(*, status, json_data=None, text_data="", raw=None, headers=None):
+ resp = AsyncMock()
+ resp.status = status
+ resp.text = AsyncMock(return_value=text_data or "")
+ if json_data is not None:
+ resp.json = AsyncMock(return_value=json_data)
+ if raw is not None:
+ resp.read = AsyncMock(return_value=raw)
+ resp.headers = headers or {}
+ return resp
+
+
+def _mock_session(responses):
+ iterator = iter(responses)
+
+ def _request(*_a, **_kw):
+ resp = next(iterator)
+ return AsyncMock(
+ __aenter__=AsyncMock(return_value=resp),
+ __aexit__=AsyncMock(return_value=False),
+ )
+
+ sess = AsyncMock()
+ sess.post = _request
+ sess.get = _request
+ return sess
+
+
+class TestPricingTable:
+ def test_known_model_maps_to_non_null_cost(self):
+ assert _cost_for("google/gemini-2.5-flash-image") is not None
+ assert _cost_for("google/gemini-2.5-flash-image") > 0
+
+ def test_unknown_model_returns_none(self, caplog):
+ assert _cost_for("nonexistent/model-xyz") is None
+
+ def test_gemini_preview_still_priced(self):
+ # Preview alias is deprecated but still has a price entry so if
+ # an admin call bypasses the generate-time guard the cost column
+ # is still usable for audit.
+ assert _cost_for("google/gemini-2.5-flash-image-preview") is not None
+
+ def test_pricing_table_contains_expected_providers(self):
+ ids = set(_MODEL_PRICING.keys())
+ assert any(m.startswith("google/gemini-") for m in ids)
+ assert any(m.startswith("openai/dall-e") for m in ids)
+ assert any(m.startswith("black-forest-labs/flux") for m in ids)
+
+ def test_env_override_merges_and_wins(self, monkeypatch):
+ monkeypatch.setenv(
+ "OPENROUTER_PRICING_OVERRIDE",
+ json.dumps({"google/gemini-2.5-flash-image": 0.001, "custom/model": 0.5}),
+ )
+ table = _pricing_table()
+ assert table["google/gemini-2.5-flash-image"] == 0.001
+ assert table["custom/model"] == 0.5
+ # Untouched entries stay from the hard-coded table.
+ assert "openai/dall-e-3" in table
+
+ def test_env_override_bad_json_is_ignored(self, monkeypatch):
+ monkeypatch.setenv("OPENROUTER_PRICING_OVERRIDE", "not-json{")
+ # Falls back to the default table, no exception.
+ assert (
+ _pricing_table()["google/gemini-2.5-flash-image"]
+ == _MODEL_PRICING["google/gemini-2.5-flash-image"]
+ )
+
+
+class TestGenerateAttributesCost:
+ @pytest.mark.asyncio
+ async def test_cost_usd_populated_for_known_model(self):
+ provider = OpenRouterProvider()
+ b64 = base64.b64encode(_PNG_1x1).decode()
+ ok = _fake_resp(
+ status=200,
+ json_data={
+ "choices": [
+ {
+ "message": {
+ "images": [{"image_url": {"url": f"data:image/png;base64,{b64}"}}]
+ }
+ }
+ ]
+ },
+ )
+ sess = _mock_session([ok])
+ with patch("plugins.ai_image.providers.openrouter.aiohttp.ClientSession") as cls:
+ cls.return_value = AsyncMock(
+ __aenter__=AsyncMock(return_value=sess),
+ __aexit__=AsyncMock(return_value=False),
+ )
+ result = await provider.generate("k", GenerationRequest(prompt="x"))
+ assert result.cost_usd == _MODEL_PRICING["google/gemini-2.5-flash-image"]
+
+ @pytest.mark.asyncio
+ async def test_cost_usd_null_for_unknown_model(self):
+ provider = OpenRouterProvider()
+ b64 = base64.b64encode(_PNG_1x1).decode()
+ ok = _fake_resp(
+ status=200,
+ json_data={
+ "choices": [
+ {
+ "message": {
+ "images": [{"image_url": {"url": f"data:image/png;base64,{b64}"}}]
+ }
+ }
+ ]
+ },
+ )
+ sess = _mock_session([ok])
+ with patch("plugins.ai_image.providers.openrouter.aiohttp.ClientSession") as cls:
+ cls.return_value = AsyncMock(
+ __aenter__=AsyncMock(return_value=sess),
+ __aexit__=AsyncMock(return_value=False),
+ )
+ result = await provider.generate(
+ "k", GenerationRequest(prompt="x", model="weird/unreleased-model")
+ )
+ assert result.cost_usd is None
+
+
+class TestDeprecatedModelGuard:
+ @pytest.mark.asyncio
+ async def test_preview_model_raises_model_deprecated(self):
+ provider = OpenRouterProvider()
+ with pytest.raises(ProviderError) as e:
+ await provider.generate(
+ "k",
+ GenerationRequest(prompt="x", model="google/gemini-2.5-flash-image-preview"),
+ )
+ assert e.value.code == "PROVIDER_MODEL_DEPRECATED"
+ assert "google/gemini-2.5-flash-image" in str(e.value.message)
+ assert e.value.details.get("replacement_model") == "google/gemini-2.5-flash-image"
diff --git a/tests/plugins/wordpress/test_openrouter_provider.py b/tests/plugins/wordpress/test_openrouter_provider.py
new file mode 100644
index 0000000..65a0114
--- /dev/null
+++ b/tests/plugins/wordpress/test_openrouter_provider.py
@@ -0,0 +1,314 @@
+"""F.5a.9 partial — Tests for the OpenRouter image-generation provider."""
+
+from __future__ import annotations
+
+import base64
+from unittest.mock import AsyncMock, patch
+
+import pytest
+
+from plugins.ai_image.providers.base import GenerationRequest, ProviderError
+from plugins.ai_image.providers.openrouter import (
+ OpenRouterProvider,
+ _extract_image_url,
+ _image_url_from_entry,
+)
+from plugins.ai_image.registry import get_provider, list_providers
+
+_PNG_1x1 = base64.b64decode(
+ b"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNkYAAAAAYAAjCB0C8AAAAASUVORK5CYII="
+)
+
+
+def _mock_session_post(responses: list):
+ """Build a fake aiohttp.ClientSession whose .post/.get cycle responses."""
+ iterator = iter(responses)
+
+ def _request(*_args, **_kwargs):
+ resp = next(iterator)
+ return AsyncMock(
+ __aenter__=AsyncMock(return_value=resp),
+ __aexit__=AsyncMock(return_value=False),
+ )
+
+ sess = AsyncMock()
+ sess.post = _request
+ sess.get = _request
+ return sess
+
+
+def _fake_resp(*, status: int, json_data=None, text_data="", raw=None, headers=None):
+ resp = AsyncMock()
+ resp.status = status
+ resp.text = AsyncMock(return_value=text_data or "")
+ if json_data is not None:
+ resp.json = AsyncMock(return_value=json_data)
+ if raw is not None:
+ resp.read = AsyncMock(return_value=raw)
+ resp.headers = headers or {}
+ return resp
+
+
+# ---------------------------------------------------------------------------
+# Registry
+# ---------------------------------------------------------------------------
+
+
+class TestRegistry:
+ def test_openrouter_is_registered(self):
+ assert "openrouter" in list_providers()
+
+ def test_registry_lookup_returns_singleton(self):
+ p = get_provider("openrouter")
+ assert p.name == "openrouter"
+ assert p is get_provider("openrouter") # same instance
+
+
+# ---------------------------------------------------------------------------
+# Response-shape parsers (pure, no network)
+# ---------------------------------------------------------------------------
+
+
+class TestImageUrlExtraction:
+ def test_image_url_from_dict_url(self):
+ entry = {"image_url": {"url": "data:image/png;base64,AAAA"}}
+ assert _image_url_from_entry(entry) == "data:image/png;base64,AAAA"
+
+ def test_image_url_from_string(self):
+ entry = {"image_url": "https://x.example/y.png"}
+ assert _image_url_from_entry(entry) == "https://x.example/y.png"
+
+ def test_image_url_bare_url_field(self):
+ assert (
+ _image_url_from_entry({"url": "https://z.example/a.png"}) == "https://z.example/a.png"
+ )
+
+ def test_image_url_from_junk(self):
+ assert _image_url_from_entry(None) is None
+ assert _image_url_from_entry({"image_url": 42}) is None
+
+ def test_extract_gemini_shape_message_images(self):
+ body = {
+ "choices": [
+ {"message": {"images": [{"image_url": {"url": "data:image/png;base64,Zm9v"}}]}}
+ ]
+ }
+ assert _extract_image_url(body) == "data:image/png;base64,Zm9v"
+
+ def test_extract_multimodal_content_parts(self):
+ body = {
+ "choices": [
+ {
+ "message": {
+ "content": [
+ {"type": "text", "text": "here's your image"},
+ {"type": "image_url", "image_url": {"url": "https://x/y.png"}},
+ ]
+ }
+ }
+ ]
+ }
+ assert _extract_image_url(body) == "https://x/y.png"
+
+ def test_extract_no_image_returns_none(self):
+ body = {"choices": [{"message": {"content": "just text"}}]}
+ assert _extract_image_url(body) is None
+
+ def test_extract_empty_choices(self):
+ assert _extract_image_url({"choices": []}) is None
+ assert _extract_image_url({}) is None
+
+
+# ---------------------------------------------------------------------------
+# Provider.generate — auth / request shape
+# ---------------------------------------------------------------------------
+
+
+class TestOpenRouterProvider:
+ @pytest.mark.asyncio
+ async def test_missing_api_key_raises_auth(self):
+ provider = OpenRouterProvider()
+ with pytest.raises(ProviderError) as e:
+ await provider.generate("", GenerationRequest(prompt="x"))
+ assert e.value.code == "PROVIDER_AUTH"
+
+ @pytest.mark.asyncio
+ async def test_happy_path_gemini_data_url(self):
+ provider = OpenRouterProvider()
+ b64 = base64.b64encode(_PNG_1x1).decode()
+ ok = _fake_resp(
+ status=200,
+ json_data={
+ "choices": [
+ {
+ "message": {
+ "images": [{"image_url": {"url": f"data:image/png;base64,{b64}"}}]
+ }
+ }
+ ],
+ "usage": {"total_tokens": 128},
+ },
+ )
+ sess = _mock_session_post([ok])
+ with patch("plugins.ai_image.providers.openrouter.aiohttp.ClientSession") as cls:
+ cls.return_value = AsyncMock(
+ __aenter__=AsyncMock(return_value=sess),
+ __aexit__=AsyncMock(return_value=False),
+ )
+ result = await provider.generate(
+ "or-test",
+ GenerationRequest(prompt="a cat"),
+ )
+ assert result.data == _PNG_1x1
+ assert result.mime == "image/png"
+ assert "gemini" in result.filename
+ assert result.meta.get("total_tokens") == 128
+ # F.X.fix #11: known model → non-null cost_usd from _MODEL_PRICING.
+ assert result.cost_usd is not None
+ assert result.cost_usd > 0
+
+ @pytest.mark.asyncio
+ async def test_http_url_is_fetched(self):
+ provider = OpenRouterProvider()
+ gen_resp = _fake_resp(
+ status=200,
+ json_data={
+ "choices": [
+ {"message": {"images": [{"image_url": {"url": "https://cdn.example/out.png"}}]}}
+ ]
+ },
+ )
+ fetch_resp = _fake_resp(
+ status=200,
+ raw=_PNG_1x1,
+ headers={"Content-Type": "image/png"},
+ )
+ sess = _mock_session_post([gen_resp, fetch_resp])
+ with patch("plugins.ai_image.providers.openrouter.aiohttp.ClientSession") as cls:
+ cls.return_value = AsyncMock(
+ __aenter__=AsyncMock(return_value=sess),
+ __aexit__=AsyncMock(return_value=False),
+ )
+ result = await provider.generate("or-test", GenerationRequest(prompt="x"))
+ assert result.data == _PNG_1x1
+ assert result.mime == "image/png"
+
+ @pytest.mark.asyncio
+ async def test_no_image_raises_bad_response(self):
+ provider = OpenRouterProvider()
+ empty = _fake_resp(
+ status=200,
+ json_data={"choices": [{"message": {"content": "no image for you"}}]},
+ )
+ sess = _mock_session_post([empty])
+ with patch("plugins.ai_image.providers.openrouter.aiohttp.ClientSession") as cls:
+ cls.return_value = AsyncMock(
+ __aenter__=AsyncMock(return_value=sess),
+ __aexit__=AsyncMock(return_value=False),
+ )
+ with pytest.raises(ProviderError) as e:
+ await provider.generate("or-test", GenerationRequest(prompt="x"))
+ assert e.value.code == "PROVIDER_BAD_RESPONSE"
+
+ @pytest.mark.asyncio
+ async def test_401_maps_to_provider_auth(self):
+ provider = OpenRouterProvider()
+ r401 = _fake_resp(status=401, text_data="invalid key")
+ sess = _mock_session_post([r401])
+ with patch("plugins.ai_image.providers.openrouter.aiohttp.ClientSession") as cls:
+ cls.return_value = AsyncMock(
+ __aenter__=AsyncMock(return_value=sess),
+ __aexit__=AsyncMock(return_value=False),
+ )
+ with pytest.raises(ProviderError) as e:
+ await provider.generate("bad-key", GenerationRequest(prompt="x"))
+ assert e.value.code == "PROVIDER_AUTH"
+
+ @pytest.mark.asyncio
+ async def test_400_maps_to_provider_bad_request(self):
+ provider = OpenRouterProvider()
+ r400 = _fake_resp(status=400, text_data="model not found")
+ sess = _mock_session_post([r400])
+ with patch("plugins.ai_image.providers.openrouter.aiohttp.ClientSession") as cls:
+ cls.return_value = AsyncMock(
+ __aenter__=AsyncMock(return_value=sess),
+ __aexit__=AsyncMock(return_value=False),
+ )
+ with pytest.raises(ProviderError) as e:
+ await provider.generate(
+ "or-test",
+ GenerationRequest(prompt="x", model="nonexistent/model"),
+ )
+ assert e.value.code == "PROVIDER_BAD_REQUEST"
+
+ @pytest.mark.asyncio
+ async def test_429_retried_then_succeeds(self):
+ provider = OpenRouterProvider()
+ b64 = base64.b64encode(_PNG_1x1).decode()
+ r429 = _fake_resp(status=429, text_data="slow down")
+ ok = _fake_resp(
+ status=200,
+ json_data={
+ "choices": [
+ {
+ "message": {
+ "images": [{"image_url": {"url": f"data:image/png;base64,{b64}"}}]
+ }
+ }
+ ]
+ },
+ )
+ sess = _mock_session_post([r429, ok])
+ with patch("plugins.ai_image.providers.openrouter.aiohttp.ClientSession") as cls:
+ cls.return_value = AsyncMock(
+ __aenter__=AsyncMock(return_value=sess),
+ __aexit__=AsyncMock(return_value=False),
+ )
+ with patch("plugins.ai_image.providers.openrouter.asyncio.sleep", AsyncMock()):
+ result = await provider.generate("or-test", GenerationRequest(prompt="x"))
+ assert result.data == _PNG_1x1
+
+ @pytest.mark.asyncio
+ async def test_persistent_5xx_raises_unavailable(self):
+ provider = OpenRouterProvider()
+ r500 = _fake_resp(status=500, text_data="boom")
+ sess = _mock_session_post([r500, r500, r500])
+ with patch("plugins.ai_image.providers.openrouter.aiohttp.ClientSession") as cls:
+ cls.return_value = AsyncMock(
+ __aenter__=AsyncMock(return_value=sess),
+ __aexit__=AsyncMock(return_value=False),
+ )
+ with patch("plugins.ai_image.providers.openrouter.asyncio.sleep", AsyncMock()):
+ with pytest.raises(ProviderError) as e:
+ await provider.generate("or-test", GenerationRequest(prompt="x"))
+ assert e.value.code == "PROVIDER_UNAVAILABLE"
+
+ @pytest.mark.asyncio
+ async def test_negative_prompt_appended_to_user_content(self, monkeypatch):
+ """Negative prompt is inlined into the user message as 'Avoid: ...'."""
+ provider = OpenRouterProvider()
+ captured: dict = {}
+
+ async def _mock_post_with_retry(self, api_key, payload):
+ captured["payload"] = payload
+ b64 = base64.b64encode(_PNG_1x1).decode()
+ return {
+ "choices": [
+ {
+ "message": {
+ "images": [{"image_url": {"url": f"data:image/png;base64,{b64}"}}]
+ }
+ }
+ ]
+ }
+
+ monkeypatch.setattr(
+ OpenRouterProvider, "_post_with_retry", _mock_post_with_retry, raising=True
+ )
+ await provider.generate(
+ "or-test",
+ GenerationRequest(prompt="a cat", negative_prompt="no dogs"),
+ )
+ user_msg = captured["payload"]["messages"][0]["content"]
+ assert "a cat" in user_msg
+ assert "Avoid: no dogs" in user_msg
diff --git a/tests/plugins/wordpress/test_regenerate_thumbnails.py b/tests/plugins/wordpress/test_regenerate_thumbnails.py
new file mode 100644
index 0000000..3f31041
--- /dev/null
+++ b/tests/plugins/wordpress/test_regenerate_thumbnails.py
@@ -0,0 +1,222 @@
+"""F.5a.8.2 — Tests for wordpress_regenerate_thumbnails."""
+
+from __future__ import annotations
+
+import json
+from unittest.mock import AsyncMock
+
+import pytest
+
+from plugins.wordpress.client import WordPressClient
+from plugins.wordpress.handlers.regenerate_thumbnails import (
+ RegenerateThumbnailsHandler,
+ get_tool_specifications,
+)
+
+
+@pytest.fixture
+def wp_client():
+ return WordPressClient(site_url="https://wp.example.com", username="u", app_password="p")
+
+
+@pytest.fixture
+def handler(wp_client):
+ return RegenerateThumbnailsHandler(wp_client)
+
+
+# ---------------------------------------------------------------------------
+# Spec
+# ---------------------------------------------------------------------------
+
+
+def test_spec_shape():
+ specs = get_tool_specifications()
+ assert len(specs) == 1
+ s = specs[0]
+ assert s["name"] == "regenerate_thumbnails"
+ assert s["scope"] == "write"
+ props = s["schema"]["properties"]
+ assert {"ids", "all", "offset", "limit"} <= set(props.keys())
+
+
+# ---------------------------------------------------------------------------
+# Validation (no round-trip)
+# ---------------------------------------------------------------------------
+
+
+@pytest.mark.asyncio
+async def test_neither_ids_nor_all_is_rejected(handler, wp_client, monkeypatch):
+ post_mock = AsyncMock()
+ monkeypatch.setattr(wp_client, "post", post_mock)
+
+ out = json.loads(await handler.regenerate_thumbnails())
+ assert out["ok"] is False
+ assert out["error"] == "invalid_request"
+ post_mock.assert_not_called()
+
+
+@pytest.mark.asyncio
+async def test_both_ids_and_all_is_rejected(handler, wp_client, monkeypatch):
+ post_mock = AsyncMock()
+ monkeypatch.setattr(wp_client, "post", post_mock)
+
+ out = json.loads(await handler.regenerate_thumbnails(ids=[1], all=True))
+ assert out["ok"] is False
+ assert "mutually exclusive" in out["message"]
+ post_mock.assert_not_called()
+
+
+@pytest.mark.asyncio
+async def test_empty_ids_list_is_rejected(handler, wp_client, monkeypatch):
+ post_mock = AsyncMock()
+ monkeypatch.setattr(wp_client, "post", post_mock)
+
+ out = json.loads(await handler.regenerate_thumbnails(ids=["abc", 0, -1]))
+ assert out["ok"] is False
+ assert out["error"] == "invalid_request"
+ post_mock.assert_not_called()
+
+
+# ---------------------------------------------------------------------------
+# Happy path — ids mode
+# ---------------------------------------------------------------------------
+
+
+@pytest.mark.asyncio
+async def test_ids_mode_dedupes_and_caps(handler, wp_client, monkeypatch):
+ captured: dict = {}
+
+ async def _post(route, json_data=None, use_custom_namespace=False):
+ captured["route"] = route
+ captured["body"] = json_data
+ captured["ns"] = use_custom_namespace
+ return {
+ "mode": "ids",
+ "attempted": 3,
+ "processed": 3,
+ "skipped": [],
+ "errors": [],
+ "plugin_version": "2.8.0",
+ }
+
+ monkeypatch.setattr(wp_client, "post", _post)
+
+ # 60 ids with duplicates — should be deduped and capped at 50
+ ids = [5, 5, 10, 10] + list(range(1, 57))
+ out = json.loads(await handler.regenerate_thumbnails(ids=ids))
+
+ assert out["ok"] is True
+ assert out["mode"] == "ids"
+ assert out["processed"] == 3
+ assert out["plugin_version"] == "2.8.0"
+ assert captured["route"] == "airano-mcp/v1/regenerate-thumbnails"
+ assert captured["ns"] is True
+ assert "all" not in captured["body"]
+ sent = captured["body"]["ids"]
+ assert len(sent) <= 50
+ assert len(sent) == len(set(sent)) # deduped
+
+
+@pytest.mark.asyncio
+async def test_all_mode_forwards_offset_and_limit(handler, wp_client, monkeypatch):
+ captured: dict = {}
+
+ async def _post(route, json_data=None, use_custom_namespace=False):
+ captured["body"] = json_data
+ return {
+ "mode": "all",
+ "attempted": 10,
+ "processed": 10,
+ "skipped": [],
+ "errors": [],
+ "offset": 100,
+ "limit": 10,
+ "has_more": True,
+ "next_offset": 110,
+ "total": 200,
+ "plugin_version": "2.8.0",
+ }
+
+ monkeypatch.setattr(wp_client, "post", _post)
+
+ out = json.loads(await handler.regenerate_thumbnails(all=True, offset=100, limit=10))
+
+ assert captured["body"] == {"all": True, "offset": 100, "limit": 10}
+ assert out["mode"] == "all"
+ assert out["has_more"] is True
+ assert out["next_offset"] == 110
+ assert out["total"] == 200
+
+
+@pytest.mark.asyncio
+async def test_all_mode_limit_capped_server_side(handler, wp_client, monkeypatch):
+ captured: dict = {}
+
+ async def _post(route, json_data=None, use_custom_namespace=False):
+ captured["body"] = json_data
+ return {"mode": "all", "attempted": 0, "processed": 0}
+
+ monkeypatch.setattr(wp_client, "post", _post)
+ await handler.regenerate_thumbnails(all=True, limit=9999)
+ assert captured["body"]["limit"] == 50
+
+
+@pytest.mark.asyncio
+async def test_all_mode_negative_offset_clamped(handler, wp_client, monkeypatch):
+ captured: dict = {}
+
+ async def _post(route, json_data=None, use_custom_namespace=False):
+ captured["body"] = json_data
+ return {"mode": "all", "attempted": 0, "processed": 0}
+
+ monkeypatch.setattr(wp_client, "post", _post)
+ await handler.regenerate_thumbnails(all=True, offset=-5)
+ assert captured["body"]["offset"] == 0
+
+
+# ---------------------------------------------------------------------------
+# Error paths
+# ---------------------------------------------------------------------------
+
+
+@pytest.mark.asyncio
+async def test_companion_unreachable_returns_structured_error(handler, wp_client, monkeypatch):
+ async def _boom(*_a, **_kw):
+ raise RuntimeError("404 - rest_no_route")
+
+ monkeypatch.setattr(wp_client, "post", _boom)
+ out = json.loads(await handler.regenerate_thumbnails(ids=[1, 2]))
+ assert out["ok"] is False
+ assert out["error"] == "companion_unreachable"
+ assert "v2.8.0" in out["hint"]
+
+
+@pytest.mark.asyncio
+async def test_non_dict_response_is_rejected(handler, wp_client, monkeypatch):
+ async def _weird(*_a, **_kw):
+ return ["not", "a", "dict"]
+
+ monkeypatch.setattr(wp_client, "post", _weird)
+ out = json.loads(await handler.regenerate_thumbnails(ids=[1]))
+ assert out["ok"] is False
+ assert out["error"] == "invalid_response"
+
+
+@pytest.mark.asyncio
+async def test_errors_from_companion_surface_through(handler, wp_client, monkeypatch):
+ async def _errs(*_a, **_kw):
+ return {
+ "mode": "ids",
+ "attempted": 2,
+ "processed": 0,
+ "skipped": [{"id": 42, "reason": "not_image"}],
+ "errors": [{"id": 99, "error": "file_missing"}],
+ "plugin_version": "2.8.0",
+ }
+
+ monkeypatch.setattr(wp_client, "post", _errs)
+ out = json.loads(await handler.regenerate_thumbnails(ids=[42, 99]))
+ # processed=0 with at least one error → not ok
+ assert out["ok"] is False
+ assert out["errors"] == [{"id": 99, "error": "file_missing"}]
+ assert out["skipped"] == [{"id": 42, "reason": "not_image"}]
diff --git a/tests/plugins/wordpress/test_seo_roundtrip.py b/tests/plugins/wordpress/test_seo_roundtrip.py
new file mode 100644
index 0000000..dc1703d
--- /dev/null
+++ b/tests/plugins/wordpress/test_seo_roundtrip.py
@@ -0,0 +1,122 @@
+"""F.X.fix #2 — Rank Math SEO roundtrip: write → read → assert equality.
+
+Protects against the regression where ``update_post_seo`` wrote
+``rank_math_seo_title`` (wrong key) while ``get_post_seo`` read back
+from the same wrong key, so a full write→read cycle looked successful
+but the WordPress frontend showed no SEO title because Rank Math never
+saw the value.
+"""
+
+from __future__ import annotations
+
+import json
+from unittest.mock import AsyncMock
+
+import pytest
+
+from plugins.wordpress.handlers.seo import SEOHandler
+
+
+@pytest.fixture
+def fake_wp_state():
+ """In-memory meta store shared across fake client get/post calls.
+
+ Rank Math is considered active when at least one canonical
+ rank_math_* key exists in the store, which mirrors the real
+ _check_seo_plugins detection heuristic.
+ """
+ return {
+ "meta": {
+ # Seeded so _check_seo_plugins detects Rank Math as active.
+ "rank_math_focus_keyword": "",
+ "rank_math_title": "",
+ "rank_math_description": "",
+ }
+ }
+
+
+@pytest.fixture
+def fake_client(fake_wp_state):
+ client = AsyncMock()
+
+ async def _get(endpoint, params=None, use_custom_namespace=False, use_woocommerce=False):
+ if endpoint.startswith("posts/"):
+ return {
+ "id": 42,
+ "title": {"rendered": "Test Post"},
+ "meta": dict(fake_wp_state["meta"]),
+ }
+ if endpoint == "posts":
+ return [
+ {
+ "id": 42,
+ "title": {"rendered": "Test Post"},
+ "meta": dict(fake_wp_state["meta"]),
+ }
+ ]
+ raise AssertionError(f"unexpected GET {endpoint}")
+
+ async def _post(endpoint, json_data=None, **_):
+ if endpoint.startswith("posts/") and json_data and "meta" in json_data:
+ fake_wp_state["meta"].update(json_data["meta"])
+ return {"id": 42, "meta": dict(fake_wp_state["meta"])}
+ raise AssertionError(f"unexpected POST {endpoint}")
+
+ client.get = _get
+ client.post = _post
+ return client
+
+
+class TestRankMathRoundtrip:
+ @pytest.mark.asyncio
+ async def test_update_writes_canonical_rank_math_title(self, fake_client, fake_wp_state):
+ handler = SEOHandler(fake_client)
+ # Force the bridge-status fallback path to detect Rank Math via
+ # meta heuristic (seeded in fixture).
+ raw = await handler.update_post_seo(
+ post_id=42,
+ focus_keyword="mcp hub",
+ seo_title="MCP Hub — unified MCP server",
+ meta_description="Self-hosted MCP platform",
+ )
+ resp = json.loads(raw)
+ assert "rank_math_title" in resp["updated_fields"]
+ # The wrong key must NOT be written — guards against silent
+ # regression of the rank_math_seo_title bug.
+ assert "rank_math_seo_title" not in resp["updated_fields"]
+ assert fake_wp_state["meta"]["rank_math_title"] == "MCP Hub — unified MCP server"
+ assert fake_wp_state["meta"]["rank_math_focus_keyword"] == "mcp hub"
+ assert fake_wp_state["meta"]["rank_math_description"] == "Self-hosted MCP platform"
+
+ @pytest.mark.asyncio
+ async def test_get_reads_canonical_rank_math_title(self, fake_client, fake_wp_state):
+ fake_wp_state["meta"].update(
+ {
+ "rank_math_focus_keyword": "mcp hub",
+ "rank_math_title": "Readback Title",
+ "rank_math_description": "Readback Desc",
+ }
+ )
+ handler = SEOHandler(fake_client)
+ raw = await handler.get_post_seo(post_id=42)
+ data = json.loads(raw)
+ assert data["plugin_detected"] == "rank_math"
+ assert data["seo_title"] == "Readback Title"
+ assert data["focus_keyword"] == "mcp hub"
+ assert data["meta_description"] == "Readback Desc"
+
+ @pytest.mark.asyncio
+ async def test_roundtrip_write_then_read_returns_non_empty(self, fake_client, fake_wp_state):
+ handler = SEOHandler(fake_client)
+ await handler.update_post_seo(
+ post_id=42,
+ focus_keyword="kw",
+ seo_title="Title A",
+ meta_description="Desc A",
+ )
+ raw = await handler.get_post_seo(post_id=42)
+ data = json.loads(raw)
+ # Every field we just wrote must come back non-empty.
+ assert data["focus_keyword"] == "kw"
+ assert data["seo_title"] == "Title A"
+ assert data["meta_description"] == "Desc A"
diff --git a/tests/plugins/wordpress/test_site_health.py b/tests/plugins/wordpress/test_site_health.py
new file mode 100644
index 0000000..5b87f43
--- /dev/null
+++ b/tests/plugins/wordpress/test_site_health.py
@@ -0,0 +1,176 @@
+"""F.18.6 — Tests for wordpress_site_health."""
+
+from __future__ import annotations
+
+import json
+from unittest.mock import AsyncMock
+
+import pytest
+
+from plugins.wordpress.client import WordPressClient
+from plugins.wordpress.handlers.site_health import (
+ SiteHealthHandler,
+ get_tool_specifications,
+)
+
+
+@pytest.fixture
+def wp_client():
+ return WordPressClient(site_url="https://wp.example.com", username="u", app_password="p")
+
+
+@pytest.fixture
+def handler(wp_client):
+ return SiteHealthHandler(wp_client)
+
+
+@pytest.fixture
+def companion_payload():
+ return {
+ "ok": True,
+ "wordpress": {
+ "version": "6.5.3",
+ "multisite": False,
+ "home_url": "https://wp.example.com",
+ "site_url": "https://wp.example.com",
+ "language": "en_US",
+ "timezone": "UTC",
+ "rest_enabled": True,
+ "application_passwords_available": True,
+ "debug_mode": False,
+ "https_home": True,
+ },
+ "php": {
+ "version": "8.1.27",
+ "memory_limit": "256M",
+ "max_execution_time": "30",
+ "upload_max_filesize": "64M",
+ "post_max_size": "128M",
+ "max_input_vars": 1000,
+ "extensions": ["curl", "gd", "json", "mbstring", "xml"],
+ "has_mbstring": True,
+ "has_curl": True,
+ "has_gd": True,
+ "has_imagick": False,
+ "has_intl": True,
+ },
+ "server": {
+ "software": "nginx/1.25.3",
+ "disk_free_bytes": 123456789,
+ "disk_total_bytes": 999999999,
+ },
+ "database": {
+ "version": "10.5.15",
+ "server_info": "10.5.15-MariaDB",
+ "charset": "utf8mb4",
+ "collate": "utf8mb4_unicode_ci",
+ "prefix": "wp_",
+ },
+ "plugins": {
+ "total_count": 20,
+ "active_count": 12,
+ "active": [
+ {
+ "file": "airano-mcp-bridge/airano-mcp-bridge.php",
+ "name": "Airano MCP Bridge for WordPress",
+ "version": "2.6.0",
+ }
+ ],
+ "must_use_count": 2,
+ },
+ "theme": {
+ "active": {
+ "name": "Twenty Twenty-Four",
+ "version": "1.1",
+ "stylesheet": "twentytwentyfour",
+ },
+ "parent": None,
+ "total_count": 3,
+ },
+ "checks": {
+ "writable_wp_content": True,
+ "writable_uploads": True,
+ "ssl_enabled": True,
+ },
+ "companion": {
+ "plugin_version": "2.6.0",
+ "routes": {
+ "seo_meta": True,
+ "site_health": True,
+ "audit_hook": False,
+ },
+ "features": {
+ "rank_math": True,
+ "yoast": False,
+ "woocommerce": False,
+ },
+ },
+ "generated_at_gmt": "2026-04-15T09:00:00Z",
+ "plugin_version": "2.6.0",
+ }
+
+
+@pytest.mark.asyncio
+async def test_happy_path(handler, wp_client, monkeypatch, companion_payload):
+ get_mock = AsyncMock(return_value=companion_payload)
+ monkeypatch.setattr(wp_client, "get", get_mock)
+
+ out = json.loads(await handler.site_health())
+ assert out["ok"] is True
+ assert out["companion_available"] is True
+ assert out["wordpress"]["version"] == "6.5.3"
+ assert out["php"]["version"] == "8.1.27"
+ assert out["database"]["charset"] == "utf8mb4"
+ assert out["plugins"]["active_count"] == 12
+ assert out["theme"]["active"]["name"] == "Twenty Twenty-Four"
+ assert out["checks"]["writable_uploads"] is True
+ assert out["companion"]["plugin_version"] == "2.6.0"
+
+ get_mock.assert_called_once_with(
+ "airano-mcp/v1/site-health",
+ use_custom_namespace=True,
+ )
+
+
+@pytest.mark.asyncio
+async def test_companion_unreachable_falls_back(handler, wp_client, monkeypatch):
+ monkeypatch.setattr(wp_client, "get", AsyncMock(side_effect=RuntimeError("404 Not Found")))
+
+ out = json.loads(await handler.site_health())
+ assert out["ok"] is False
+ assert out["companion_available"] is False
+ assert out["error"] == "companion_unreachable"
+ assert "wordpress_get_site_health" in out["hint"]
+ assert "manage_options" in out["hint"]
+
+
+@pytest.mark.asyncio
+async def test_non_dict_response(handler, wp_client, monkeypatch):
+ monkeypatch.setattr(wp_client, "get", AsyncMock(return_value=["not", "a", "dict"]))
+ out = json.loads(await handler.site_health())
+ assert out["ok"] is False
+ assert out["error"] == "invalid_response"
+ assert out["companion_available"] is False
+
+
+@pytest.mark.asyncio
+async def test_ok_inferred_when_payload_omits_it(handler, wp_client, monkeypatch):
+ """Older plugin builds might not set `ok`; derive it from presence/no-error."""
+ payload = {
+ "wordpress": {"version": "6.4"},
+ "php": {"version": "8.0"},
+ "plugin_version": "2.6.0",
+ }
+ monkeypatch.setattr(wp_client, "get", AsyncMock(return_value=payload))
+
+ out = json.loads(await handler.site_health())
+ assert out["ok"] is True
+ assert out["companion_available"] is True
+ assert out["wordpress"]["version"] == "6.4"
+
+
+def test_tool_spec_is_read_scope():
+ specs = get_tool_specifications()
+ assert len(specs) == 1
+ assert specs[0]["name"] == "site_health"
+ assert specs[0]["scope"] == "read"
diff --git a/tests/plugins/wordpress/test_tool_rate_limit.py b/tests/plugins/wordpress/test_tool_rate_limit.py
new file mode 100644
index 0000000..b95bf4a
--- /dev/null
+++ b/tests/plugins/wordpress/test_tool_rate_limit.py
@@ -0,0 +1,98 @@
+"""F.5a.6.1 — Per-tool rate limit tests."""
+
+from __future__ import annotations
+
+import json
+
+import pytest
+
+from core.tool_rate_limiter import (
+ PerToolRateLimiter,
+ ToolRateLimitError,
+ set_tool_rate_limiter,
+)
+from plugins.wordpress.client import WordPressClient
+from plugins.wordpress.handlers.ai_media import AIMediaHandler
+
+
+@pytest.fixture(autouse=True)
+def _fresh_limiter():
+ set_tool_rate_limiter(PerToolRateLimiter())
+ yield
+ set_tool_rate_limiter(None)
+
+
+def test_admin_exempt():
+ limiter = PerToolRateLimiter({"tool_x": 2})
+ for _ in range(10):
+ limiter.check("tool_x", None) # None = admin/env
+ limiter.check("tool_x", "")
+
+
+def test_per_user_limit_enforced():
+ limiter = PerToolRateLimiter({"tool_x": 3})
+ for _ in range(3):
+ limiter.check("tool_x", "alice")
+ with pytest.raises(ToolRateLimitError) as exc:
+ limiter.check("tool_x", "alice")
+ assert exc.value.tool_name == "tool_x"
+ assert exc.value.limit_per_hour == 3
+ d = exc.value.to_dict()
+ assert d["error_code"] == "TOOL_RATE_LIMITED"
+ assert d["details"]["tool"] == "tool_x"
+
+
+def test_different_users_isolated():
+ limiter = PerToolRateLimiter({"tool_x": 2})
+ limiter.check("tool_x", "alice")
+ limiter.check("tool_x", "alice")
+ with pytest.raises(ToolRateLimitError):
+ limiter.check("tool_x", "alice")
+ # Bob still has full quota
+ limiter.check("tool_x", "bob")
+ limiter.check("tool_x", "bob")
+
+
+def test_unknown_tool_is_unlimited():
+ limiter = PerToolRateLimiter({})
+ for _ in range(100):
+ limiter.check("whatever", "alice")
+
+
+@pytest.mark.asyncio
+async def test_ai_generate_returns_typed_error_on_11th_call():
+ """With cap of 10/hr/user, the 11th AI call yields TOOL_RATE_LIMITED."""
+ set_tool_rate_limiter(PerToolRateLimiter({"wordpress_generate_and_upload_image": 10}))
+ client = WordPressClient(site_url="https://wp.example.com", username="u", app_password="p")
+ handler = AIMediaHandler(client, user_id="alice")
+
+ # Pre-consume 10 tokens for alice
+ from core.tool_rate_limiter import get_tool_rate_limiter
+
+ limiter = get_tool_rate_limiter()
+ for _ in range(10):
+ limiter.check("wordpress_generate_and_upload_image", "alice")
+
+ # 11th call via handler should short-circuit with typed error and not
+ # even attempt to call the provider.
+ result_json = await handler.generate_and_upload_image(provider="openai", prompt="a cat")
+ result = json.loads(result_json)
+ assert result["error_code"] == "TOOL_RATE_LIMITED"
+ assert result["details"]["tool"] == "wordpress_generate_and_upload_image"
+
+
+@pytest.mark.asyncio
+async def test_ai_generate_another_user_not_rate_limited():
+ set_tool_rate_limiter(PerToolRateLimiter({"wordpress_generate_and_upload_image": 2}))
+ from core.tool_rate_limiter import get_tool_rate_limiter
+
+ limiter = get_tool_rate_limiter()
+ # Burn alice's quota
+ for _ in range(2):
+ limiter.check("wordpress_generate_and_upload_image", "alice")
+ with pytest.raises(ToolRateLimitError):
+ limiter.check("wordpress_generate_and_upload_image", "alice")
+
+ # Bob's check still passes (handler isn't invoked — we verify the
+ # limiter path, actual provider call is out of scope for this unit).
+ limiter.check("wordpress_generate_and_upload_image", "bob")
diff --git a/tests/plugins/wordpress/test_transient_flush.py b/tests/plugins/wordpress/test_transient_flush.py
new file mode 100644
index 0000000..d5d46f7
--- /dev/null
+++ b/tests/plugins/wordpress/test_transient_flush.py
@@ -0,0 +1,193 @@
+"""F.18.5 — Tests for wordpress_transient_flush."""
+
+from __future__ import annotations
+
+import json
+from unittest.mock import AsyncMock
+
+import pytest
+
+from plugins.wordpress.client import WordPressClient
+from plugins.wordpress.handlers.transient_flush import (
+ TransientFlushHandler,
+ _validate,
+ get_tool_specifications,
+)
+
+
+@pytest.fixture
+def wp_client():
+ return WordPressClient(site_url="https://wp.example.com", username="u", app_password="p")
+
+
+@pytest.fixture
+def handler(wp_client):
+ return TransientFlushHandler(wp_client)
+
+
+# ---------------------------------------------------------------------------
+# Client-side validation.
+# ---------------------------------------------------------------------------
+
+
+def test_validate_defaults_to_expired():
+ assert _validate(None, None) is None
+
+
+def test_validate_expired_ok():
+ assert _validate("expired", None) is None
+
+
+def test_validate_all_ok():
+ assert _validate("all", None) is None
+
+
+def test_validate_pattern_requires_pattern():
+ err = _validate("pattern", None)
+ assert err is not None
+ assert err["error"] == "pattern_required"
+
+
+def test_validate_pattern_with_glob_ok():
+ assert _validate("pattern", "rank_math_*") is None
+
+
+def test_validate_rejects_unknown_scope():
+ err = _validate("demolish", None)
+ assert err is not None
+ assert err["error"] == "invalid_scope"
+
+
+# ---------------------------------------------------------------------------
+# Handler behaviour.
+# ---------------------------------------------------------------------------
+
+
+@pytest.mark.asyncio
+async def test_invalid_scope_never_hits_network(handler, wp_client, monkeypatch):
+ post_mock = AsyncMock()
+ monkeypatch.setattr(wp_client, "post", post_mock)
+ out = json.loads(await handler.transient_flush(scope="demolish"))
+ assert out["ok"] is False
+ assert out["error"] == "invalid_scope"
+ post_mock.assert_not_called()
+
+
+@pytest.mark.asyncio
+async def test_pattern_without_pattern_rejected_client_side(handler, wp_client, monkeypatch):
+ post_mock = AsyncMock()
+ monkeypatch.setattr(wp_client, "post", post_mock)
+ out = json.loads(await handler.transient_flush(scope="pattern"))
+ assert out["ok"] is False
+ assert out["error"] == "pattern_required"
+ post_mock.assert_not_called()
+
+
+@pytest.mark.asyncio
+async def test_expired_happy_path(handler, wp_client, monkeypatch):
+ companion_response = {
+ "ok": True,
+ "scope": "expired",
+ "pattern": None,
+ "include_site_transients": True,
+ "deleted_count": 42,
+ "deleted_sample": ["foo", "bar"],
+ "plugin_version": "2.5.0",
+ }
+ post_mock = AsyncMock(return_value=companion_response)
+ monkeypatch.setattr(wp_client, "post", post_mock)
+
+ out = json.loads(await handler.transient_flush()) # default scope
+ assert out["ok"] is True
+ assert out["scope"] == "expired"
+ assert out["deleted_count"] == 42
+
+ call_args = post_mock.call_args
+ assert call_args.args[0] == "airano-mcp/v1/transient-flush"
+ body = call_args.kwargs["json_data"]
+ assert body["scope"] == "expired"
+ assert body["include_site_transients"] is True
+ assert "pattern" not in body
+
+
+@pytest.mark.asyncio
+async def test_pattern_passes_pattern_through(handler, wp_client, monkeypatch):
+ captured: dict = {}
+
+ async def fake_post(endpoint, json_data=None, **kwargs):
+ captured["body"] = json_data
+ return {
+ "ok": True,
+ "scope": "pattern",
+ "pattern": "rank_math_*",
+ "include_site_transients": False,
+ "deleted_count": 3,
+ "deleted_sample": ["rank_math_a", "rank_math_b", "rank_math_c"],
+ "plugin_version": "2.5.0",
+ }
+
+ monkeypatch.setattr(wp_client, "post", AsyncMock(side_effect=fake_post))
+
+ out = json.loads(
+ await handler.transient_flush(
+ scope="pattern",
+ pattern="rank_math_*",
+ include_site_transients=False,
+ )
+ )
+ assert out["ok"] is True
+ assert out["pattern"] == "rank_math_*"
+ assert out["deleted_count"] == 3
+ assert captured["body"]["pattern"] == "rank_math_*"
+ assert captured["body"]["include_site_transients"] is False
+
+
+@pytest.mark.asyncio
+async def test_include_site_transients_bool_coercion(handler, wp_client, monkeypatch):
+ captured: dict = {}
+
+ async def fake_post(endpoint, json_data=None, **kwargs):
+ captured["body"] = json_data
+ return {"ok": True, "scope": "all", "deleted_count": 0}
+
+ monkeypatch.setattr(wp_client, "post", AsyncMock(side_effect=fake_post))
+
+ # String "false" should coerce to False.
+ await handler.transient_flush(scope="all", include_site_transients="false")
+ assert captured["body"]["include_site_transients"] is False
+
+ # Integer 1 should coerce to True.
+ await handler.transient_flush(scope="all", include_site_transients=1)
+ assert captured["body"]["include_site_transients"] is True
+
+ # None / default → True.
+ await handler.transient_flush(scope="all", include_site_transients=None)
+ assert captured["body"]["include_site_transients"] is True
+
+
+@pytest.mark.asyncio
+async def test_companion_unreachable(handler, wp_client, monkeypatch):
+ monkeypatch.setattr(wp_client, "post", AsyncMock(side_effect=RuntimeError("404 Not Found")))
+ out = json.loads(await handler.transient_flush(scope="expired"))
+ assert out["ok"] is False
+ assert out["error"] == "companion_unreachable"
+
+
+@pytest.mark.asyncio
+async def test_non_dict_response(handler, wp_client, monkeypatch):
+ monkeypatch.setattr(wp_client, "post", AsyncMock(return_value="bad"))
+ out = json.loads(await handler.transient_flush(scope="expired"))
+ assert out["ok"] is False
+ assert out["error"] == "invalid_response"
+
+
+def test_tool_spec_is_admin_scope():
+ specs = get_tool_specifications()
+ assert len(specs) == 1
+ assert specs[0]["name"] == "transient_flush"
+ assert specs[0]["scope"] == "admin"
+ assert set(specs[0]["schema"]["properties"]["scope"]["enum"]) == {
+ "expired",
+ "all",
+ "pattern",
+ }
diff --git a/tests/plugins/wordpress/test_upload_and_attach.py b/tests/plugins/wordpress/test_upload_and_attach.py
new file mode 100644
index 0000000..08f458b
--- /dev/null
+++ b/tests/plugins/wordpress/test_upload_and_attach.py
@@ -0,0 +1,437 @@
+"""F.5a.8.5 — unified upload+metadata+attach+featured via companion.
+
+Tests the routing layer in ``_media_core.wp_raw_upload``:
+
+* When the cached capability probe advertises ``upload_and_attach`` AND
+ the caller supplies metadata/attach intent, we POST to
+ ``/airano-mcp/v1/upload-and-attach`` and ``_apply_metadata_and_attach``
+ becomes a no-op.
+* When the probe doesn't advertise the route, we fall through to the
+ legacy 3-step path (upload + PATCH metadata + PATCH featured).
+* When the companion call errors, we fall back to the legacy path and
+ metadata gets reapplied via the REST calls.
+"""
+
+from __future__ import annotations
+
+import json
+from unittest.mock import AsyncMock, patch
+
+import pytest
+
+from plugins.wordpress.client import WordPressClient
+from plugins.wordpress.handlers._media_core import (
+ _companion_has_upload_and_attach,
+ _companion_upload_and_attach,
+ wp_raw_upload,
+)
+from plugins.wordpress.handlers._media_security import UploadError
+from plugins.wordpress.handlers.media import _apply_metadata_and_attach
+
+_PNG_1x1 = bytes.fromhex(
+ "89504e470d0a1a0a0000000d4948445200000001000000010806000000"
+ "1f15c4890000000a49444154789c6300010000000500010a2db4b70000"
+ "000049454e44ae426082"
+)
+
+
+@pytest.fixture
+def client():
+ return WordPressClient(site_url="https://wp.example.com", username="admin", app_password="xxxx")
+
+
+# ---------------------------------------------------------------------------
+# Route advertisement check
+# ---------------------------------------------------------------------------
+
+
+class TestCompanionAdvertiseCheck:
+ @pytest.mark.asyncio
+ async def test_returns_true_when_route_advertised(self, client):
+ with patch(
+ "plugins.wordpress.handlers.capabilities.get_cached_capabilities",
+ AsyncMock(
+ return_value={
+ "companion_available": True,
+ "routes": {"upload_and_attach": True, "upload_chunk": True},
+ }
+ ),
+ ):
+ assert await _companion_has_upload_and_attach(client) is True
+
+ @pytest.mark.asyncio
+ async def test_returns_false_when_route_absent(self, client):
+ with patch(
+ "plugins.wordpress.handlers.capabilities.get_cached_capabilities",
+ AsyncMock(
+ return_value={
+ "companion_available": True,
+ "routes": {"upload_chunk": True}, # no upload_and_attach
+ }
+ ),
+ ):
+ assert await _companion_has_upload_and_attach(client) is False
+
+ @pytest.mark.asyncio
+ async def test_returns_false_when_companion_missing(self, client):
+ with patch(
+ "plugins.wordpress.handlers.capabilities.get_cached_capabilities",
+ AsyncMock(return_value={"companion_available": False}),
+ ):
+ assert await _companion_has_upload_and_attach(client) is False
+
+ @pytest.mark.asyncio
+ async def test_returns_false_on_cold_cache(self, client):
+ with patch(
+ "plugins.wordpress.handlers.capabilities.get_cached_capabilities",
+ AsyncMock(return_value=None),
+ ):
+ assert await _companion_has_upload_and_attach(client) is False
+
+
+# ---------------------------------------------------------------------------
+# wp_raw_upload routing: unified path when advertised + metadata supplied
+# ---------------------------------------------------------------------------
+
+
+class TestWpRawUploadRouting:
+ @pytest.mark.asyncio
+ async def test_uses_unified_path_when_advertised_and_metadata_supplied(self, client):
+ captured: dict = {}
+
+ async def _fake_unified(
+ _client,
+ data,
+ *,
+ sniffed,
+ disposition,
+ attach_to_post,
+ set_featured,
+ title,
+ alt_text,
+ caption,
+ description,
+ idempotency_key=None,
+ ):
+ captured["params"] = {
+ "attach_to_post": attach_to_post,
+ "set_featured": set_featured,
+ "title": title,
+ "alt_text": alt_text,
+ "caption": caption,
+ "description": description,
+ }
+ captured["called"] = True
+ return {
+ "id": 123,
+ "source_url": "https://wp.example.com/out.png",
+ "mime_type": "image/png",
+ "_upload_and_attach": {
+ "attach_to_post": attach_to_post,
+ "set_featured": set_featured,
+ },
+ }
+
+ with (
+ patch(
+ "plugins.wordpress.handlers._media_core._companion_has_upload_and_attach",
+ AsyncMock(return_value=True),
+ ),
+ patch(
+ "plugins.wordpress.handlers._media_core._companion_upload_and_attach",
+ _fake_unified,
+ ),
+ ):
+ media = await wp_raw_upload(
+ client,
+ _PNG_1x1,
+ filename="hero.png",
+ mime_hint="image/png",
+ attach_to_post=42,
+ set_featured=True,
+ title="Hero",
+ alt_text="A hero image",
+ caption="caption here",
+ )
+
+ assert captured.get("called") is True
+ assert captured["params"]["attach_to_post"] == 42
+ assert captured["params"]["set_featured"] is True
+ assert captured["params"]["title"] == "Hero"
+ assert media["_upload_route"] == "companion_unified"
+ assert media["id"] == 123
+
+ @pytest.mark.asyncio
+ async def test_skips_unified_when_no_metadata_intent(self, client):
+ """Upload with zero metadata → no reason to use unified route
+ even if advertised; fall through to legacy path."""
+ unified_called: dict = {"hit": False}
+
+ async def _fake_unified(*_a, **_kw):
+ unified_called["hit"] = True
+ return {}
+
+ legacy_mock = AsyncMock(return_value={"id": 1, "source_url": "x", "mime_type": "image/png"})
+ with (
+ patch(
+ "plugins.wordpress.handlers._media_core._companion_has_upload_and_attach",
+ AsyncMock(return_value=True),
+ ),
+ patch(
+ "plugins.wordpress.handlers._media_core._companion_upload_and_attach",
+ _fake_unified,
+ ),
+ patch(
+ "plugins.wordpress.handlers._media_core._should_use_companion",
+ AsyncMock(return_value=False),
+ ),
+ patch("plugins.wordpress.handlers._media_core.aiohttp.ClientSession") as sess_cls,
+ ):
+ # Bare /wp/v2/media session mock.
+ resp = AsyncMock()
+ resp.status = 201
+ resp.text = AsyncMock(return_value="{}")
+ resp.json = AsyncMock(
+ return_value={"id": 1, "source_url": "x", "mime_type": "image/png"}
+ )
+ sess = AsyncMock()
+ sess.post = lambda *a, **kw: AsyncMock(
+ __aenter__=AsyncMock(return_value=resp),
+ __aexit__=AsyncMock(return_value=False),
+ )
+ sess_cls.return_value = AsyncMock(
+ __aenter__=AsyncMock(return_value=sess),
+ __aexit__=AsyncMock(return_value=False),
+ )
+ _ = legacy_mock
+
+ media = await wp_raw_upload(client, _PNG_1x1, filename="x.png", mime_hint="image/png")
+
+ assert unified_called["hit"] is False
+ assert media["_upload_route"] == "rest"
+
+ @pytest.mark.asyncio
+ async def test_falls_back_when_unified_errors(self, client):
+ """Unified call raising UploadError → fall through to legacy
+ path, and the caller's metadata gets applied via the REST calls."""
+
+ async def _boom(*_a, **_kw):
+ raise UploadError("COMPANION_500", "simulated")
+
+ with (
+ patch(
+ "plugins.wordpress.handlers._media_core._companion_has_upload_and_attach",
+ AsyncMock(return_value=True),
+ ),
+ patch(
+ "plugins.wordpress.handlers._media_core._companion_upload_and_attach",
+ _boom,
+ ),
+ patch(
+ "plugins.wordpress.handlers._media_core._should_use_companion",
+ AsyncMock(return_value=False),
+ ),
+ patch("plugins.wordpress.handlers._media_core.aiohttp.ClientSession") as sess_cls,
+ ):
+ resp = AsyncMock()
+ resp.status = 201
+ resp.text = AsyncMock(return_value="{}")
+ resp.json = AsyncMock(
+ return_value={"id": 2, "source_url": "x", "mime_type": "image/png"}
+ )
+ sess = AsyncMock()
+ sess.post = lambda *a, **kw: AsyncMock(
+ __aenter__=AsyncMock(return_value=resp),
+ __aexit__=AsyncMock(return_value=False),
+ )
+ sess_cls.return_value = AsyncMock(
+ __aenter__=AsyncMock(return_value=sess),
+ __aexit__=AsyncMock(return_value=False),
+ )
+ media = await wp_raw_upload(
+ client,
+ _PNG_1x1,
+ filename="x.png",
+ mime_hint="image/png",
+ attach_to_post=7,
+ )
+
+ # Legacy path marker — metadata will be applied via separate calls.
+ assert media["_upload_route"] == "rest"
+ assert "_upload_and_attach" not in media
+
+
+# ---------------------------------------------------------------------------
+# _apply_metadata_and_attach: skip when unified route marker present
+# ---------------------------------------------------------------------------
+
+
+class TestApplyMetadataSkip:
+ @pytest.mark.asyncio
+ async def test_unified_marker_short_circuits(self, client):
+ # Media response carries the unified marker → no REST calls.
+ media = {"id": 55, "_upload_route": "companion_unified"}
+ update_mock = AsyncMock()
+ featured_mock = AsyncMock()
+ with (
+ patch(
+ "plugins.wordpress.handlers.media.wp_update_media_metadata",
+ update_mock,
+ ),
+ patch(
+ "plugins.wordpress.handlers.media.wp_set_featured_media",
+ featured_mock,
+ ),
+ ):
+ await _apply_metadata_and_attach(
+ client,
+ media,
+ title="t",
+ alt_text="a",
+ caption="c",
+ attach_to_post=7,
+ set_featured=True,
+ )
+ update_mock.assert_not_called()
+ featured_mock.assert_not_called()
+
+ @pytest.mark.asyncio
+ async def test_legacy_marker_runs_metadata_calls(self, client):
+ media = {"id": 55, "_upload_route": "rest"}
+ update_mock = AsyncMock()
+ featured_mock = AsyncMock()
+ with (
+ patch(
+ "plugins.wordpress.handlers.media.wp_update_media_metadata",
+ update_mock,
+ ),
+ patch(
+ "plugins.wordpress.handlers.media.wp_set_featured_media",
+ featured_mock,
+ ),
+ ):
+ await _apply_metadata_and_attach(
+ client,
+ media,
+ title="t",
+ alt_text="a",
+ caption="c",
+ attach_to_post=7,
+ set_featured=True,
+ )
+ update_mock.assert_awaited_once()
+ featured_mock.assert_awaited_once()
+
+
+# ---------------------------------------------------------------------------
+# _companion_upload_and_attach: query params + error mapping
+# ---------------------------------------------------------------------------
+
+
+def _build_fake_session(status: int, body_text: str):
+ resp = AsyncMock()
+ resp.status = status
+ resp.text = AsyncMock(return_value=body_text)
+ try:
+ _parsed = json.loads(body_text) if body_text else {}
+ except json.JSONDecodeError:
+ _parsed = {}
+ resp.json = AsyncMock(return_value=_parsed)
+ captured: dict = {}
+
+ def _post(url, **kwargs):
+ captured["url"] = url
+ captured["params"] = kwargs.get("params")
+ captured["headers"] = kwargs.get("headers")
+ return AsyncMock(
+ __aenter__=AsyncMock(return_value=resp),
+ __aexit__=AsyncMock(return_value=False),
+ )
+
+ sess = AsyncMock()
+ sess.post = _post
+ sess_cm = AsyncMock(
+ __aenter__=AsyncMock(return_value=sess),
+ __aexit__=AsyncMock(return_value=False),
+ )
+ return sess_cm, captured
+
+
+class TestCompanionUploadAndAttachClient:
+ @pytest.mark.asyncio
+ async def test_query_params_encoded_from_metadata(self, client):
+ cm, captured = _build_fake_session(
+ 200,
+ json.dumps({"id": 7, "source_url": "x", "mime_type": "image/png"}),
+ )
+ with patch(
+ "plugins.wordpress.handlers._media_core.aiohttp.ClientSession",
+ return_value=cm,
+ ):
+ out = await _companion_upload_and_attach(
+ client,
+ b"irrelevant",
+ sniffed="image/png",
+ disposition='attachment; filename="x.png"',
+ attach_to_post=42,
+ set_featured=True,
+ title="T",
+ alt_text="A",
+ caption="C",
+ description="D",
+ )
+ assert out["id"] == 7
+ p = captured["params"]
+ assert p["attach_to_post"] == "42"
+ assert p["set_featured"] == "true"
+ assert p["title"] == "T"
+ assert p["alt_text"] == "A"
+ assert p["caption"] == "C"
+ assert p["description"] == "D"
+ assert "airano-mcp/v1/upload-and-attach" in captured["url"]
+
+ @pytest.mark.asyncio
+ async def test_set_featured_ignored_without_attach_target(self, client):
+ cm, captured = _build_fake_session(
+ 200, json.dumps({"id": 1, "source_url": "x", "mime_type": "image/png"})
+ )
+ with patch(
+ "plugins.wordpress.handlers._media_core.aiohttp.ClientSession",
+ return_value=cm,
+ ):
+ await _companion_upload_and_attach(
+ client,
+ b"x",
+ sniffed="image/png",
+ disposition="attachment",
+ attach_to_post=None,
+ set_featured=True, # ignored when no attach_to_post
+ title=None,
+ alt_text=None,
+ caption=None,
+ description=None,
+ )
+ assert "set_featured" not in captured["params"]
+ assert "attach_to_post" not in captured["params"]
+
+ @pytest.mark.asyncio
+ async def test_500_maps_to_companion_status_error(self, client):
+ cm, _ = _build_fake_session(500, "internal error")
+ with patch(
+ "plugins.wordpress.handlers._media_core.aiohttp.ClientSession",
+ return_value=cm,
+ ):
+ with pytest.raises(UploadError) as e:
+ await _companion_upload_and_attach(
+ client,
+ b"x",
+ sniffed="image/png",
+ disposition="attachment",
+ attach_to_post=1,
+ set_featured=False,
+ title=None,
+ alt_text=None,
+ caption=None,
+ description=None,
+ )
+ assert e.value.code == "COMPANION_500"
diff --git a/tests/test_api_keys_bcrypt_upgrade.py b/tests/test_api_keys_bcrypt_upgrade.py
new file mode 100644
index 0000000..fe21fc0
--- /dev/null
+++ b/tests/test_api_keys_bcrypt_upgrade.py
@@ -0,0 +1,200 @@
+"""F.8 security hardening: bcrypt + legacy SHA-256 upgrade-on-verify.
+
+Ensures:
+
+* Fresh keys created via ``create_key`` are stored as bcrypt hashes
+ (``$2`` prefix). No new SHA-256 hashes can land.
+* Keys whose storage file carries legacy SHA-256 hashes still validate
+ (no customer lock-out), but the moment they validate once they are
+ re-hashed with bcrypt and persisted — so the file on disk
+ progressively drifts to bcrypt-only.
+* Legacy-hash verification uses ``hmac.compare_digest`` (constant-time)
+ rather than ``==`` to avoid a timing oracle on legacy entries.
+* Corrupt or truncated bcrypt hashes don't crash the verifier — they
+ return False so the caller emits a uniform 401.
+"""
+
+from __future__ import annotations
+
+import hashlib
+import json
+import tempfile
+from pathlib import Path
+
+import pytest
+
+from core.api_keys import APIKey, APIKeyManager
+
+
+@pytest.fixture
+def temp_storage():
+ with tempfile.NamedTemporaryFile(mode="w", delete=False, suffix=".json") as f:
+ storage_path = f.name
+ yield storage_path
+ Path(storage_path).unlink(missing_ok=True)
+
+
+@pytest.fixture
+def manager(temp_storage):
+ return APIKeyManager(storage_path=temp_storage)
+
+
+# ---------------------------------------------------------------------------
+# Fresh keys always bcrypt
+# ---------------------------------------------------------------------------
+
+
+class TestFreshKeysAreBcrypt:
+ @pytest.mark.unit
+ def test_created_key_stores_bcrypt_hash(self, manager):
+ result = manager.create_key(project_id="*", scope="read")
+ key = manager.keys[result["key_id"]]
+ assert APIKeyManager._is_bcrypt_hash(key.key_hash)
+
+ @pytest.mark.unit
+ def test_created_key_hash_is_not_sha256(self, manager):
+ plain = "cmp_test_plaintext_not_sha256"
+ # Grab the output of _hash_key directly and compare against what a
+ # SHA-256 would produce — they must differ.
+ bcrypt_hash = manager._hash_key(plain)
+ assert bcrypt_hash.startswith("$2")
+ assert bcrypt_hash != hashlib.sha256(plain.encode()).hexdigest()
+
+
+# ---------------------------------------------------------------------------
+# Legacy SHA-256 validation + upgrade
+# ---------------------------------------------------------------------------
+
+
+def _install_legacy_key(manager: APIKeyManager, raw_key: str, *, scope: str = "read") -> str:
+ """Inject a legacy SHA-256-hashed key directly into the manager.
+
+ Simulates a keys.json file that was written before F.8 landed.
+ Returns the key_id.
+ """
+ import uuid
+ from datetime import datetime
+
+ key_id = "legacy-" + uuid.uuid4().hex[:8]
+ legacy_hash = hashlib.sha256(raw_key.encode()).hexdigest()
+ manager.keys[key_id] = APIKey(
+ key_id=key_id,
+ key_hash=legacy_hash,
+ project_id="*",
+ scope=scope,
+ created_at=datetime.now().isoformat(),
+ )
+ manager._save_keys()
+ return key_id
+
+
+class TestLegacySha256Validation:
+ @pytest.mark.unit
+ def test_legacy_sha256_hash_still_validates(self, manager):
+ raw = "cmp_legacy_customer_key_1234567890"
+ _install_legacy_key(manager, raw)
+
+ assert manager.validate_key(raw, project_id="anything", required_scope="read") is not None
+
+ @pytest.mark.unit
+ def test_legacy_hash_rejects_wrong_key(self, manager):
+ _install_legacy_key(manager, "cmp_real_one_1234")
+ assert manager.validate_key("cmp_wrong", project_id="x", required_scope="read") is None
+
+ @pytest.mark.unit
+ def test_verify_uses_constant_time_on_legacy_path(self, manager):
+ """Sanity check: _verify_key returns the same type regardless of
+ how many leading characters of the SHA-256 hex happen to match."""
+ raw = "cmp_constant_time_check_key"
+ _install_legacy_key(manager, raw)
+ legacy_hash = hashlib.sha256(raw.encode()).hexdigest()
+
+ # Flip the very last byte of the hash — must still be rejected.
+ tampered = legacy_hash[:-1] + ("0" if legacy_hash[-1] != "0" else "1")
+ assert manager._verify_key(raw, tampered) is False
+ # Real hash still passes.
+ assert manager._verify_key(raw, legacy_hash) is True
+
+
+class TestLegacyHashUpgradeOnVerify:
+ @pytest.mark.unit
+ def test_validate_key_upgrades_legacy_hash_to_bcrypt(self, manager):
+ raw = "cmp_upgrade_target_1234567890"
+ key_id = _install_legacy_key(manager, raw)
+
+ # Before validation: legacy SHA-256 hash.
+ assert not APIKeyManager._is_bcrypt_hash(manager.keys[key_id].key_hash)
+
+ # Validate → upgrade path runs.
+ assert manager.validate_key(raw, project_id="*", required_scope="read") == key_id
+
+ # After validation: bcrypt.
+ assert APIKeyManager._is_bcrypt_hash(manager.keys[key_id].key_hash)
+
+ @pytest.mark.unit
+ def test_get_key_by_token_also_upgrades(self, manager):
+ raw = "cmp_get_by_token_upgrade_key"
+ key_id = _install_legacy_key(manager, raw)
+
+ assert manager.get_key_by_token(raw) is not None
+ assert APIKeyManager._is_bcrypt_hash(manager.keys[key_id].key_hash)
+
+ @pytest.mark.unit
+ def test_upgrade_is_persisted_to_disk(self, manager, temp_storage):
+ raw = "cmp_persistent_upgrade_key"
+ key_id = _install_legacy_key(manager, raw)
+
+ manager.validate_key(raw, project_id="*", required_scope="read")
+
+ on_disk = json.loads(Path(temp_storage).read_text())
+ assert APIKeyManager._is_bcrypt_hash(on_disk[key_id]["key_hash"])
+
+ @pytest.mark.unit
+ def test_upgrade_keeps_key_valid_after(self, manager):
+ raw = "cmp_double_validate_key"
+ _install_legacy_key(manager, raw)
+
+ # First call upgrades.
+ assert manager.validate_key(raw, project_id="*", required_scope="read") is not None
+ # Second call uses the bcrypt path.
+ assert manager.validate_key(raw, project_id="*", required_scope="read") is not None
+
+ @pytest.mark.unit
+ def test_upgrade_does_not_touch_bcrypt_keys(self, manager):
+ """Keys that are already bcrypt-hashed must not be re-hashed every
+ verify (bcrypt with fresh salt would invalidate later calls)."""
+ result = manager.create_key(project_id="*", scope="read")
+ raw = result["key"] # raw plaintext key (create_key returns ``key``)
+ before = manager.keys[result["key_id"]].key_hash
+
+ manager.validate_key(raw, project_id="*", required_scope="read")
+
+ after = manager.keys[result["key_id"]].key_hash
+ assert before == after
+
+
+# ---------------------------------------------------------------------------
+# Robustness: corrupt / truncated bcrypt hashes must not crash
+# ---------------------------------------------------------------------------
+
+
+class TestCorruptBcryptHandling:
+ @pytest.mark.unit
+ def test_truncated_bcrypt_returns_false(self, manager):
+ truncated = "$2b$12$truncated"
+ assert manager._verify_key("any", truncated) is False
+
+ @pytest.mark.unit
+ def test_non_bcrypt_non_matching_returns_false(self, manager):
+ assert manager._verify_key("any", "garbage-not-a-hash") is False
+
+ @pytest.mark.unit
+ def test_is_bcrypt_hash_classifier(self):
+ # Positive cases.
+ assert APIKeyManager._is_bcrypt_hash("$2b$12$abc.def")
+ assert APIKeyManager._is_bcrypt_hash("$2a$10$x")
+ assert APIKeyManager._is_bcrypt_hash("$2y$12$foo")
+ # Negative cases: SHA-256 hex and garbage.
+ assert not APIKeyManager._is_bcrypt_hash("a" * 64)
+ assert not APIKeyManager._is_bcrypt_hash("")
+ assert not APIKeyManager._is_bcrypt_hash("not-a-hash")
diff --git a/tests/test_capability_badge_integration.py b/tests/test_capability_badge_integration.py
new file mode 100644
index 0000000..ae699be
--- /dev/null
+++ b/tests/test_capability_badge_integration.py
@@ -0,0 +1,249 @@
+"""F.7e — dashboard_sites_view renders capability probe into template context.
+
+End-to-end-ish test: builds a real DB + site, stubs the plugin's probe,
+and verifies that ``dashboard_sites_view`` injects a ``capability_probe``
+context entry with a well-shaped ``fit`` dict. The manage.html template
+consumes this entry to render the badge; this test only covers the
+context wiring, not the template output (templates are hit in
+integration dashboard tests).
+"""
+
+from __future__ import annotations
+
+import base64
+import os
+from typing import Any
+from unittest.mock import patch
+
+import pytest
+
+
+@pytest.fixture(autouse=True)
+def _encryption_key(monkeypatch):
+ key = base64.b64encode(os.urandom(32)).decode()
+ monkeypatch.setenv("ENCRYPTION_KEY", key)
+ import core.encryption as enc_mod
+
+ monkeypatch.setattr(enc_mod, "_credential_encryption", None)
+
+
+@pytest.fixture(autouse=True)
+def _reset_probe_cache():
+ from core.capability_probe import get_probe_cache
+
+ get_probe_cache()._entries.clear()
+ yield
+ get_probe_cache()._entries.clear()
+
+
+@pytest.fixture
+async def _db_with_wp_site(tmp_path, monkeypatch):
+ import core.database as db_mod
+ from core.database import initialize_database
+ from core.encryption import get_credential_encryption
+
+ monkeypatch.setattr(db_mod, "_database", None)
+ db = await initialize_database(str(tmp_path / "badge.db"))
+
+ user = await db.create_user(
+ email="badge@example.com",
+ name="Badge",
+ provider="github",
+ provider_id="gh-badge",
+ )
+ enc = get_credential_encryption()
+ creds = enc.encrypt_credentials(
+ {"username": "admin", "app_password": "xxxx xxxx"}, "site-badge-1"
+ )
+ site = await db.create_site(
+ user_id=user["id"],
+ plugin_type="wordpress",
+ alias="bloggy",
+ url="https://wp.example.com",
+ credentials=creds,
+ )
+ await db.execute(
+ "UPDATE sites SET id = ?, tool_scope = ? WHERE id = ?",
+ ("site-badge-1", "admin", site["id"]),
+ )
+ site = await db.get_site("site-badge-1", user["id"])
+ yield db, user, site
+ await db.close()
+ monkeypatch.setattr(db_mod, "_database", None)
+
+
+class TestSitesViewInjectsCapabilityProbe:
+ @pytest.mark.asyncio
+ async def test_admin_tier_with_admin_role_renders_ok(self, _db_with_wp_site, monkeypatch):
+ _, user, site = _db_with_wp_site
+
+ async def _probe(self: Any) -> dict[str, Any]:
+ return {
+ "probe_available": True,
+ "granted": ["administrator", "manage_options", "upload_files"],
+ "source": "wordpress_companion",
+ "roles": ["administrator"],
+ }
+
+ from plugins.wordpress.plugin import WordPressPlugin
+
+ monkeypatch.setattr(WordPressPlugin, "probe_credential_capabilities", _probe)
+
+ captured: dict = {}
+
+ def _capture_template(_request, template_name, context, **kwargs):
+ captured["template"] = template_name
+ captured["context"] = context
+
+ # Return a minimal response object; the test doesn't render HTML.
+ class _Resp:
+ status_code = 200
+
+ return _Resp()
+
+ from starlette.requests import Request
+
+ from core.dashboard.routes import dashboard_sites_view
+
+ scope = {
+ "type": "http",
+ "method": "GET",
+ "path": f"/dashboard/sites/{site['id']}",
+ "path_params": {"id": site["id"]},
+ "headers": [],
+ "query_string": b"",
+ }
+
+ async def receive():
+ return {"type": "http.request", "body": b""}
+
+ request = Request(scope, receive)
+
+ with (
+ patch("core.dashboard.routes.templates") as mock_templates,
+ patch(
+ "core.dashboard.routes._require_user_session",
+ return_value=({"user_id": user["id"]}, None),
+ ),
+ ):
+ mock_templates.TemplateResponse = _capture_template
+ await dashboard_sites_view(request)
+
+ assert captured["template"] == "dashboard/sites/manage.html"
+ ctx = captured["context"]
+ assert "capability_probe" in ctx
+ probe = ctx["capability_probe"]
+ assert probe["probe_available"] is True
+ assert probe["fit"]["status"] == "ok"
+ assert probe["fit"]["missing"] == []
+
+ @pytest.mark.asyncio
+ async def test_admin_tier_with_editor_role_renders_warning(self, _db_with_wp_site, monkeypatch):
+ _, user, site = _db_with_wp_site
+
+ async def _probe(self: Any) -> dict[str, Any]:
+ return {
+ "probe_available": True,
+ "granted": ["editor", "edit_posts", "upload_files"],
+ "source": "wordpress_companion",
+ "roles": ["editor"],
+ }
+
+ from plugins.wordpress.plugin import WordPressPlugin
+
+ monkeypatch.setattr(WordPressPlugin, "probe_credential_capabilities", _probe)
+
+ captured: dict = {}
+
+ def _capture_template(_request, _name, context, **kwargs):
+ captured["context"] = context
+
+ class _Resp:
+ status_code = 200
+
+ return _Resp()
+
+ from starlette.requests import Request
+
+ from core.dashboard.routes import dashboard_sites_view
+
+ scope = {
+ "type": "http",
+ "method": "GET",
+ "path": f"/dashboard/sites/{site['id']}",
+ "path_params": {"id": site["id"]},
+ "headers": [],
+ "query_string": b"",
+ }
+
+ async def receive():
+ return {"type": "http.request", "body": b""}
+
+ request = Request(scope, receive)
+
+ with (
+ patch("core.dashboard.routes.templates") as mock_templates,
+ patch(
+ "core.dashboard.routes._require_user_session",
+ return_value=({"user_id": user["id"]}, None),
+ ),
+ ):
+ mock_templates.TemplateResponse = _capture_template
+ await dashboard_sites_view(request)
+
+ probe = captured["context"]["capability_probe"]
+ assert probe["fit"]["status"] == "warning"
+ assert "manage_options" in probe["fit"]["missing"]
+
+ @pytest.mark.asyncio
+ async def test_probe_raises_renders_probe_unavailable(self, _db_with_wp_site, monkeypatch):
+ _, user, site = _db_with_wp_site
+
+ async def _probe(self: Any) -> dict[str, Any]:
+ raise RuntimeError("no companion")
+
+ from plugins.wordpress.plugin import WordPressPlugin
+
+ monkeypatch.setattr(WordPressPlugin, "probe_credential_capabilities", _probe)
+
+ captured: dict = {}
+
+ def _capture_template(_request, _name, context, **kwargs):
+ captured["context"] = context
+
+ class _Resp:
+ status_code = 200
+
+ return _Resp()
+
+ from starlette.requests import Request
+
+ from core.dashboard.routes import dashboard_sites_view
+
+ scope = {
+ "type": "http",
+ "method": "GET",
+ "path": f"/dashboard/sites/{site['id']}",
+ "path_params": {"id": site["id"]},
+ "headers": [],
+ "query_string": b"",
+ }
+
+ async def receive():
+ return {"type": "http.request", "body": b""}
+
+ request = Request(scope, receive)
+
+ with (
+ patch("core.dashboard.routes.templates") as mock_templates,
+ patch(
+ "core.dashboard.routes._require_user_session",
+ return_value=({"user_id": user["id"]}, None),
+ ),
+ ):
+ mock_templates.TemplateResponse = _capture_template
+ await dashboard_sites_view(request)
+
+ probe = captured["context"]["capability_probe"]
+ assert probe["probe_available"] is False
+ assert probe["fit"]["status"] == "probe_unavailable"
diff --git a/tests/test_capability_badge_partial.py b/tests/test_capability_badge_partial.py
new file mode 100644
index 0000000..69ea327
--- /dev/null
+++ b/tests/test_capability_badge_partial.py
@@ -0,0 +1,86 @@
+"""F.X.fix #9 — capability badge HTMX partial endpoint + template.
+
+Regression: the Re-check button called ``window.location.reload()``,
+which is jarring on slow networks and discards unrelated unsaved
+state on the page. Fix: swap the badge in place via HTMX. The button
+now carries ``hx-get="/api/sites/{id}/capabilities/badge"`` and the
+target element has a matching id.
+
+These tests don't spin up Starlette — they verify the two contracts
+the front end depends on:
+
+ 1. The partial template contains the right HTMX attrs (hx-get,
+ hx-target, hx-swap outerHTML) so the button actually triggers a
+ swap of just the badge element.
+ 2. The new Starlette handler name is importable from
+ ``core.capability_probe`` and wired under
+ /api/sites/{id}/capabilities/badge in server.py.
+"""
+
+from __future__ import annotations
+
+from pathlib import Path
+
+import pytest
+
+BADGE_PARTIAL = (
+ Path(__file__).resolve().parent.parent / "core/templates/dashboard/sites/_capability_badge.html"
+)
+MANAGE_PAGE = Path(__file__).resolve().parent.parent / "core/templates/dashboard/sites/manage.html"
+SERVER_PY = Path(__file__).resolve().parent.parent / "server.py"
+
+
+class TestBadgePartialTemplate:
+ @pytest.mark.unit
+ def test_partial_exists(self):
+ assert BADGE_PARTIAL.is_file(), "_capability_badge.html partial must exist"
+
+ @pytest.mark.unit
+ def test_partial_has_stable_target_id(self):
+ text = BADGE_PARTIAL.read_text()
+ # The target id MUST match the hx-target value and the manage.html
+ # container id the old JS used.
+ assert 'id="capability-badge"' in text
+
+ @pytest.mark.unit
+ def test_partial_has_htmx_attrs(self):
+ text = BADGE_PARTIAL.read_text()
+ assert 'hx-get="/api/sites/{{ site.id }}/capabilities/badge?force=1"' in text
+ assert 'hx-target="#capability-badge"' in text
+ assert 'hx-swap="outerHTML"' in text
+
+ @pytest.mark.unit
+ def test_partial_renders_all_four_fit_states(self):
+ text = BADGE_PARTIAL.read_text()
+ # One branch per fit.status value produced by evaluate_tier_fit.
+ for state in ("ok", "warning", "probe_unavailable", "unknown_tier"):
+ assert state in text, f"partial missing branch for status={state!r}"
+
+
+class TestManagePageUsesPartial:
+ @pytest.mark.unit
+ def test_manage_page_includes_partial(self):
+ text = MANAGE_PAGE.read_text()
+ assert '"dashboard/sites/_capability_badge.html"' in text
+
+ @pytest.mark.unit
+ def test_legacy_reload_shim_is_gone(self):
+ text = MANAGE_PAGE.read_text()
+ # The old window.location.reload()-based recheck must not be
+ # the mechanism any more.
+ assert "__capabilityProbe.recheck()" not in text
+ assert "window.__capabilityProbe" not in text
+
+
+class TestHandlerIsWired:
+ @pytest.mark.unit
+ def test_handler_importable(self):
+ from core.capability_probe import api_site_capabilities_badge
+
+ assert callable(api_site_capabilities_badge)
+
+ @pytest.mark.unit
+ def test_route_registered_in_server_py(self):
+ text = SERVER_PY.read_text()
+ assert "/api/sites/{id}/capabilities/badge" in text
+ assert "api_site_capabilities_badge" in text
diff --git a/tests/test_capability_probe.py b/tests/test_capability_probe.py
new file mode 100644
index 0000000..812138f
--- /dev/null
+++ b/tests/test_capability_probe.py
@@ -0,0 +1,367 @@
+"""F.7e — tests for the per-site credential capability probe.
+
+Covers:
+
+* ``BasePlugin.probe_credential_capabilities`` default (probe unavailable).
+* WordPress override: extracts granted capabilities from the companion
+ payload, surfaces roles + plugin_version, and degrades gracefully
+ when the companion isn't installed.
+* ``probe_site_capabilities`` caches, decrypts credentials safely,
+ handles unknown sites, plugin-not-registered, and probe failures.
+* ``_ProbeCache`` TTL expiry + explicit invalidation.
+* Starlette handler: 401 unauth, 404 unknown site, happy-path body.
+"""
+
+from __future__ import annotations
+
+import base64
+import os
+from typing import Any
+from unittest.mock import AsyncMock, MagicMock, patch
+
+import pytest
+
+from core.capability_probe import (
+ _ProbeCache,
+ api_site_capabilities,
+ get_probe_cache,
+ probe_site_capabilities,
+)
+from plugins.base import BasePlugin
+
+# ---------------------------------------------------------------------------
+# BasePlugin default
+# ---------------------------------------------------------------------------
+
+
+class _DummyPlugin(BasePlugin):
+ def get_plugin_name(self) -> str:
+ return "dummy"
+
+
+class TestBasePluginDefault:
+ @pytest.mark.asyncio
+ async def test_default_reports_probe_unavailable(self):
+ p = _DummyPlugin(config={"url": "https://x"}, project_id="dummy_t")
+ out = await p.probe_credential_capabilities()
+ assert out["probe_available"] is False
+ assert out["granted"] == []
+ assert out["source"] == "unavailable"
+ assert out["reason"] == "probe_not_implemented"
+
+
+# ---------------------------------------------------------------------------
+# WordPress override
+# ---------------------------------------------------------------------------
+
+
+@pytest.fixture
+def wp_plugin():
+ """Build a WordPress plugin instance with a stubbed client."""
+ from plugins.wordpress.plugin import WordPressPlugin
+
+ return WordPressPlugin(
+ config={
+ "url": "https://wp.example.com",
+ "username": "admin",
+ "app_password": "xxxx xxxx xxxx",
+ },
+ project_id="wordpress_probe_test",
+ )
+
+
+class TestWordPressProbe:
+ @pytest.mark.asyncio
+ async def test_happy_path_extracts_granted_caps(self, wp_plugin, monkeypatch):
+ companion_payload = {
+ "companion_available": True,
+ "plugin_version": "2.8.0",
+ "user": {
+ "id": 1,
+ "login": "admin",
+ "roles": ["administrator"],
+ "capabilities": {
+ "edit_posts": True,
+ "upload_files": True,
+ "manage_options": True,
+ "read": True,
+ "publish_pages": False,
+ },
+ },
+ "features": {"rank_math": True},
+ "routes": {"cache_purge": True},
+ }
+ monkeypatch.setattr(
+ wp_plugin.capabilities,
+ "_fetch_capabilities",
+ AsyncMock(return_value=companion_payload),
+ )
+
+ out = await wp_plugin.probe_credential_capabilities()
+ assert out["probe_available"] is True
+ assert out["source"] == "wordpress_companion"
+ assert set(out["granted"]) == {"edit_posts", "upload_files", "manage_options", "read"}
+ assert out["roles"] == ["administrator"]
+ assert out["plugin_version"] == "2.8.0"
+
+ @pytest.mark.asyncio
+ async def test_companion_unavailable_returns_probe_false(self, wp_plugin, monkeypatch):
+ monkeypatch.setattr(
+ wp_plugin.capabilities,
+ "_fetch_capabilities",
+ AsyncMock(
+ return_value={
+ "companion_available": False,
+ "reason": "companion_unreachable: 404",
+ "site_url": "https://wp.example.com",
+ }
+ ),
+ )
+
+ out = await wp_plugin.probe_credential_capabilities()
+ assert out["probe_available"] is False
+ assert out["granted"] == []
+ assert "companion_unreachable" in out["reason"]
+
+ @pytest.mark.asyncio
+ async def test_probe_call_failure_does_not_raise(self, wp_plugin, monkeypatch):
+ async def _boom(*_a, **_kw):
+ raise RuntimeError("network collapsed")
+
+ monkeypatch.setattr(wp_plugin.capabilities, "_fetch_capabilities", _boom)
+
+ out = await wp_plugin.probe_credential_capabilities()
+ # _empty_capabilities_payload kicks in → probe_available stays False.
+ assert out["probe_available"] is False
+ assert "reason" in out
+
+
+# ---------------------------------------------------------------------------
+# Cache behaviour
+# ---------------------------------------------------------------------------
+
+
+class TestProbeCache:
+ @pytest.mark.unit
+ def test_get_missing_returns_none(self):
+ c = _ProbeCache(ttl_seconds=60)
+ assert c.get("no-such-id") is None
+
+ @pytest.mark.unit
+ def test_set_then_get_round_trip(self):
+ c = _ProbeCache(ttl_seconds=60)
+ c.set("s1", {"granted": ["read"]})
+ assert c.get("s1") == {"granted": ["read"]}
+
+ @pytest.mark.unit
+ def test_expiry_evicts(self):
+ c = _ProbeCache(ttl_seconds=0)
+ c.set("s1", {"granted": ["read"]})
+ # ttl=0 → immediately expired.
+ assert c.get("s1") is None
+
+ @pytest.mark.unit
+ def test_invalidate_removes(self):
+ c = _ProbeCache(ttl_seconds=60)
+ c.set("s1", {"granted": ["read"]})
+ assert c.invalidate("s1") is True
+ assert c.get("s1") is None
+ # Second invalidate on empty slot returns False.
+ assert c.invalidate("s1") is False
+
+ @pytest.mark.unit
+ def test_get_probe_cache_is_singleton(self):
+ a = get_probe_cache()
+ b = get_probe_cache()
+ assert a is b
+
+
+# ---------------------------------------------------------------------------
+# probe_site_capabilities (integration with DB + encryption)
+# ---------------------------------------------------------------------------
+
+
+@pytest.fixture(autouse=True)
+def _reset_probe_cache():
+ cache = get_probe_cache()
+ cache._entries.clear()
+ yield
+ cache._entries.clear()
+
+
+@pytest.fixture(autouse=True)
+def _encryption_key(monkeypatch):
+ key = base64.b64encode(os.urandom(32)).decode()
+ monkeypatch.setenv("ENCRYPTION_KEY", key)
+ import core.encryption as enc_mod
+
+ monkeypatch.setattr(enc_mod, "_credential_encryption", None)
+
+
+@pytest.fixture
+async def db_with_site(tmp_path, monkeypatch):
+ """Initialize a real DB + fixture user and WP site."""
+ import core.database as db_mod
+ from core.database import initialize_database
+ from core.encryption import get_credential_encryption
+
+ monkeypatch.setattr(db_mod, "_database", None)
+ database = await initialize_database(str(tmp_path / "probe.db"))
+ user = await database.create_user(
+ email="probe@example.com",
+ name="Probe",
+ provider="github",
+ provider_id="gh-probe",
+ )
+
+ enc = get_credential_encryption()
+ creds = enc.encrypt_credentials(
+ {"username": "admin", "app_password": "yyyy yyyy yyyy"},
+ "site-probe-1", # any scope string; decrypt uses same
+ )
+ site = await database.create_site(
+ user_id=user["id"],
+ plugin_type="wordpress",
+ alias="blog",
+ url="https://wp.example.com",
+ credentials=creds,
+ )
+ # Force known id so encryption scope matches.
+ await database.execute(
+ "UPDATE sites SET id = ? WHERE id = ?",
+ ("site-probe-1", site["id"]),
+ )
+ # Re-fetch.
+ site = await database.get_site("site-probe-1", user["id"])
+
+ yield database, user, site
+ await database.close()
+ monkeypatch.setattr(db_mod, "_database", None)
+
+
+class TestProbeSiteCapabilities:
+ @pytest.mark.asyncio
+ async def test_unknown_site_returns_structured_error(self, db_with_site):
+ _, user, _ = db_with_site
+ out = await probe_site_capabilities("no-such", user["id"])
+ assert out["reason"] == "site_not_found"
+ assert out["probe_available"] is False
+
+ @pytest.mark.asyncio
+ async def test_happy_path_caches_result(self, db_with_site, monkeypatch):
+ _, user, site = db_with_site
+
+ call_count = {"n": 0}
+
+ async def _fake_probe(self: Any) -> dict[str, Any]:
+ call_count["n"] += 1
+ return {
+ "probe_available": True,
+ "granted": ["edit_posts", "upload_files"],
+ "source": "wordpress_companion",
+ "roles": ["administrator"],
+ "plugin_version": "2.8.0",
+ }
+
+ from plugins.wordpress.plugin import WordPressPlugin
+
+ monkeypatch.setattr(WordPressPlugin, "probe_credential_capabilities", _fake_probe)
+
+ out1 = await probe_site_capabilities(site["id"], user["id"])
+ assert out1["probe_available"] is True
+ assert out1["cached"] is False
+ assert set(out1["granted"]) == {"edit_posts", "upload_files"}
+ assert out1["roles"] == ["administrator"]
+
+ # Second call hits cache.
+ out2 = await probe_site_capabilities(site["id"], user["id"])
+ assert out2["cached"] is True
+ assert call_count["n"] == 1 # only one real probe
+
+ # Force bypass.
+ out3 = await probe_site_capabilities(site["id"], user["id"], force=True)
+ assert out3["cached"] is False
+ assert call_count["n"] == 2
+
+ @pytest.mark.asyncio
+ async def test_probe_exception_is_swallowed(self, db_with_site, monkeypatch):
+ _, user, site = db_with_site
+
+ async def _boom(self: Any) -> dict[str, Any]:
+ raise RuntimeError("no network")
+
+ from plugins.wordpress.plugin import WordPressPlugin
+
+ monkeypatch.setattr(WordPressPlugin, "probe_credential_capabilities", _boom)
+
+ out = await probe_site_capabilities(site["id"], user["id"])
+ assert out["probe_available"] is False
+ assert "probe_call_failed" in out["reason"]
+
+
+# ---------------------------------------------------------------------------
+# Starlette handler
+# ---------------------------------------------------------------------------
+
+
+def _make_probe_request(site_id: str, force: bool = False):
+ from starlette.requests import Request
+
+ query = b"force=1" if force else b""
+ scope = {
+ "type": "http",
+ "method": "GET",
+ "path": f"/api/sites/{site_id}/capabilities",
+ "path_params": {"id": site_id},
+ "headers": [],
+ "query_string": query,
+ }
+
+ async def receive():
+ return {"type": "http.request", "body": b""}
+
+ return Request(scope, receive)
+
+
+class TestCapabilitiesEndpoint:
+ @pytest.mark.asyncio
+ async def test_unauthenticated_returns_401(self):
+ with patch("core.dashboard.routes._require_user_session") as mock_guard:
+ mock_guard.return_value = (None, MagicMock())
+ resp = await api_site_capabilities(_make_probe_request("s1"))
+ assert resp.status_code == 401
+
+ @pytest.mark.asyncio
+ async def test_unknown_site_returns_404(self, db_with_site):
+ _, user, _ = db_with_site
+ with patch("core.dashboard.routes._require_user_session") as mock_guard:
+ mock_guard.return_value = ({"user_id": user["id"]}, None)
+ resp = await api_site_capabilities(_make_probe_request("no-such"))
+ assert resp.status_code == 404
+
+ @pytest.mark.asyncio
+ async def test_happy_path_returns_payload(self, db_with_site, monkeypatch):
+ _, user, site = db_with_site
+
+ async def _fake_probe(self: Any) -> dict[str, Any]:
+ return {
+ "probe_available": True,
+ "granted": ["edit_posts", "upload_files"],
+ "source": "wordpress_companion",
+ }
+
+ from plugins.wordpress.plugin import WordPressPlugin
+
+ monkeypatch.setattr(WordPressPlugin, "probe_credential_capabilities", _fake_probe)
+
+ with patch("core.dashboard.routes._require_user_session") as mock_guard:
+ mock_guard.return_value = ({"user_id": user["id"]}, None)
+ resp = await api_site_capabilities(_make_probe_request(site["id"]))
+ assert resp.status_code == 200
+ import json as _json
+
+ body = _json.loads(bytes(resp.body))
+ assert body["ok"] is True
+ assert body["probe_available"] is True
+ assert body["plugin_type"] == "wordpress"
+ assert set(body["granted"]) == {"edit_posts", "upload_files"}
diff --git a/tests/test_capability_probe_adapters.py b/tests/test_capability_probe_adapters.py
new file mode 100644
index 0000000..d46d7f0
--- /dev/null
+++ b/tests/test_capability_probe_adapters.py
@@ -0,0 +1,235 @@
+"""F.7e — WooCommerce + Gitea ``probe_credential_capabilities`` adapters.
+
+Each plugin's override is exercised with a stubbed client / network layer
+so the test is purely logic.
+"""
+
+from __future__ import annotations
+
+from unittest.mock import AsyncMock, MagicMock, patch
+
+import pytest
+
+# ---------------------------------------------------------------------------
+# WooCommerce
+# ---------------------------------------------------------------------------
+
+
+@pytest.fixture
+def wc_plugin():
+ from plugins.woocommerce.plugin import WooCommercePlugin
+
+ return WooCommercePlugin(
+ config={
+ "url": "https://shop.example.com",
+ "consumer_key": "ck_1234567890abcdefghijklmnopqrstuvwxyzABC",
+ "consumer_secret": "cs_1234567890abcdefghijklmnopqrstuvwxyzABC",
+ },
+ project_id="wc_probe_test",
+ )
+
+
+class TestWooCommerceProbe:
+ @pytest.mark.asyncio
+ async def test_read_permission_grants_read_only(self, wc_plugin, monkeypatch):
+ payload = {
+ "security": {
+ "rest_api_keys": [
+ {
+ "truncated_key": "wxyzABC", # last 7 chars of consumer_key
+ "permissions": "read",
+ }
+ ]
+ }
+ }
+ monkeypatch.setattr(wc_plugin.client, "get", AsyncMock(return_value=payload))
+
+ out = await wc_plugin.probe_credential_capabilities()
+ assert out["probe_available"] is True
+ assert out["source"] == "woocommerce_system_status"
+ assert out["permissions"] == "read"
+ assert set(out["granted"]) == {"read_products", "read_orders"}
+
+ @pytest.mark.asyncio
+ async def test_read_write_permission_grants_everything(self, wc_plugin, monkeypatch):
+ payload = {
+ "security": {
+ "rest_api_keys": [{"truncated_key": "wxyzABC", "permissions": "read_write"}]
+ }
+ }
+ monkeypatch.setattr(wc_plugin.client, "get", AsyncMock(return_value=payload))
+
+ out = await wc_plugin.probe_credential_capabilities()
+ assert set(out["granted"]) == {
+ "read_products",
+ "read_orders",
+ "write_products",
+ "write_orders",
+ }
+
+ @pytest.mark.asyncio
+ async def test_key_not_listed_falls_back_to_inferred(self, wc_plugin, monkeypatch):
+ """F.X.fix-pass3 — when the consumer key isn't in
+ system_status (truncated_key mismatch is common across WC
+ builds), fall back to ``probe_inferred`` instead of returning
+ a misleading probe_unavailable that triggers the badge's
+ "install companion plugin" hint (WC has no companion).
+
+ F.X.fix-pass5 — STAY CONSERVATIVE: report read-only on the
+ inferred path. The previous pass probed ``settings`` and
+ upgraded to write+admin on 200, but ``/wc/v3/settings`` checks
+ the WP user's manage_woocommerce capability (not the API key's
+ WC permission), so an admin user with a read-only key was
+ being over-granted and tier-fit "Read + Write" stayed green.
+ Now the badge correctly warns when ck/cs is read-only and
+ the user picks Read+Write tier; tools that actually have
+ write permission still execute fine.
+ """
+ payload = {
+ "security": {
+ "rest_api_keys": [
+ {"truncated_key": "xxxxxxx", "permissions": "read"},
+ {"truncated_key": "yyyyyyy", "permissions": "write"},
+ ]
+ }
+ }
+
+ async def _fake_get(path, **kwargs):
+ if path == "system_status":
+ return payload
+ raise RuntimeError(f"unexpected path {path}")
+
+ monkeypatch.setattr(wc_plugin.client, "get", _fake_get)
+
+ out = await wc_plugin.probe_credential_capabilities()
+ assert out["probe_available"] is True
+ assert out.get("probe_inferred") is True
+ # Conservative: only read perms reported, write withheld.
+ assert "read_products" in out["granted"]
+ assert "read_orders" in out["granted"]
+ assert "write_products" not in out["granted"]
+ assert "write_orders" not in out["granted"]
+ assert out["source"] == "woocommerce_system_status_inferred"
+
+ @pytest.mark.asyncio
+ async def test_single_key_fallback_when_truncation_missing(self, wc_plugin, monkeypatch):
+ """Some WC versions omit truncated_key; if there's exactly one
+ key listed, fall back to it."""
+ payload = {
+ "security": {"rest_api_keys": [{"permissions": "read_write"}]} # no truncated_key
+ }
+ monkeypatch.setattr(wc_plugin.client, "get", AsyncMock(return_value=payload))
+
+ out = await wc_plugin.probe_credential_capabilities()
+ assert out["probe_available"] is True
+ assert "write_products" in out["granted"]
+
+ @pytest.mark.asyncio
+ async def test_network_failure_returns_probe_false(self, wc_plugin, monkeypatch):
+ async def _boom(*_a, **_kw):
+ raise RuntimeError("502 bad gateway")
+
+ monkeypatch.setattr(wc_plugin.client, "get", _boom)
+
+ out = await wc_plugin.probe_credential_capabilities()
+ assert out["probe_available"] is False
+ assert "system_status_unreachable" in out["reason"]
+
+ @pytest.mark.asyncio
+ async def test_non_dict_response_returns_probe_false(self, wc_plugin, monkeypatch):
+ monkeypatch.setattr(wc_plugin.client, "get", AsyncMock(return_value=["not a dict"]))
+
+ out = await wc_plugin.probe_credential_capabilities()
+ assert out["probe_available"] is False
+ assert out["reason"] == "non_dict_response"
+
+
+# ---------------------------------------------------------------------------
+# Gitea
+# ---------------------------------------------------------------------------
+
+
+@pytest.fixture
+def gitea_plugin():
+ from plugins.gitea.plugin import GiteaPlugin
+
+ return GiteaPlugin(
+ config={"url": "https://git.example.com", "token": "gta_test"},
+ project_id="gitea_probe_test",
+ )
+
+
+def _fake_aiohttp_get(status: int, scopes_header: str, text_body: str = ""):
+ """Build an aiohttp.ClientSession mock whose .get() returns a response
+ with ``status`` and ``X-OAuth-Scopes`` header."""
+ resp = AsyncMock()
+ resp.status = status
+ resp.headers = {"X-OAuth-Scopes": scopes_header}
+ resp.text = AsyncMock(return_value=text_body)
+
+ sess = MagicMock()
+ sess.get = MagicMock(
+ return_value=AsyncMock(
+ __aenter__=AsyncMock(return_value=resp),
+ __aexit__=AsyncMock(return_value=False),
+ )
+ )
+ sess.__aenter__ = AsyncMock(return_value=sess)
+ sess.__aexit__ = AsyncMock(return_value=False)
+ return sess
+
+
+class TestGiteaProbe:
+ @pytest.mark.asyncio
+ async def test_happy_path_returns_header_scopes(self, gitea_plugin):
+ sess = _fake_aiohttp_get(200, "read:repository, write:repository, admin:repo_hook")
+ with patch("plugins.gitea.plugin.aiohttp.ClientSession", return_value=sess):
+ out = await gitea_plugin.probe_credential_capabilities()
+ assert out["probe_available"] is True
+ assert out["source"] == "gitea_oauth_scopes"
+ assert out["granted"] == [
+ "admin:repo_hook",
+ "read:repository",
+ "write:repository",
+ ]
+
+ @pytest.mark.asyncio
+ async def test_empty_header_returns_probe_false(self, gitea_plugin):
+ sess = _fake_aiohttp_get(200, "")
+ with patch("plugins.gitea.plugin.aiohttp.ClientSession", return_value=sess):
+ out = await gitea_plugin.probe_credential_capabilities()
+ assert out["probe_available"] is False
+ assert out["reason"] == "scopes_header_absent_or_empty"
+
+ @pytest.mark.asyncio
+ async def test_http_error_returns_probe_false(self, gitea_plugin):
+ sess = _fake_aiohttp_get(401, "", text_body="bad token")
+ with patch("plugins.gitea.plugin.aiohttp.ClientSession", return_value=sess):
+ out = await gitea_plugin.probe_credential_capabilities()
+ assert out["probe_available"] is False
+ assert "user_endpoint_http_401" in out["reason"]
+ assert "bad token" in out["reason"]
+
+ @pytest.mark.asyncio
+ async def test_network_failure_returns_probe_false(self, gitea_plugin):
+ with patch(
+ "plugins.gitea.plugin.aiohttp.ClientSession",
+ side_effect=RuntimeError("dns failure"),
+ ):
+ out = await gitea_plugin.probe_credential_capabilities()
+ assert out["probe_available"] is False
+ assert "probe_failed" in out["reason"]
+
+ @pytest.mark.asyncio
+ async def test_single_scope_parsed(self, gitea_plugin):
+ sess = _fake_aiohttp_get(200, "read:user")
+ with patch("plugins.gitea.plugin.aiohttp.ClientSession", return_value=sess):
+ out = await gitea_plugin.probe_credential_capabilities()
+ assert out["granted"] == ["read:user"]
+
+ @pytest.mark.asyncio
+ async def test_whitespace_stripped_from_scopes(self, gitea_plugin):
+ sess = _fake_aiohttp_get(200, " read:user , write:repository ,")
+ with patch("plugins.gitea.plugin.aiohttp.ClientSession", return_value=sess):
+ out = await gitea_plugin.probe_credential_capabilities()
+ assert out["granted"] == ["read:user", "write:repository"]
diff --git a/tests/test_capability_probe_read_tier.py b/tests/test_capability_probe_read_tier.py
new file mode 100644
index 0000000..cc2fad7
--- /dev/null
+++ b/tests/test_capability_probe_read_tier.py
@@ -0,0 +1,123 @@
+"""F.X.fix #5 — tier-fit for the WP ``read`` tier recognises role names.
+
+Regression: every admin probe showed ``status=warning, missing=['read']``
+because the companion capability payload places WP role strings under
+``roles`` (e.g. ``administrator``) while the bare ``read`` capability
+is only implied by the role and never written out as a standalone cap.
+The ``_cap_matches`` alias resolver understood the mapping but was
+only fed ``granted`` — not ``granted ∪ roles``.
+"""
+
+from __future__ import annotations
+
+import pytest
+
+from core.capability_probe import evaluate_tier_fit
+
+# Realistic admin probe payload captured from Phase A of F.X.test on
+# blog.example.com. Only the fields that ``evaluate_tier_fit``
+# actually reads are kept.
+ADMIN_PROBE_PAYLOAD = {
+ "probe_available": True,
+ "granted": [
+ "delete_others_pages",
+ "delete_others_posts",
+ "delete_pages",
+ "delete_posts",
+ "delete_private_pages",
+ "delete_private_posts",
+ "delete_published_pages",
+ "delete_published_posts",
+ "edit_others_pages",
+ "edit_others_posts",
+ "edit_pages",
+ "edit_posts",
+ "manage_options",
+ "moderate_comments",
+ "upload_files",
+ ],
+ "roles": ["administrator"],
+ "plugin_version": "2.9.0",
+}
+
+
+@pytest.mark.unit
+class TestReadTierForAdminRoles:
+ def test_read_tier_ok_when_only_role_is_administrator(self):
+ # Admin user: ``read`` is implied by the role, not present in
+ # granted. Before the fix this returned status=warning.
+ fit = evaluate_tier_fit(
+ plugin_type="wordpress",
+ tier="read",
+ probe_payload=ADMIN_PROBE_PAYLOAD,
+ )
+ assert fit["status"] == "ok"
+ assert fit["missing"] == []
+
+ def test_read_tier_ok_for_subscriber_role_only(self):
+ # Subscriber: no caps in granted, but ``subscriber`` role
+ # implies ``read``. This was also broken before the fix.
+ payload = {
+ "probe_available": True,
+ "granted": [],
+ "roles": ["subscriber"],
+ }
+ fit = evaluate_tier_fit(
+ plugin_type="wordpress",
+ tier="read",
+ probe_payload=payload,
+ )
+ assert fit["status"] == "ok"
+
+ def test_write_tier_ok_when_editor_role_present(self):
+ # ``edit_posts`` aliases cover ``editor`` and ``administrator``
+ # roles — this was the alias resolver's job all along; with
+ # the fix it now also works when the cap only arrives via
+ # ``roles`` (edge case but possible for leaner probes).
+ payload = {
+ "probe_available": True,
+ "granted": ["upload_files"], # one required cap missing from granted
+ "roles": ["editor"],
+ }
+ fit = evaluate_tier_fit(
+ plugin_type="wordpress",
+ tier="write",
+ probe_payload=payload,
+ )
+ assert fit["status"] == "ok"
+ assert fit["missing"] == []
+
+
+@pytest.mark.unit
+class TestWarningStillFiresForUnderPrivileged:
+ def test_read_tier_warning_when_no_role_and_no_cap(self):
+ # Sanity: the union doesn't silence ALL missing-cap warnings,
+ # only the "role implies cap" bucket.
+ payload = {
+ "probe_available": True,
+ "granted": [],
+ "roles": [],
+ }
+ fit = evaluate_tier_fit(
+ plugin_type="wordpress",
+ tier="read",
+ probe_payload=payload,
+ )
+ assert fit["status"] == "warning"
+ assert fit["missing"] == ["read"]
+
+ def test_admin_tier_still_requires_manage_options(self):
+ # An editor cannot satisfy the admin tier — roles are in the
+ # union but manage_options still isn't granted.
+ payload = {
+ "probe_available": True,
+ "granted": ["edit_posts", "upload_files"],
+ "roles": ["editor"],
+ }
+ fit = evaluate_tier_fit(
+ plugin_type="wordpress",
+ tier="admin",
+ probe_payload=payload,
+ )
+ assert fit["status"] == "warning"
+ assert fit["missing"] == ["manage_options"]
diff --git a/tests/test_capability_tier_fit.py b/tests/test_capability_tier_fit.py
new file mode 100644
index 0000000..6cf0154
--- /dev/null
+++ b/tests/test_capability_tier_fit.py
@@ -0,0 +1,232 @@
+"""F.7e — ``evaluate_tier_fit`` + ``TIER_REQUIREMENTS`` coverage."""
+
+from __future__ import annotations
+
+import pytest
+
+from core.capability_probe import (
+ TIER_REQUIREMENTS,
+ _cap_matches,
+ evaluate_tier_fit,
+)
+
+# ---------------------------------------------------------------------------
+# TIER_REQUIREMENTS sanity
+# ---------------------------------------------------------------------------
+
+
+class TestTierRequirementsTable:
+ @pytest.mark.unit
+ def test_all_three_tiers_for_every_mapped_plugin(self):
+ for plugin_type, tiers in TIER_REQUIREMENTS.items():
+ assert {"read", "write", "admin"} <= set(tiers.keys()), f"{plugin_type} missing a tier"
+
+ @pytest.mark.unit
+ def test_requirements_are_non_empty_sets(self):
+ for plugin_type, tiers in TIER_REQUIREMENTS.items():
+ for tier, reqs in tiers.items():
+ assert isinstance(reqs, set), f"{plugin_type}/{tier} is not a set"
+ assert reqs, f"{plugin_type}/{tier} has no required caps"
+
+
+# ---------------------------------------------------------------------------
+# _cap_matches: canonical + alias resolution
+# ---------------------------------------------------------------------------
+
+
+class TestCapMatches:
+ @pytest.mark.unit
+ def test_exact_match(self):
+ assert _cap_matches("read", {"read"})
+ assert _cap_matches("manage_options", {"manage_options"})
+
+ @pytest.mark.unit
+ def test_role_satisfies_capability_alias(self):
+ # WP role -> implied capability
+ assert _cap_matches("manage_options", {"administrator"})
+ assert _cap_matches("edit_posts", {"editor"})
+ assert _cap_matches("read", {"subscriber"})
+
+ @pytest.mark.unit
+ def test_read_write_satisfies_read_and_write(self):
+ # WC "read_write" permission satisfies both read_* and write_*.
+ assert _cap_matches("read_products", {"read_write"})
+ assert _cap_matches("write_products", {"read_write"})
+
+ @pytest.mark.unit
+ def test_missing_capability(self):
+ assert not _cap_matches("manage_options", {"edit_posts"})
+ assert not _cap_matches("write_products", {"read"})
+
+
+# ---------------------------------------------------------------------------
+# evaluate_tier_fit: probe-unavailable path
+# ---------------------------------------------------------------------------
+
+
+class TestEvaluateTierFit:
+ @pytest.mark.unit
+ def test_probe_unavailable_short_circuits(self):
+ fit = evaluate_tier_fit(
+ "wordpress",
+ "admin",
+ {"probe_available": False, "reason": "companion_not_installed"},
+ )
+ assert fit["status"] == "probe_unavailable"
+ assert fit["reason"] == "companion_not_installed"
+ assert fit["missing"] == []
+
+ @pytest.mark.unit
+ def test_custom_tier_is_always_ok(self):
+ # "custom" means the caller cherry-picked tools — no tier-level
+ # contract to validate.
+ fit = evaluate_tier_fit(
+ "wordpress",
+ "custom",
+ {"probe_available": True, "granted": ["read"]},
+ )
+ assert fit["status"] == "ok"
+ assert fit["required"] == []
+ assert fit["missing"] == []
+
+ @pytest.mark.unit
+ def test_none_tier_is_always_ok(self):
+ fit = evaluate_tier_fit(
+ "wordpress",
+ None,
+ {"probe_available": True, "granted": ["read"]},
+ )
+ assert fit["status"] == "ok"
+
+ @pytest.mark.unit
+ def test_unknown_tier_for_plugin_returns_unknown(self):
+ fit = evaluate_tier_fit(
+ "wordpress",
+ "deploy", # not in WP's table
+ {"probe_available": True, "granted": ["read"]},
+ )
+ assert fit["status"] == "unknown_tier"
+
+ @pytest.mark.unit
+ def test_unknown_plugin_returns_unknown(self):
+ fit = evaluate_tier_fit(
+ "n8n", # no tier table
+ "read",
+ {"probe_available": True, "granted": ["read"]},
+ )
+ assert fit["status"] == "unknown_tier"
+
+
+class TestWordPressFit:
+ @pytest.mark.unit
+ def test_admin_tier_with_administrator_role_is_ok(self):
+ fit = evaluate_tier_fit(
+ "wordpress",
+ "admin",
+ {
+ "probe_available": True,
+ "granted": ["administrator", "manage_options", "upload_files"],
+ },
+ )
+ assert fit["status"] == "ok"
+ assert fit["missing"] == []
+
+ @pytest.mark.unit
+ def test_admin_tier_with_editor_role_warns_missing_manage_options(self):
+ fit = evaluate_tier_fit(
+ "wordpress",
+ "admin",
+ {"probe_available": True, "granted": ["editor", "edit_posts", "upload_files"]},
+ )
+ assert fit["status"] == "warning"
+ assert "manage_options" in fit["missing"]
+
+ @pytest.mark.unit
+ def test_write_tier_with_editor_role_is_ok(self):
+ fit = evaluate_tier_fit(
+ "wordpress",
+ "write",
+ {"probe_available": True, "granted": ["editor"]},
+ )
+ # editor alias covers edit_posts + upload_files.
+ assert fit["status"] == "ok"
+
+ @pytest.mark.unit
+ def test_read_tier_with_subscriber_is_ok(self):
+ fit = evaluate_tier_fit(
+ "wordpress",
+ "read",
+ {"probe_available": True, "granted": ["subscriber"]},
+ )
+ assert fit["status"] == "ok"
+
+
+class TestWooCommerceFit:
+ @pytest.mark.unit
+ def test_read_key_satisfies_read_tier(self):
+ fit = evaluate_tier_fit(
+ "woocommerce",
+ "read",
+ {"probe_available": True, "granted": ["read_products", "read_orders"]},
+ )
+ assert fit["status"] == "ok"
+
+ @pytest.mark.unit
+ def test_read_key_fails_write_tier(self):
+ fit = evaluate_tier_fit(
+ "woocommerce",
+ "write",
+ {"probe_available": True, "granted": ["read_products", "read_orders"]},
+ )
+ assert fit["status"] == "warning"
+ assert "write_products" in fit["missing"]
+
+ @pytest.mark.unit
+ def test_read_write_key_satisfies_both(self):
+ # Using the raw permission alias "read_write" directly.
+ fit_read = evaluate_tier_fit(
+ "woocommerce",
+ "read",
+ {"probe_available": True, "granted": ["read_write"]},
+ )
+ fit_write = evaluate_tier_fit(
+ "woocommerce",
+ "write",
+ {"probe_available": True, "granted": ["read_write"]},
+ )
+ assert fit_read["status"] == "ok"
+ assert fit_write["status"] == "ok"
+
+
+class TestGiteaFit:
+ @pytest.mark.unit
+ def test_admin_tier_needs_admin_repo_hook(self):
+ fit = evaluate_tier_fit(
+ "gitea",
+ "admin",
+ {
+ "probe_available": True,
+ "granted": ["read:repository", "write:repository"],
+ },
+ )
+ assert fit["status"] == "warning"
+ assert "admin:repo_hook" in fit["missing"]
+
+ @pytest.mark.unit
+ def test_write_tier_with_write_scope_ok(self):
+ fit = evaluate_tier_fit(
+ "gitea",
+ "write",
+ {"probe_available": True, "granted": ["write:repository"]},
+ )
+ assert fit["status"] == "ok"
+
+ @pytest.mark.unit
+ def test_read_tier_with_only_user_scope_warns(self):
+ fit = evaluate_tier_fit(
+ "gitea",
+ "read",
+ {"probe_available": True, "granted": ["read:user"]},
+ )
+ assert fit["status"] == "warning"
+ assert "read:repository" in fit["missing"]
diff --git a/tests/test_companion_audit.py b/tests/test_companion_audit.py
new file mode 100644
index 0000000..c4df8a5
--- /dev/null
+++ b/tests/test_companion_audit.py
@@ -0,0 +1,404 @@
+"""F.18.7 — Tests for the companion-audit receiver + secret store."""
+
+from __future__ import annotations
+
+import hashlib
+import hmac
+import json
+import time
+from datetime import UTC, datetime
+from pathlib import Path
+from unittest.mock import MagicMock, patch
+
+import pytest
+from starlette.requests import Request
+
+from core.companion_audit import (
+ CompanionAuditSecretStore,
+ _parse_timestamp,
+ handle_companion_audit_request,
+ verify_companion_signature,
+)
+
+SITE = "https://wp.example.com"
+SECRET = "a" * 32
+
+
+def _now_iso() -> str:
+ """Return an ISO 8601 UTC timestamp matching the companion plugin's format."""
+ return datetime.now(UTC).strftime("%Y-%m-%dT%H:%M:%SZ")
+
+
+def _build_request(body_bytes: bytes, headers: dict[str, str]) -> Request:
+ """Construct a minimal Starlette Request from a body + headers."""
+ scope = {
+ "type": "http",
+ "method": "POST",
+ "path": "/api/companion-audit",
+ "headers": [(k.lower().encode(), v.encode()) for k, v in headers.items()],
+ "query_string": b"",
+ }
+
+ async def receive():
+ return {"type": "http.request", "body": body_bytes, "more_body": False}
+
+ return Request(scope, receive)
+
+
+# ---------------------------------------------------------------------------
+# Signature verification.
+# ---------------------------------------------------------------------------
+
+
+def test_verify_signature_accepts_sha256_prefix():
+ body = b'{"hi":1}'
+ sig = hmac.new(SECRET.encode(), body, hashlib.sha256).hexdigest()
+ assert verify_companion_signature(body, f"sha256={sig}", SECRET) is True
+
+
+def test_verify_signature_accepts_bare_hex():
+ body = b'{"hi":1}'
+ sig = hmac.new(SECRET.encode(), body, hashlib.sha256).hexdigest()
+ assert verify_companion_signature(body, sig, SECRET) is True
+
+
+def test_verify_signature_rejects_wrong_secret():
+ body = b'{"hi":1}'
+ sig = hmac.new(b"other_secret_1234", body, hashlib.sha256).hexdigest()
+ assert verify_companion_signature(body, f"sha256={sig}", SECRET) is False
+
+
+def test_verify_signature_rejects_missing_header():
+ assert verify_companion_signature(b"x", None, SECRET) is False
+ assert verify_companion_signature(b"x", "", SECRET) is False
+
+
+def test_verify_signature_rejects_non_hex():
+ assert verify_companion_signature(b"x", "sha256=NOTHEX!", SECRET) is False
+
+
+# ---------------------------------------------------------------------------
+# Secret store.
+# ---------------------------------------------------------------------------
+
+
+def test_store_set_and_get(tmp_path: Path):
+ store = CompanionAuditSecretStore(tmp_path / "secrets.json")
+ store.set(SITE, SECRET)
+ assert store.get(SITE) == SECRET
+ # Normalisation: trailing slash + case should not matter.
+ assert store.get(SITE + "/") == SECRET
+ assert store.get(SITE.upper()) == SECRET
+
+
+def test_store_rejects_short_secret(tmp_path: Path):
+ store = CompanionAuditSecretStore(tmp_path / "secrets.json")
+ with pytest.raises(ValueError):
+ store.set(SITE, "short")
+
+
+def test_store_delete(tmp_path: Path):
+ store = CompanionAuditSecretStore(tmp_path / "secrets.json")
+ store.set(SITE, SECRET)
+ assert store.delete(SITE) is True
+ assert store.get(SITE) is None
+ assert store.delete(SITE) is False
+
+
+def test_store_list_sites_masks_secret(tmp_path: Path):
+ store = CompanionAuditSecretStore(tmp_path / "secrets.json")
+ store.set(SITE, SECRET)
+ sites = store.list_sites()
+ assert len(sites) == 1
+ assert sites[0]["site_url"].lower().startswith("https://wp.example.com")
+ assert sites[0]["secret_set"] is True
+ assert sites[0]["secret_last4"] == SECRET[-4:]
+ assert "secret" not in sites[0] # never leaks plaintext
+
+
+def test_store_survives_restart(tmp_path: Path):
+ path = tmp_path / "secrets.json"
+ store1 = CompanionAuditSecretStore(path)
+ store1.set(SITE, SECRET)
+
+ store2 = CompanionAuditSecretStore(path)
+ assert store2.get(SITE) == SECRET
+
+
+def test_store_corrupt_file_treated_as_empty(tmp_path: Path):
+ path = tmp_path / "secrets.json"
+ path.write_text("not json")
+ store = CompanionAuditSecretStore(path)
+ assert store.get(SITE) is None
+
+
+# ---------------------------------------------------------------------------
+# Receiver Starlette handler.
+# ---------------------------------------------------------------------------
+
+
+@pytest.mark.asyncio
+async def test_receiver_rejects_empty_body():
+ req = _build_request(b"", {})
+ resp = await handle_companion_audit_request(req)
+ assert resp.status_code == 400
+
+
+@pytest.mark.asyncio
+async def test_receiver_rejects_invalid_json():
+ req = _build_request(b"not json", {"X-Airano-MCP-Site": SITE})
+ resp = await handle_companion_audit_request(req)
+ assert resp.status_code == 400
+
+
+@pytest.mark.asyncio
+async def test_receiver_rejects_missing_site(tmp_path: Path):
+ body = json.dumps({"event": "x"}).encode()
+ req = _build_request(body, {})
+ resp = await handle_companion_audit_request(req)
+ assert resp.status_code == 400
+
+
+@pytest.mark.asyncio
+async def test_receiver_unauthorized_when_unknown_site(tmp_path: Path, monkeypatch):
+ store = CompanionAuditSecretStore(tmp_path / "secrets.json")
+ monkeypatch.setattr("core.companion_audit.get_companion_audit_store", lambda: store)
+ body = json.dumps({"event": "x", "site_url": "https://unknown.example"}).encode()
+ req = _build_request(body, {"X-Airano-MCP-Signature": "sha256=deadbeef"})
+ resp = await handle_companion_audit_request(req)
+ assert resp.status_code == 401
+
+
+@pytest.mark.asyncio
+async def test_receiver_unauthorized_on_bad_signature(tmp_path: Path, monkeypatch):
+ store = CompanionAuditSecretStore(tmp_path / "secrets.json")
+ store.set(SITE, SECRET)
+ monkeypatch.setattr("core.companion_audit.get_companion_audit_store", lambda: store)
+ body = json.dumps({"event": "x", "site_url": SITE}).encode()
+ req = _build_request(
+ body,
+ {
+ "X-Airano-MCP-Site": SITE,
+ "X-Airano-MCP-Signature": "sha256=" + "0" * 64,
+ },
+ )
+ resp = await handle_companion_audit_request(req)
+ assert resp.status_code == 401
+
+
+@pytest.mark.asyncio
+async def test_receiver_happy_path(tmp_path: Path, monkeypatch):
+ store = CompanionAuditSecretStore(tmp_path / "secrets.json")
+ store.set(SITE, SECRET)
+ monkeypatch.setattr("core.companion_audit.get_companion_audit_store", lambda: store)
+
+ envelope = {
+ "event": "transition_post_status",
+ "site_url": SITE,
+ "timestamp": _now_iso(),
+ "user_id": 1,
+ "data": {"post_id": 42, "new_status": "publish", "old_status": "draft"},
+ "plugin_version": "2.7.0",
+ }
+ body = json.dumps(envelope).encode()
+ sig = hmac.new(SECRET.encode(), body, hashlib.sha256).hexdigest()
+
+ fake_logger = MagicMock()
+ with patch("core.audit_log.get_audit_logger", return_value=fake_logger):
+ req = _build_request(
+ body,
+ {
+ "X-Airano-MCP-Site": SITE,
+ "X-Airano-MCP-Signature": f"sha256={sig}",
+ "Content-Type": "application/json",
+ },
+ )
+ resp = await handle_companion_audit_request(req)
+
+ assert resp.status_code == 200
+ fake_logger.log_system_event.assert_called_once()
+ call_args = fake_logger.log_system_event.call_args
+ assert call_args.kwargs["event"] == "companion_audit:transition_post_status"
+ details = call_args.kwargs["details"]
+ assert details["site_url"] == SITE
+ assert details["known_event"] is True
+ assert details["data"]["post_id"] == 42
+
+
+@pytest.mark.asyncio
+async def test_receiver_tags_unknown_events(tmp_path: Path, monkeypatch):
+ store = CompanionAuditSecretStore(tmp_path / "secrets.json")
+ store.set(SITE, SECRET)
+ monkeypatch.setattr("core.companion_audit.get_companion_audit_store", lambda: store)
+
+ envelope = {"event": "made_up_event", "site_url": SITE, "timestamp": _now_iso()}
+ body = json.dumps(envelope).encode()
+ sig = hmac.new(SECRET.encode(), body, hashlib.sha256).hexdigest()
+
+ fake_logger = MagicMock()
+ with patch("core.audit_log.get_audit_logger", return_value=fake_logger):
+ req = _build_request(
+ body,
+ {
+ "X-Airano-MCP-Site": SITE,
+ "X-Airano-MCP-Signature": f"sha256={sig}",
+ },
+ )
+ resp = await handle_companion_audit_request(req)
+
+ assert resp.status_code == 200
+ details = fake_logger.log_system_event.call_args.kwargs["details"]
+ assert details["known_event"] is False
+
+
+# ---------------------------------------------------------------------------
+# Pre-F.20 security sweep: body-size cap + replay window + timestamp parsing.
+# ---------------------------------------------------------------------------
+
+
+def test_parse_timestamp_iso_8601_z_suffix():
+ iso = "2026-04-15T09:00:00Z"
+ ts = _parse_timestamp(iso)
+ assert ts is not None
+ # Round-trip: same moment in epoch seconds.
+ assert abs(ts - datetime(2026, 4, 15, 9, 0, 0, tzinfo=UTC).timestamp()) < 1
+
+
+def test_parse_timestamp_numeric_epoch():
+ assert _parse_timestamp(1712345678) == 1712345678.0
+ assert _parse_timestamp(1712345678.5) == 1712345678.5
+
+
+def test_parse_timestamp_numeric_string():
+ assert _parse_timestamp("1712345678") == 1712345678.0
+
+
+def test_parse_timestamp_rejects_garbage():
+ assert _parse_timestamp("not a date") is None
+ assert _parse_timestamp(None) is None
+ assert _parse_timestamp("") is None
+ assert _parse_timestamp({"not": "a timestamp"}) is None
+
+
+@pytest.mark.asyncio
+async def test_receiver_rejects_oversized_body_via_content_length():
+ # Content-Length > cap → 413 before we even read the body.
+ req = _build_request(
+ b'{"event":"x"}',
+ {
+ "X-Airano-MCP-Site": SITE,
+ "Content-Length": str(1_000_000),
+ },
+ )
+ resp = await handle_companion_audit_request(req)
+ assert resp.status_code == 413
+ body = json.loads(bytes(resp.body))
+ assert body["error"] == "body_too_large"
+
+
+@pytest.mark.asyncio
+async def test_receiver_rejects_oversized_body_via_actual_length(monkeypatch):
+ # If Content-Length is absent but the body itself exceeds the cap,
+ # we still return 413 after the read.
+ monkeypatch.setattr("core.companion_audit._MAX_BODY_BYTES", 32)
+ big = b"x" * 64
+ req = _build_request(big, {"X-Airano-MCP-Site": SITE})
+ resp = await handle_companion_audit_request(req)
+ assert resp.status_code == 413
+
+
+@pytest.mark.asyncio
+async def test_receiver_rejects_missing_timestamp(tmp_path: Path, monkeypatch):
+ """Signed but timestamp-less envelope: rejected as if sig were bad."""
+ store = CompanionAuditSecretStore(tmp_path / "secrets.json")
+ store.set(SITE, SECRET)
+ monkeypatch.setattr("core.companion_audit.get_companion_audit_store", lambda: store)
+
+ envelope = {"event": "transition_post_status", "site_url": SITE}
+ body = json.dumps(envelope).encode()
+ sig = hmac.new(SECRET.encode(), body, hashlib.sha256).hexdigest()
+
+ req = _build_request(
+ body,
+ {
+ "X-Airano-MCP-Site": SITE,
+ "X-Airano-MCP-Signature": f"sha256={sig}",
+ },
+ )
+ resp = await handle_companion_audit_request(req)
+ assert resp.status_code == 401
+
+
+@pytest.mark.asyncio
+async def test_receiver_rejects_stale_timestamp(tmp_path: Path, monkeypatch):
+ """Valid sig but timestamp outside the replay window → 401."""
+ store = CompanionAuditSecretStore(tmp_path / "secrets.json")
+ store.set(SITE, SECRET)
+ monkeypatch.setattr("core.companion_audit.get_companion_audit_store", lambda: store)
+
+ stale = datetime.fromtimestamp(time.time() - 3600, tz=UTC).strftime("%Y-%m-%dT%H:%M:%SZ")
+ envelope = {"event": "transition_post_status", "site_url": SITE, "timestamp": stale}
+ body = json.dumps(envelope).encode()
+ sig = hmac.new(SECRET.encode(), body, hashlib.sha256).hexdigest()
+
+ req = _build_request(
+ body,
+ {
+ "X-Airano-MCP-Site": SITE,
+ "X-Airano-MCP-Signature": f"sha256={sig}",
+ },
+ )
+ resp = await handle_companion_audit_request(req)
+ assert resp.status_code == 401
+
+
+@pytest.mark.asyncio
+async def test_receiver_accepts_future_timestamp_within_skew(tmp_path: Path, monkeypatch):
+ """Small positive clock skew (server drifted ahead of WP) is tolerated."""
+ store = CompanionAuditSecretStore(tmp_path / "secrets.json")
+ store.set(SITE, SECRET)
+ monkeypatch.setattr("core.companion_audit.get_companion_audit_store", lambda: store)
+
+ # +30 s is well inside the default 300 s window.
+ skewed = datetime.fromtimestamp(time.time() + 30, tz=UTC).strftime("%Y-%m-%dT%H:%M:%SZ")
+ envelope = {"event": "transition_post_status", "site_url": SITE, "timestamp": skewed}
+ body = json.dumps(envelope).encode()
+ sig = hmac.new(SECRET.encode(), body, hashlib.sha256).hexdigest()
+
+ fake_logger = MagicMock()
+ with patch("core.audit_log.get_audit_logger", return_value=fake_logger):
+ req = _build_request(
+ body,
+ {
+ "X-Airano-MCP-Site": SITE,
+ "X-Airano-MCP-Signature": f"sha256={sig}",
+ },
+ )
+ resp = await handle_companion_audit_request(req)
+ assert resp.status_code == 200
+
+
+@pytest.mark.asyncio
+async def test_receiver_replay_window_disabled(tmp_path: Path, monkeypatch):
+ """Setting the window to 0 turns replay protection off."""
+ store = CompanionAuditSecretStore(tmp_path / "secrets.json")
+ store.set(SITE, SECRET)
+ monkeypatch.setattr("core.companion_audit.get_companion_audit_store", lambda: store)
+ monkeypatch.setattr("core.companion_audit._REPLAY_WINDOW_SECONDS", 0)
+
+ ancient = "2020-01-01T00:00:00Z"
+ envelope = {"event": "transition_post_status", "site_url": SITE, "timestamp": ancient}
+ body = json.dumps(envelope).encode()
+ sig = hmac.new(SECRET.encode(), body, hashlib.sha256).hexdigest()
+
+ fake_logger = MagicMock()
+ with patch("core.audit_log.get_audit_logger", return_value=fake_logger):
+ req = _build_request(
+ body,
+ {
+ "X-Airano-MCP-Site": SITE,
+ "X-Airano-MCP-Signature": f"sha256={sig}",
+ },
+ )
+ resp = await handle_companion_audit_request(req)
+ assert resp.status_code == 200
diff --git a/tests/test_dashboard_ux_hints.py b/tests/test_dashboard_ux_hints.py
new file mode 100644
index 0000000..4b51904
--- /dev/null
+++ b/tests/test_dashboard_ux_hints.py
@@ -0,0 +1,104 @@
+"""Dashboard UX hints (F.20 prep): companion-plugin download + credential hints.
+
+Verifies:
+
+* WP / WC service pages and site-manage page surface the companion
+ download URL.
+* Other plugin types (Gitea, n8n, Supabase, OpenPanel) do NOT get the
+ URL, so the banner / hint is hidden.
+* Credential hints for WP ``app_password`` and WC
+ ``consumer_key``/``consumer_secret`` explicitly tell the user the
+ field IS the API auth (no separate API key needed) — the user-
+ feedback item the phase was added for.
+"""
+
+from __future__ import annotations
+
+import pytest
+
+from core.site_api import PLUGIN_CREDENTIAL_FIELDS, get_credential_fields
+
+# ---------------------------------------------------------------------------
+# Credential hint copy (static, no DB).
+# ---------------------------------------------------------------------------
+
+
+class TestCredentialHintCopy:
+ @pytest.mark.unit
+ def test_wp_app_password_hint_flags_it_as_the_api_credential(self):
+ fields = PLUGIN_CREDENTIAL_FIELDS["wordpress"]
+ app_pw = next(f for f in fields if f["name"] == "app_password")
+ assert "API credential" in app_pw["hint"] or "api credential" in app_pw["hint"].lower()
+ # No separate API key is needed.
+ assert "no separate" in app_pw["hint"].lower() or "no extra" in app_pw["hint"].lower()
+
+ @pytest.mark.unit
+ def test_wc_consumer_key_hint_flags_pair_as_api_auth(self):
+ fields = PLUGIN_CREDENTIAL_FIELDS["woocommerce"]
+ ck = next(f for f in fields if f["name"] == "consumer_key")
+ assert "API" in ck["hint"]
+ assert "no extra" in ck["hint"].lower() or "no separate" in ck["hint"].lower()
+
+ @pytest.mark.unit
+ def test_wc_consumer_secret_hint_mentions_shown_once(self):
+ fields = PLUGIN_CREDENTIAL_FIELDS["woocommerce"]
+ cs = next(f for f in fields if f["name"] == "consumer_secret")
+ assert "Shown once" in cs["hint"] or "shown once" in cs["hint"].lower()
+ # Starts-with-cs_ tip helps users confirm they grabbed the right one.
+ assert "cs_" in cs["hint"]
+
+ @pytest.mark.unit
+ def test_get_credential_fields_wraps_the_dict(self):
+ # Sanity: the public getter returns the same structure.
+ assert get_credential_fields("wordpress") == PLUGIN_CREDENTIAL_FIELDS["wordpress"]
+ assert get_credential_fields("woocommerce") == PLUGIN_CREDENTIAL_FIELDS["woocommerce"]
+
+ @pytest.mark.unit
+ def test_get_credential_fields_unknown_raises(self):
+ with pytest.raises(ValueError, match="Unknown plugin type"):
+ get_credential_fields("does_not_exist")
+
+
+# ---------------------------------------------------------------------------
+# Companion download URL gating on service / sites views.
+# ---------------------------------------------------------------------------
+
+
+# The exact URL we advertise until F.20 swaps it to wp.org.
+EXPECTED_COMPANION_URL = (
+ "https://github.com/airano-ir/mcphub/raw/main/" "wordpress-plugin/airano-mcp-bridge.zip"
+)
+
+
+@pytest.mark.parametrize("plugin_type", ["wordpress", "woocommerce"])
+def test_companion_url_is_set_for_wp_and_wc(plugin_type):
+ """Exercising the exact branch the two views take inline."""
+ companion_download_url = None
+ if plugin_type in {"wordpress", "woocommerce"}:
+ companion_download_url = EXPECTED_COMPANION_URL
+
+ assert companion_download_url == EXPECTED_COMPANION_URL
+
+
+@pytest.mark.parametrize(
+ "plugin_type",
+ ["gitea", "n8n", "supabase", "openpanel", "appwrite", "directus", "coolify"],
+)
+def test_companion_url_is_none_for_other_plugins(plugin_type):
+ companion_download_url = None
+ if plugin_type in {"wordpress", "woocommerce"}:
+ companion_download_url = EXPECTED_COMPANION_URL
+
+ assert companion_download_url is None
+
+
+@pytest.mark.unit
+def test_companion_url_is_the_github_raw_path():
+ """Guard against accidentally pointing at a 404 or a 3rd-party host.
+
+ F.20 will swap this to ``wordpress.org/plugins/airano-mcp-bridge/``
+ once the wp.org listing goes live. Until then, the GitHub raw path
+ in the main branch is the canonical distribution channel.
+ """
+ assert EXPECTED_COMPANION_URL.startswith("https://github.com/airano-ir/mcphub/raw/main/")
+ assert EXPECTED_COMPANION_URL.endswith("airano-mcp-bridge.zip")
diff --git a/tests/test_database.py b/tests/test_database.py
index d3cfe8f..f7c139f 100644
--- a/tests/test_database.py
+++ b/tests/test_database.py
@@ -604,6 +604,103 @@ class TestSiteToolToggles:
assert await db.get_site_tool_scope("does-not-exist") == "admin"
+class TestSiteProviderKeys:
+ """F.5a.9.x: per-site AI provider key CRUD + cascade on site delete."""
+
+ @pytest.mark.unit
+ async def test_empty_list_by_default(self, db, site_row):
+ assert await db.list_site_provider_keys(site_row["id"]) == []
+
+ @pytest.mark.unit
+ async def test_upsert_and_get(self, db, site_row):
+ row = await db.upsert_site_provider_key(site_row["id"], "openai", b"ciphertext-bytes")
+ assert row["provider"] == "openai"
+ assert row["site_id"] == site_row["id"]
+
+ fetched = await db.get_site_provider_key(site_row["id"], "openai")
+ assert fetched is not None
+ assert fetched["key_ciphertext"] == b"ciphertext-bytes"
+
+ @pytest.mark.unit
+ async def test_upsert_replaces_existing(self, db, site_row):
+ await db.upsert_site_provider_key(site_row["id"], "openai", b"first")
+ await db.upsert_site_provider_key(site_row["id"], "openai", b"second")
+
+ fetched = await db.get_site_provider_key(site_row["id"], "openai")
+ assert fetched is not None
+ assert fetched["key_ciphertext"] == b"second"
+
+ @pytest.mark.unit
+ async def test_list_orders_by_provider(self, db, site_row):
+ await db.upsert_site_provider_key(site_row["id"], "stability", b"s")
+ await db.upsert_site_provider_key(site_row["id"], "openai", b"o")
+ await db.upsert_site_provider_key(site_row["id"], "replicate", b"r")
+
+ rows = await db.list_site_provider_keys(site_row["id"])
+ providers = [r["provider"] for r in rows]
+ assert providers == ["openai", "replicate", "stability"]
+ # list_* excludes ciphertext
+ assert all("key_ciphertext" not in r for r in rows)
+
+ @pytest.mark.unit
+ async def test_delete(self, db, site_row):
+ await db.upsert_site_provider_key(site_row["id"], "openai", b"x")
+ deleted = await db.delete_site_provider_key(site_row["id"], "openai")
+ assert deleted is True
+ assert await db.get_site_provider_key(site_row["id"], "openai") is None
+
+ @pytest.mark.unit
+ async def test_delete_missing_returns_false(self, db, site_row):
+ assert await db.delete_site_provider_key(site_row["id"], "openai") is False
+
+ @pytest.mark.unit
+ async def test_touch_updates_last_used(self, db, site_row):
+ await db.upsert_site_provider_key(site_row["id"], "openai", b"x")
+ fetched_before = await db.get_site_provider_key(site_row["id"], "openai")
+ assert fetched_before is not None
+ assert fetched_before["last_used"] is None
+
+ await db.touch_site_provider_key(site_row["id"], "openai")
+ fetched_after = await db.get_site_provider_key(site_row["id"], "openai")
+ assert fetched_after is not None
+ assert fetched_after["last_used"] is not None
+
+ @pytest.mark.unit
+ async def test_cascade_delete_on_site(self, db, user_row, site_row):
+ await db.upsert_site_provider_key(site_row["id"], "openai", b"x")
+ await db.delete_site(site_row["id"], user_row["id"])
+
+ rows = await db.fetchall(
+ "SELECT * FROM site_provider_keys WHERE site_id = ?",
+ (site_row["id"],),
+ )
+ assert rows == []
+
+ @pytest.mark.unit
+ async def test_two_sites_keys_are_isolated(self, db, user_row):
+ s1 = await db.create_site(
+ user_id=user_row["id"],
+ plugin_type="wordpress",
+ alias="a",
+ url="https://a.example.com",
+ credentials=b"c1",
+ )
+ s2 = await db.create_site(
+ user_id=user_row["id"],
+ plugin_type="woocommerce",
+ alias="b",
+ url="https://b.example.com",
+ credentials=b"c2",
+ )
+ await db.upsert_site_provider_key(s1["id"], "openai", b"A")
+ await db.upsert_site_provider_key(s2["id"], "openai", b"B")
+
+ got_a = await db.get_site_provider_key(s1["id"], "openai")
+ got_b = await db.get_site_provider_key(s2["id"], "openai")
+ assert got_a is not None and got_a["key_ciphertext"] == b"A"
+ assert got_b is not None and got_b["key_ciphertext"] == b"B"
+
+
class TestModuleHelpers:
"""Test get_database() and initialize_database() helpers."""
diff --git a/tests/test_gitea_f17_ergonomics.py b/tests/test_gitea_f17_ergonomics.py
new file mode 100644
index 0000000..6b5887b
--- /dev/null
+++ b/tests/test_gitea_f17_ergonomics.py
@@ -0,0 +1,307 @@
+"""F.17 — Gitea ergonomics: batch files, tree, search, compare, releases, fork.
+
+Tests cover both the client-level additions and the handler wrappers.
+All network calls are mocked through ``client.request`` so the tests
+exercise validation, error shaping, and payload construction without
+hitting a real Gitea instance.
+"""
+
+from __future__ import annotations
+
+import base64
+import json
+from unittest.mock import AsyncMock
+
+import pytest
+
+from plugins.gitea.client import GiteaClient
+from plugins.gitea.handlers import repositories as repo_handlers
+
+
+@pytest.fixture
+def client():
+ """A GiteaClient with its ``request`` method replaced by an AsyncMock."""
+ c = GiteaClient(site_url="https://git.example.com", token="ghs_test")
+ c.request = AsyncMock() # type: ignore[assignment]
+ return c
+
+
+# ---------------------------------------------------------------------------
+# _normalise_file_content: error messages + round-trip
+# ---------------------------------------------------------------------------
+
+
+class TestNormaliseFileContent:
+ @pytest.mark.unit
+ def test_plaintext_roundtrips(self):
+ out = GiteaClient._normalise_file_content("hello world", False)
+ assert base64.b64decode(out) == b"hello world"
+
+ @pytest.mark.unit
+ def test_bytes_input_accepted(self):
+ out = GiteaClient._normalise_file_content(b"\x00\x01\x02", False)
+ assert base64.b64decode(out) == b"\x00\x01\x02"
+
+ @pytest.mark.unit
+ def test_base64_input_validates_roundtrip(self):
+ b64 = base64.b64encode(b"precise").decode()
+ out = GiteaClient._normalise_file_content(b64, True)
+ assert base64.b64decode(out) == b"precise"
+
+ @pytest.mark.unit
+ def test_invalid_base64_raises_actionable_error(self):
+ # F.17 feedback #10: make the error message tell the user how to
+ # recover rather than just echoing the decoder's byte offset.
+ with pytest.raises(ValueError) as exc:
+ GiteaClient._normalise_file_content("###not base64###", True)
+ msg = str(exc.value)
+ assert "not valid base64" in msg
+ assert "content_is_base64=False" in msg
+
+ @pytest.mark.unit
+ def test_data_url_prefix_rejected_with_hint(self):
+ with pytest.raises(ValueError) as exc:
+ GiteaClient._normalise_file_content("data:text/plain;base64,aGVsbG8=", True)
+ assert "data:" in str(exc.value)
+ assert "Strip" in str(exc.value)
+
+ @pytest.mark.unit
+ def test_non_string_non_bytes_rejected(self):
+ with pytest.raises(ValueError):
+ GiteaClient._normalise_file_content(12345, False) # type: ignore[arg-type]
+
+
+# ---------------------------------------------------------------------------
+# Client-level endpoints
+# ---------------------------------------------------------------------------
+
+
+class TestClientEndpoints:
+ @pytest.mark.asyncio
+ async def test_change_files_forwards_to_batch_endpoint(self, client):
+ client.request.return_value = {"commit": {"sha": "abc"}}
+ payload = {"message": "m", "files": [{"operation": "create", "path": "x"}]}
+ await client.change_files("o", "r", payload)
+ client.request.assert_called_once_with("POST", "repos/o/r/contents", json_data=payload)
+
+ @pytest.mark.asyncio
+ async def test_get_tree_passes_recursive_flag(self, client):
+ client.request.return_value = {"tree": []}
+ await client.get_tree("o", "r", "main", recursive=True, page=2, per_page=50)
+ call = client.request.call_args
+ assert call.args == ("GET", "repos/o/r/git/trees/main")
+ assert call.kwargs["params"] == {"page": 2, "per_page": 50, "recursive": "true"}
+
+ @pytest.mark.asyncio
+ async def test_get_tree_omits_recursive_when_false(self, client):
+ client.request.return_value = {"tree": []}
+ await client.get_tree("o", "r")
+ params = client.request.call_args.kwargs["params"]
+ assert "recursive" not in params
+
+ @pytest.mark.asyncio
+ async def test_search_code_scoped_to_repo(self, client):
+ client.request.return_value = {"ok": True, "data": []}
+ await client.search_code(keyword="func", owner="o", repo="r")
+ assert client.request.call_args.args == ("GET", "repos/o/r/search/code")
+
+ @pytest.mark.asyncio
+ async def test_search_code_instance_wide(self, client):
+ client.request.return_value = {"ok": True, "data": []}
+ await client.search_code(keyword="func")
+ assert client.request.call_args.args == ("GET", "repos/search/code")
+
+ @pytest.mark.asyncio
+ async def test_compare_uses_triple_dot_separator(self, client):
+ client.request.return_value = {"commits": []}
+ await client.compare("o", "r", "main", "feature-x")
+ assert client.request.call_args.args == ("GET", "repos/o/r/compare/main...feature-x")
+
+ @pytest.mark.asyncio
+ async def test_create_release_forwards_payload(self, client):
+ client.request.return_value = {"id": 1}
+ await client.create_release("o", "r", {"tag_name": "v1.0", "draft": False})
+ assert client.request.call_args.args == ("POST", "repos/o/r/releases")
+
+ @pytest.mark.asyncio
+ async def test_fork_payload_omits_none_fields(self, client):
+ client.request.return_value = {"id": 1}
+ await client.fork_repository("o", "r")
+ # Empty payload — no organization, no name.
+ assert client.request.call_args.kwargs["json_data"] == {}
+
+ client.request.reset_mock()
+ await client.fork_repository("o", "r", organization="new-org", name="new-name")
+ assert client.request.call_args.kwargs["json_data"] == {
+ "organization": "new-org",
+ "name": "new-name",
+ }
+
+
+# ---------------------------------------------------------------------------
+# Handlers: validation + JSON shape
+# ---------------------------------------------------------------------------
+
+
+class TestCreateFilesHandler:
+ @pytest.mark.asyncio
+ async def test_happy_path_batches_operations(self, client):
+ client.request.return_value = {"commit": {"sha": "abc"}}
+ out = json.loads(
+ await repo_handlers.create_files(
+ client,
+ owner="o",
+ repo="r",
+ files=[
+ {"operation": "create", "path": "a.txt", "content": "hi"},
+ {
+ "operation": "update",
+ "path": "b.txt",
+ "content": "bye",
+ "sha": "deadbeef",
+ },
+ {"operation": "delete", "path": "c.txt", "sha": "cafebabe"},
+ ],
+ message="batch commit",
+ branch="main",
+ )
+ )
+ assert out["success"] is True
+ assert "Batched 3 file" in out["message"]
+ # Client got a single batch call.
+ body = client.request.call_args.kwargs["json_data"]
+ assert body["branch"] == "main"
+ assert body["message"] == "batch commit"
+ assert len(body["files"]) == 3
+ # content is base64-encoded on the way out.
+ for f in body["files"][:2]:
+ assert base64.b64decode(f["content"])
+
+ @pytest.mark.asyncio
+ async def test_rejects_invalid_operation(self, client):
+ out = json.loads(
+ await repo_handlers.create_files(
+ client,
+ owner="o",
+ repo="r",
+ files=[{"operation": "rename", "path": "a.txt", "content": "x"}],
+ message="m",
+ )
+ )
+ assert out["success"] is False
+ assert out["errors"][0]["error"].startswith("invalid_operation")
+ client.request.assert_not_called()
+
+ @pytest.mark.asyncio
+ async def test_update_requires_sha(self, client):
+ out = json.loads(
+ await repo_handlers.create_files(
+ client,
+ owner="o",
+ repo="r",
+ files=[{"operation": "update", "path": "a.txt", "content": "x"}],
+ message="m",
+ )
+ )
+ assert out["success"] is False
+ assert out["errors"][0]["error"] == "missing_sha_for_update"
+ client.request.assert_not_called()
+
+ @pytest.mark.asyncio
+ async def test_delete_requires_sha(self, client):
+ out = json.loads(
+ await repo_handlers.create_files(
+ client,
+ owner="o",
+ repo="r",
+ files=[{"operation": "delete", "path": "a.txt"}],
+ message="m",
+ )
+ )
+ assert out["success"] is False
+ assert out["errors"][0]["error"] == "missing_sha_for_delete"
+
+ @pytest.mark.asyncio
+ async def test_bad_base64_surfaces_actionable_error(self, client):
+ out = json.loads(
+ await repo_handlers.create_files(
+ client,
+ owner="o",
+ repo="r",
+ files=[
+ {
+ "operation": "create",
+ "path": "a.txt",
+ "content": "###garbage###",
+ "content_is_base64": True,
+ }
+ ],
+ message="m",
+ )
+ )
+ assert out["success"] is False
+ assert "content_is_base64=False" in out["errors"][0]["error"]
+
+
+class TestTreeAndSearchHandlers:
+ @pytest.mark.asyncio
+ async def test_get_tree_passthrough(self, client):
+ client.request.return_value = {"tree": [{"path": "x"}]}
+ out = json.loads(await repo_handlers.get_tree(client, "o", "r", sha="main", recursive=True))
+ assert out["success"] is True
+ assert out["tree"]["tree"][0]["path"] == "x"
+
+ @pytest.mark.asyncio
+ async def test_search_code_passthrough(self, client):
+ client.request.return_value = {"ok": True, "data": [{"path": "hit"}]}
+ out = json.loads(
+ await repo_handlers.search_code(client, keyword="foo", owner="o", repo="r")
+ )
+ assert out["success"] is True
+ assert out["result"]["data"][0]["path"] == "hit"
+
+ @pytest.mark.asyncio
+ async def test_compare_passthrough(self, client):
+ client.request.return_value = {"commits": [{"sha": "c1"}]}
+ out = json.loads(await repo_handlers.compare(client, "o", "r", "main", "x"))
+ assert out["success"] is True
+ assert out["compare"]["commits"][0]["sha"] == "c1"
+
+
+class TestReleaseAndForkHandlers:
+ @pytest.mark.asyncio
+ async def test_list_releases_passthrough(self, client):
+ client.request.return_value = [{"tag_name": "v1"}]
+ out = json.loads(await repo_handlers.list_releases(client, "o", "r"))
+ assert out["success"] is True
+ assert out["releases"][0]["tag_name"] == "v1"
+
+ @pytest.mark.asyncio
+ async def test_create_release_forwards_optional_fields(self, client):
+ client.request.return_value = {"id": 42}
+ await repo_handlers.create_release(
+ client, "o", "r", tag_name="v1.0", name="1.0", body="hi", prerelease=True
+ )
+ body = client.request.call_args.kwargs["json_data"]
+ assert body["tag_name"] == "v1.0"
+ assert body["name"] == "1.0"
+ assert body["body"] == "hi"
+ assert body["prerelease"] is True
+
+ @pytest.mark.asyncio
+ async def test_create_release_without_optional_fields(self, client):
+ client.request.return_value = {"id": 42}
+ await repo_handlers.create_release(client, "o", "r", tag_name="v1.0")
+ body = client.request.call_args.kwargs["json_data"]
+ assert body["tag_name"] == "v1.0"
+ assert "name" not in body
+ assert "body" not in body
+
+ @pytest.mark.asyncio
+ async def test_fork_repository_with_org(self, client):
+ client.request.return_value = {"full_name": "neworg/r"}
+ out = json.loads(
+ await repo_handlers.fork_repository(client, "o", "r", organization="neworg")
+ )
+ assert out["success"] is True
+ assert out["fork"]["full_name"] == "neworg/r"
diff --git a/tests/test_gitea_plugin.py b/tests/test_gitea_plugin.py
index e160516..0e85ba1 100644
--- a/tests/test_gitea_plugin.py
+++ b/tests/test_gitea_plugin.py
@@ -125,8 +125,13 @@ class TestGiteaToolSpecifications:
return GiteaPlugin.get_tool_specifications()
def test_total_tool_count(self, specs):
- """Should have 58 tools total (16 repo + 13 issue + 15 PR + 8 user + 6 webhook)."""
- assert len(specs) == 58
+ """Gitea tool count.
+
+ Base v3.6.0 count was 58. F.17 ergonomics (2026-04-16) added 7
+ tools: create_files, get_tree, search_code, compare, list_releases,
+ create_release, fork_repository.
+ """
+ assert len(specs) == 65
def test_all_specs_have_required_keys(self, specs):
"""Every spec must have name, method_name, description, schema, scope."""
diff --git a/tests/test_site_provider_keys.py b/tests/test_site_provider_keys.py
new file mode 100644
index 0000000..f1aa576
--- /dev/null
+++ b/tests/test_site_provider_keys.py
@@ -0,0 +1,249 @@
+"""Tests for per-site AI provider keys (F.5a.9.x).
+
+Uses a real SQLite DB + real AES-256-GCM encryption to exercise the full
+round-trip: site_api encrypts/decrypts via the per-site scope, DB stores
+ciphertext, tenant isolation is enforced, and cascade delete cleans up.
+"""
+
+import base64
+import os
+
+import pytest
+
+from core.database import Database, initialize_database
+from core.site_api import (
+ PROVIDER_KEYS_ALLOWED_PLUGIN_TYPES,
+ SITE_PROVIDERS,
+ delete_site_provider_key,
+ get_site_provider_key,
+ list_site_providers_set,
+ set_site_provider_key,
+ site_provider_scope,
+ site_supports_provider_keys,
+)
+
+
+@pytest.fixture(autouse=True)
+def _set_encryption_key(monkeypatch):
+ """Ensure ENCRYPTION_KEY is set and encryption singleton is fresh."""
+ key = base64.b64encode(os.urandom(32)).decode()
+ monkeypatch.setenv("ENCRYPTION_KEY", key)
+ import core.encryption as enc_mod
+
+ monkeypatch.setattr(enc_mod, "_credential_encryption", None)
+
+
+@pytest.fixture
+async def db(tmp_path, monkeypatch):
+ """Initialize DB singleton so site_api.get_database() works."""
+ import core.database as db_mod
+
+ monkeypatch.setattr(db_mod, "_database", None)
+ database = await initialize_database(str(tmp_path / "test.db"))
+ yield database
+ await database.close()
+ monkeypatch.setattr(db_mod, "_database", None)
+
+
+@pytest.fixture
+async def user_row(db: Database):
+ return await db.create_user(
+ email="owner@example.com",
+ name="Owner",
+ provider="github",
+ provider_id="gh-999",
+ )
+
+
+@pytest.fixture
+async def second_user(db: Database):
+ return await db.create_user(
+ email="stranger@example.com",
+ name="Stranger",
+ provider="google",
+ provider_id="gg-888",
+ )
+
+
+async def _make_site(db: Database, user_id: str, *, plugin_type="wordpress", alias="myblog"):
+ return await db.create_site(
+ user_id=user_id,
+ plugin_type=plugin_type,
+ alias=alias,
+ url=f"https://{alias}.example.com",
+ credentials=b"fake-encrypted-creds",
+ )
+
+
+# ---------------------------------------------------------------------------
+# Pure helpers (no DB)
+# ---------------------------------------------------------------------------
+
+
+class TestHelpers:
+ @pytest.mark.unit
+ def test_scope_format(self):
+ assert site_provider_scope("abc-123", "openai") == "site_provider:abc-123:openai"
+
+ @pytest.mark.unit
+ def test_scope_differs_per_provider(self):
+ s1 = site_provider_scope("site-1", "openai")
+ s2 = site_provider_scope("site-1", "stability")
+ assert s1 != s2
+
+ @pytest.mark.unit
+ def test_supports_provider_keys_wp_wc(self):
+ assert site_supports_provider_keys("wordpress") is True
+ assert site_supports_provider_keys("woocommerce") is True
+
+ @pytest.mark.unit
+ def test_supports_provider_keys_rejects_others(self):
+ for plugin_type in ("gitea", "n8n", "supabase", "openpanel", "appwrite"):
+ assert site_supports_provider_keys(plugin_type) is False
+
+ @pytest.mark.unit
+ def test_allowed_plugin_types_and_providers(self):
+ # Guard against accidental scope creep
+ assert frozenset({"wordpress", "woocommerce"}) == PROVIDER_KEYS_ALLOWED_PLUGIN_TYPES
+ assert set(SITE_PROVIDERS) == {"openai", "stability", "replicate", "openrouter"}
+
+
+# ---------------------------------------------------------------------------
+# Round-trip via site_api + DB + real AES-GCM
+# ---------------------------------------------------------------------------
+
+
+class TestSiteProviderKeyRoundTrip:
+ @pytest.mark.unit
+ async def test_set_then_get_returns_plaintext(self, db, user_row):
+ site = await _make_site(db, user_row["id"])
+ await set_site_provider_key(site["id"], user_row["id"], "openai", "sk-test-1234567890")
+
+ got = await get_site_provider_key(site["id"], "openai")
+ assert got == "sk-test-1234567890"
+
+ @pytest.mark.unit
+ async def test_get_missing_returns_none(self, db, user_row):
+ site = await _make_site(db, user_row["id"])
+ assert await get_site_provider_key(site["id"], "openai") is None
+
+ @pytest.mark.unit
+ async def test_set_trims_whitespace(self, db, user_row):
+ site = await _make_site(db, user_row["id"])
+ await set_site_provider_key(site["id"], user_row["id"], "openai", " sk-xyz ")
+ assert await get_site_provider_key(site["id"], "openai") == "sk-xyz"
+
+ @pytest.mark.unit
+ async def test_set_overwrites_existing(self, db, user_row):
+ site = await _make_site(db, user_row["id"])
+ await set_site_provider_key(site["id"], user_row["id"], "openai", "first")
+ await set_site_provider_key(site["id"], user_row["id"], "openai", "second")
+ assert await get_site_provider_key(site["id"], "openai") == "second"
+
+ @pytest.mark.unit
+ async def test_list_providers_set(self, db, user_row):
+ site = await _make_site(db, user_row["id"])
+ assert await list_site_providers_set(site["id"]) == set()
+
+ await set_site_provider_key(site["id"], user_row["id"], "openai", "a")
+ await set_site_provider_key(site["id"], user_row["id"], "stability", "b")
+ assert await list_site_providers_set(site["id"]) == {"openai", "stability"}
+
+ @pytest.mark.unit
+ async def test_delete(self, db, user_row):
+ site = await _make_site(db, user_row["id"])
+ await set_site_provider_key(site["id"], user_row["id"], "openai", "a")
+
+ deleted = await delete_site_provider_key(site["id"], user_row["id"], "openai")
+ assert deleted is True
+ assert await get_site_provider_key(site["id"], "openai") is None
+
+ @pytest.mark.unit
+ async def test_delete_missing_returns_false(self, db, user_row):
+ site = await _make_site(db, user_row["id"])
+ assert await delete_site_provider_key(site["id"], user_row["id"], "openai") is False
+
+
+# ---------------------------------------------------------------------------
+# Validation / security
+# ---------------------------------------------------------------------------
+
+
+class TestSiteProviderKeyValidation:
+ @pytest.mark.unit
+ async def test_rejects_empty_key(self, db, user_row):
+ site = await _make_site(db, user_row["id"])
+ with pytest.raises(ValueError, match="empty"):
+ await set_site_provider_key(site["id"], user_row["id"], "openai", " ")
+
+ @pytest.mark.unit
+ async def test_rejects_unknown_provider(self, db, user_row):
+ site = await _make_site(db, user_row["id"])
+ with pytest.raises(ValueError, match="Unsupported provider"):
+ await set_site_provider_key(site["id"], user_row["id"], "midjourney", "abc")
+
+ @pytest.mark.unit
+ async def test_rejects_unknown_site(self, db, user_row):
+ with pytest.raises(ValueError, match="Site not found"):
+ await set_site_provider_key("does-not-exist", user_row["id"], "openai", "abc")
+
+ @pytest.mark.unit
+ async def test_rejects_non_wp_wc_site(self, db, user_row):
+ site = await _make_site(db, user_row["id"], plugin_type="gitea")
+ with pytest.raises(ValueError, match="does not support"):
+ await set_site_provider_key(site["id"], user_row["id"], "openai", "abc")
+
+ @pytest.mark.unit
+ async def test_get_unknown_provider_returns_none(self, db, user_row):
+ site = await _make_site(db, user_row["id"])
+ assert await get_site_provider_key(site["id"], "midjourney") is None
+
+ @pytest.mark.unit
+ async def test_foreign_user_cannot_set_key(self, db, user_row, second_user):
+ site = await _make_site(db, user_row["id"])
+ # set_site_provider_key enforces ownership via db.get_site(site_id, user_id)
+ with pytest.raises(ValueError, match="Site not found"):
+ await set_site_provider_key(site["id"], second_user["id"], "openai", "abc")
+
+ @pytest.mark.unit
+ async def test_foreign_user_cannot_delete_key(self, db, user_row, second_user):
+ site = await _make_site(db, user_row["id"])
+ await set_site_provider_key(site["id"], user_row["id"], "openai", "abc")
+
+ # Delete by stranger — returns False, row still present
+ assert await delete_site_provider_key(site["id"], second_user["id"], "openai") is False
+ assert await get_site_provider_key(site["id"], "openai") == "abc"
+
+
+# ---------------------------------------------------------------------------
+# Encryption isolation
+# ---------------------------------------------------------------------------
+
+
+class TestSiteProviderKeyEncryption:
+ @pytest.mark.unit
+ async def test_ciphertext_not_plaintext_in_db(self, db, user_row):
+ site = await _make_site(db, user_row["id"])
+ await set_site_provider_key(site["id"], user_row["id"], "openai", "sk-SENSITIVE-VALUE")
+ row = await db.get_site_provider_key(site["id"], "openai")
+ assert row is not None
+ assert b"sk-SENSITIVE-VALUE" not in row["key_ciphertext"]
+
+ @pytest.mark.unit
+ async def test_keys_differ_across_sites(self, db, user_row):
+ """Two sites storing the same plaintext key should produce different
+ ciphertexts (different HKDF scope + different random nonce)."""
+ s1 = await _make_site(db, user_row["id"], alias="one")
+ s2 = await _make_site(db, user_row["id"], alias="two", plugin_type="woocommerce")
+
+ await set_site_provider_key(s1["id"], user_row["id"], "openai", "same")
+ await set_site_provider_key(s2["id"], user_row["id"], "openai", "same")
+
+ r1 = await db.get_site_provider_key(s1["id"], "openai")
+ r2 = await db.get_site_provider_key(s2["id"], "openai")
+ assert r1 is not None and r2 is not None
+ assert r1["key_ciphertext"] != r2["key_ciphertext"]
+
+ # Both decrypt back to the same plaintext via their own scopes
+ assert await get_site_provider_key(s1["id"], "openai") == "same"
+ assert await get_site_provider_key(s2["id"], "openai") == "same"
diff --git a/tests/test_tool_access_visibility.py b/tests/test_tool_access_visibility.py
new file mode 100644
index 0000000..2459417
--- /dev/null
+++ b/tests/test_tool_access_visibility.py
@@ -0,0 +1,117 @@
+"""F.X.fix #8 — Tool Access hides AI tools until a provider key is set.
+
+Regression: ``wordpress_generate_and_upload_image`` appeared as
+available in the Tool Access list even when the site had no AI
+provider key configured. User only discovered at call time via
+``NO_PROVIDER_KEY``. Fix: each tool row now carries
+``provider_key_required`` + ``provider_key_configured`` so the
+template can gray the tool out and render a "Configure key" CTA.
+"""
+
+from __future__ import annotations
+
+from unittest.mock import AsyncMock, patch
+
+import pytest
+
+from core.tool_access import (
+ _tool_has_configured_provider,
+ _tool_requires_provider_key,
+ get_tool_access_manager,
+)
+
+
+class TestProviderKeyHelpers:
+ def test_ai_image_tool_requires_key(self):
+ assert _tool_requires_provider_key("wordpress_generate_and_upload_image")
+
+ def test_normal_tool_does_not_require_key(self):
+ assert not _tool_requires_provider_key("wordpress_create_post")
+ assert not _tool_requires_provider_key("wordpress_list_posts")
+
+ def test_configured_helper_returns_true_for_non_ai_tools(self):
+ # Non-AI tools aren't gated; they're always "configured".
+ assert _tool_has_configured_provider("wordpress_create_post", set())
+
+ def test_configured_helper_gates_ai_on_providers_set(self):
+ assert not _tool_has_configured_provider("wordpress_generate_and_upload_image", set())
+ assert _tool_has_configured_provider("wordpress_generate_and_upload_image", {"openrouter"})
+
+
+class _FakeToolDef:
+ """Minimal ToolDefinition stand-in for the registry mock."""
+
+ def __init__(self, name: str, category: str = "content"):
+ self.name = name
+ self.description = f"desc for {name}"
+ self.plugin_type = "wordpress"
+ self.category = category
+ self.sensitivity = "low"
+ self.required_scope = "read"
+
+
+class TestListToolsForSiteAnnotatesProviderKey:
+ @pytest.fixture
+ def fake_tools(self):
+ return [
+ _FakeToolDef("wordpress_list_posts"),
+ _FakeToolDef("wordpress_create_post", category="content"),
+ _FakeToolDef("wordpress_generate_and_upload_image", category="media"),
+ ]
+
+ @pytest.mark.asyncio
+ async def test_ai_tool_flagged_not_configured_when_site_has_no_keys(self, fake_tools):
+ manager = get_tool_access_manager()
+ fake_db = AsyncMock()
+ fake_db.get_site_tool_toggles = AsyncMock(return_value={})
+ fake_registry = AsyncMock()
+ fake_registry.get_by_plugin_type = lambda plugin_type: fake_tools
+ with (
+ patch("core.database.get_database", return_value=fake_db),
+ patch("core.tool_registry.get_tool_registry", return_value=fake_registry),
+ patch("core.site_api.list_site_providers_set", new=AsyncMock(return_value=set())),
+ ):
+ rows = await manager.list_tools_for_site("site-1", "wordpress")
+
+ ai = next(r for r in rows if r["name"] == "wordpress_generate_and_upload_image")
+ assert ai["provider_key_required"] is True
+ assert ai["provider_key_configured"] is False
+
+ @pytest.mark.asyncio
+ async def test_ai_tool_flagged_configured_when_site_has_openrouter_key(self, fake_tools):
+ manager = get_tool_access_manager()
+ fake_db = AsyncMock()
+ fake_db.get_site_tool_toggles = AsyncMock(return_value={})
+ fake_registry = AsyncMock()
+ fake_registry.get_by_plugin_type = lambda plugin_type: fake_tools
+ with (
+ patch("core.database.get_database", return_value=fake_db),
+ patch("core.tool_registry.get_tool_registry", return_value=fake_registry),
+ patch(
+ "core.site_api.list_site_providers_set",
+ new=AsyncMock(return_value={"openrouter"}),
+ ),
+ ):
+ rows = await manager.list_tools_for_site("site-1", "wordpress")
+
+ ai = next(r for r in rows if r["name"] == "wordpress_generate_and_upload_image")
+ assert ai["provider_key_required"] is True
+ assert ai["provider_key_configured"] is True
+
+ @pytest.mark.asyncio
+ async def test_non_ai_tool_always_shows_configured_regardless_of_keys(self, fake_tools):
+ manager = get_tool_access_manager()
+ fake_db = AsyncMock()
+ fake_db.get_site_tool_toggles = AsyncMock(return_value={})
+ fake_registry = AsyncMock()
+ fake_registry.get_by_plugin_type = lambda plugin_type: fake_tools
+ with (
+ patch("core.database.get_database", return_value=fake_db),
+ patch("core.tool_registry.get_tool_registry", return_value=fake_registry),
+ patch("core.site_api.list_site_providers_set", new=AsyncMock(return_value=set())),
+ ):
+ rows = await manager.list_tools_for_site("site-1", "wordpress")
+
+ non_ai = next(r for r in rows if r["name"] == "wordpress_create_post")
+ assert non_ai["provider_key_required"] is False
+ assert non_ai["provider_key_configured"] is True
diff --git a/tests/test_user_endpoints_tool_filter.py b/tests/test_user_endpoints_tool_filter.py
new file mode 100644
index 0000000..c15a670
--- /dev/null
+++ b/tests/test_user_endpoints_tool_filter.py
@@ -0,0 +1,108 @@
+"""F.5a.9.x: per-site tool visibility filter for wordpress_generate_and_upload_image.
+
+Verifies that ``_get_visible_tools_for_site`` hides the AI-image tool from
+``tools/list`` when the site has no provider key configured — the user's
+stated requirement that "if not defined, the image creation tool should
+be disabled by default".
+"""
+
+from __future__ import annotations
+
+from unittest.mock import AsyncMock, MagicMock, patch
+
+import pytest
+
+from core.user_endpoints import _get_visible_tools_for_site
+
+
+def _make_tool(name: str) -> MagicMock:
+ tool = MagicMock()
+ tool.name = name
+ tool.description = f"description for {name}"
+ tool.plugin_type = "wordpress"
+ tool.required_scope = "write"
+ tool.category = "write"
+ tool.sensitivity = "normal"
+ tool.input_schema = {"type": "object", "properties": {}}
+ return tool
+
+
+@pytest.fixture
+def base_tools():
+ return [
+ _make_tool("wordpress_list_posts"),
+ _make_tool("wordpress_create_post"),
+ _make_tool("wordpress_generate_and_upload_image"),
+ ]
+
+
+class TestAIImageToolVisibility:
+ @pytest.mark.unit
+ async def test_no_key_hides_ai_tool(self, base_tools):
+ """Site without any provider key → ai-image tool is filtered out."""
+ with (
+ patch("core.tool_access.get_tool_access_manager") as m_access,
+ patch("core.site_api.list_site_providers_set", AsyncMock(return_value=set())),
+ ):
+ m_access.return_value.get_visible_tools = AsyncMock(return_value=base_tools)
+ out = await _get_visible_tools_for_site(
+ site_id="site-1", key_scopes=["write"], plugin_type="wordpress"
+ )
+
+ names = [t["name"] for t in out]
+ assert "wordpress_generate_and_upload_image" not in names
+ assert "wordpress_list_posts" in names
+ assert "wordpress_create_post" in names
+
+ @pytest.mark.unit
+ async def test_any_key_exposes_ai_tool(self, base_tools):
+ """Site with at least one provider key → ai-image tool is visible."""
+ with (
+ patch("core.tool_access.get_tool_access_manager") as m_access,
+ patch(
+ "core.site_api.list_site_providers_set",
+ AsyncMock(return_value={"openai"}),
+ ),
+ ):
+ m_access.return_value.get_visible_tools = AsyncMock(return_value=base_tools)
+ out = await _get_visible_tools_for_site(
+ site_id="site-1", key_scopes=["write"], plugin_type="wordpress"
+ )
+
+ names = [t["name"] for t in out]
+ assert "wordpress_generate_and_upload_image" in names
+
+ @pytest.mark.unit
+ async def test_non_wp_wc_plugins_are_unaffected(self):
+ """Gitea etc. never carry provider keys and never register the
+ ai-image tool — the filter must not interfere."""
+ tools = [_make_tool("gitea_list_repos")]
+ tools[0].plugin_type = "gitea"
+
+ with patch("core.tool_access.get_tool_access_manager") as m_access:
+ m_access.return_value.get_visible_tools = AsyncMock(return_value=tools)
+ # list_site_providers_set should NOT be called on non-wp/wc plugin types
+ with patch(
+ "core.site_api.list_site_providers_set",
+ AsyncMock(side_effect=AssertionError("should not be called")),
+ ):
+ out = await _get_visible_tools_for_site(
+ site_id="site-1", key_scopes=["read"], plugin_type="gitea"
+ )
+
+ assert [t["name"] for t in out] == ["gitea_list_repos"]
+
+ @pytest.mark.unit
+ async def test_woocommerce_plugin_also_gates_ai_tool(self, base_tools):
+ """Symmetric behaviour on woocommerce plugin_type."""
+ with (
+ patch("core.tool_access.get_tool_access_manager") as m_access,
+ patch("core.site_api.list_site_providers_set", AsyncMock(return_value=set())),
+ ):
+ m_access.return_value.get_visible_tools = AsyncMock(return_value=base_tools)
+ out = await _get_visible_tools_for_site(
+ site_id="wc-1", key_scopes=["write"], plugin_type="woocommerce"
+ )
+
+ names = [t["name"] for t in out]
+ assert "wordpress_generate_and_upload_image" not in names
diff --git a/tests/test_woocommerce_plugin.py b/tests/test_woocommerce_plugin.py
index b051717..59ab6cc 100644
--- a/tests/test_woocommerce_plugin.py
+++ b/tests/test_woocommerce_plugin.py
@@ -94,9 +94,16 @@ class TestWooCommerceToolSpecs:
assert len(specs) > 0
def test_specs_count(self):
- """Should return exactly 28 tool specs."""
+ """Should return 32 tool specs.
+
+ Breakdown: 12 products + 5 orders + 4 customers + 4 coupons +
+ 3 reports + 3 media-attach (F.5a.3) + 1 AI image (F.X.fix-pass5
+ re-exposed generate_and_upload_image on the WC plugin so
+ operators don't need a separate WP site to chain AI generation
+ with WC product attachment).
+ """
specs = WooCommercePlugin.get_tool_specifications()
- assert len(specs) == 28
+ assert len(specs) == 32
def test_specs_have_required_fields(self):
"""Each spec should have name, method_name, description, schema, scope."""
diff --git a/wordpress-plugin/airano-mcp-bridge.zip b/wordpress-plugin/airano-mcp-bridge.zip
new file mode 100644
index 0000000..972ad55
Binary files /dev/null and b/wordpress-plugin/airano-mcp-bridge.zip differ
diff --git a/wordpress-plugin/airano-mcp-bridge/README.md b/wordpress-plugin/airano-mcp-bridge/README.md
new file mode 100644
index 0000000..ec9e9f9
--- /dev/null
+++ b/wordpress-plugin/airano-mcp-bridge/README.md
@@ -0,0 +1,193 @@
+# Airano MCP Bridge
+
+**Version:** 2.9.0
+**Requires WordPress:** 5.0+
+**Tested up to:** 6.9
+**Requires PHP:** 7.4+
+**License:** GPLv2 or later
+**Plugin slug:** `airano-mcp-bridge` (permanent)
+
+> Companion plugin for [MCP Hub](https://github.com/airano-ir/mcphub) — the AI-native management hub for WordPress, WooCommerce, and self-hosted services.
+
+## What this plugin does
+
+MCP Hub lets AI assistants (Claude, ChatGPT, Cursor, VS Code Copilot, …) manage your WordPress site through the Model Context Protocol. The core features work against the stock `/wp-json/wp/v2/` REST endpoints — **you do not need this plugin to use MCP Hub**.
+
+Installing **Airano MCP Bridge** unlocks a second tier of capabilities that WordPress's built-in REST API can't reach on its own:
+
+- **Large-file uploads** that bypass `upload_max_filesize`
+- **One-round-trip upload + metadata + attach + set-featured** (v2.9.0)
+- **Thumbnail regeneration** (no WP-CLI / SSH)
+- **Cache purge** for 6 major cache plugins (LiteSpeed, WP Rocket, W3TC, WP Super Cache, WP Fastest Cache, SG Optimizer)
+- **Transient cleanup** (expired / all / pattern)
+- **Bulk post-meta writes** in a single REST round-trip
+- **Unified site-health snapshot** (5+ stock calls → 1)
+- **Structured JSON export** (posts / pages / products + media + terms + meta)
+- **Capability probe** — tells MCP Hub what the calling application password can actually do
+- **Audit hook** — forwards WordPress action events to MCP Hub with HMAC-signed webhooks
+- **SEO meta routes** for Rank Math and Yoast
+
+All operations remain guarded by WordPress's own capability system — this plugin never bypasses auth.
+
+## Installation
+
+1. Download `airano-mcp-bridge.zip` from the MCP Hub release page.
+2. In WP Admin → **Plugins → Add New → Upload Plugin**, choose the zip and **Activate**.
+3. Generate an **Application Password** under Users → Profile (scroll to bottom).
+4. In MCP Hub dashboard → **My Sites**, add your WordPress site using that Application Password.
+
+The plugin works out of the box. There are no settings pages to configure; behavior is controlled entirely through the REST API.
+
+## REST API endpoints
+
+### SEO meta (namespace `airano-mcp-bridge/v1`)
+
+- `GET / POST /posts/{id}/seo` — Post SEO data (Rank Math or Yoast, auto-detected)
+- `GET / POST /pages/{id}/seo` — Page SEO data
+- `GET / POST /products/{id}/seo` — WooCommerce product SEO data
+- `GET /status` — Plugin status + active SEO plugins list
+
+### MCP Bridge helpers (namespace `airano-mcp/v1`)
+
+| Route | Method | Introduced | Purpose |
+|---|---|---|---|
+| `/upload-limits` | GET | 2.0.0 | Returns effective PHP + WP upload limits so MCP Hub can pick the right upload path |
+| `/upload-chunk` | POST | 2.0.0 | Raw-body upload that bypasses `upload_max_filesize` (still bounded by `post_max_size`) |
+| `/upload-and-attach` | POST | 2.9.0 | Same as `/upload-chunk` plus query-param metadata (`attach_to_post`, `set_featured`, `title`, `alt_text`, `caption`, `description`) — collapses 3 REST round-trips into 1 |
+| `/capabilities` | GET | 2.1.0 | Effective capabilities of the calling Application Password + feature flags + available routes |
+| `/bulk-meta` | POST | 2.2.0 | Batch `post_meta` writes in one request (max 500 items, per-item permission check) |
+| `/export` | GET | 2.3.0 | Structured JSON export of posts / pages / products + media + terms + meta, with `post_type` / `status` / `since` / `limit` / `offset` paging |
+| `/cache-purge` | POST | 2.4.0 | Auto-detects LiteSpeed, WP Rocket, W3 Total Cache, WP Super Cache, WP Fastest Cache, SiteGround Optimizer and triggers each one's purge API; always flushes object cache |
+| `/transient-flush` | POST | 2.5.0 | Native transient cleanup with `expired` / `all` / `pattern` scopes; optional site-transient handling for multisite |
+| `/site-health` | GET | 2.6.0 | Single-envelope health snapshot: WP/PHP/MySQL versions, disk free, active plugins, theme, writability, SSL — replaces 5+ stock calls |
+| `/audit-hook` | GET / POST / DELETE | 2.7.0 | Configure a webhook that forwards WP action events to MCP Hub. HMAC-SHA256-signed, non-blocking |
+| `/regenerate-thumbnails` | POST | 2.8.0 | Rebuild attachment sub-sizes via `wp_generate_attachment_metadata()` for a list of IDs or in paged batch mode |
+
+### Authentication
+
+All routes require **HTTP Basic** authentication with a WordPress **Application Password** (Users → Profile → Application Passwords). Per-route capability requirements:
+
+| Route family | Required capability |
+|---|---|
+| SEO meta (read/write) | `edit_posts` |
+| Upload helpers | `upload_files` or `manage_options` |
+| Capabilities probe | `read` (any logged-in user) |
+| Bulk meta, Export | `edit_posts` (per-item `edit_post` check) |
+| Cache purge, Transient flush, Site health, Audit hook, Regenerate thumbnails | `manage_options` |
+
+## Privacy
+
+This plugin makes **no outbound network calls** on its own. It exposes authenticated REST API endpoints that your MCP Hub instance calls into. The audit-hook route **only** sends webhooks when you explicitly configure it (POST to `/audit-hook` with an endpoint URL + secret); without that configuration, zero telemetry leaves your WordPress.
+
+No analytics, no phone-home, no cloud relay.
+
+## Security notes
+
+- All write routes enforce capability checks.
+- Raw-body uploads go through `wp_handle_sideload()` which respects WordPress's allowed-MIME list.
+- Bulk meta writes check `current_user_can('edit_post', $post_id)` per item, not just per batch.
+- Audit-hook secrets are stored in `wp_options` and never echoed in responses (only the last 4 characters are returned on read).
+- Audit-hook webhooks are signed with HMAC-SHA256 so MCP Hub can verify the message came from your site and hasn't been tampered with.
+
+If you find a security issue, please report it privately to the maintainers at the [GitHub issues page](https://github.com/airano-ir/mcphub/issues) rather than filing a public issue.
+
+## Frequently asked questions
+
+### Do I need MCP Hub to use this plugin?
+
+No. Every route is a standard authenticated WordPress REST endpoint and can be called from any HTTP client (curl, Postman, a custom integration). MCP Hub is the reference consumer but not a requirement.
+
+### Which SEO plugins are supported?
+
+**Rank Math SEO** and **Yoast SEO**. The plugin auto-detects which is active and routes SEO-meta calls accordingly.
+
+### Does this plugin work on multisite?
+
+Yes. Route registration, capability checks, and transient flushing all respect multisite boundaries. The optional `include_site_transients` flag on `/transient-flush` handles site transients on multisite setups.
+
+### Why keep the folder name `airano-mcp-bridge` even though the plugin now does much more than SEO?
+
+Plugin slugs on WordPress.org are permanent once registered. Existing installs keep working; display name, description, and feature surface are what we can update.
+
+### What happens if I uninstall?
+
+Uninstall removes plugin options including the audit-hook secret and endpoint. Media uploaded via the plugin stays in your media library (it's regular WordPress attachments — the plugin just provides the upload pipeline).
+
+### How large a file can I upload?
+
+The `/upload-chunk` route bypasses PHP's `upload_max_filesize` (typically 2–64 MB on shared hosting), but is still bounded by `post_max_size` and `memory_limit`. For files larger than `post_max_size`, MCP Hub falls back to its own server-side chunked session pipeline that reassembles chunks before handing the full file to this route.
+
+## Changelog
+
+### 2.9.0 — 2026-04-16
+
+- New: `POST /airano-mcp/v1/upload-and-attach` — raw-body upload + metadata (title / alt / caption / description) + attach-to-post + set-featured in a single REST round-trip. Saves 2–3 calls for every hero-image or product-image workflow. Per-target permission enforced via `current_user_can('edit_post', attach_to_post)`.
+- Status + capabilities payloads now advertise `upload_and_attach: true` + `regenerate_thumbnails: true`.
+
+### 2.8.0 — 2026-04-15
+
+- New: `POST /airano-mcp/v1/regenerate-thumbnails` — rebuild attachment sub-sizes via `wp_generate_attachment_metadata()` after uploads, theme switches, or format conversions. Two modes: `{ "ids": [...] }` for targeted regeneration (max 50/call) or `{ "all": true, "offset": N, "limit": M }` for paged batches.
+
+### 2.7.0
+
+- New: `GET|POST|DELETE /airano-mcp/v1/audit-hook` — configure a webhook that forwards WP action events (`transition_post_status`, `deleted_post`, `user_register`, `profile_update`, `deleted_user`, `activated_plugin`, `deactivated_plugin`, `switch_theme`) to MCP Hub. HMAC-SHA256-signed, non-blocking `wp_remote_post`. Permission: `manage_options`.
+
+### 2.6.0
+
+- New: `GET /airano-mcp/v1/site-health` — single-envelope snapshot: WP version / multisite / locale, PHP version + extensions, server software + disk free, MySQL/MariaDB version + charset, active plugins + theme, writability checks (wp-content, uploads) + SSL.
+
+### 2.5.0
+
+- New: `POST /airano-mcp/v1/transient-flush` — native transient cleanup. Scopes: `expired` (default), `all`, or `pattern` (glob match, e.g. `rank_math_*`). Optional `include_site_transients` for multisite.
+
+### 2.4.0
+
+- New: `POST /airano-mcp/v1/cache-purge` — auto-detects active page-cache plugins (LiteSpeed, WP Rocket, W3 Total Cache, WP Super Cache, WP Fastest Cache, SiteGround Optimizer) and invokes each one's purge API. Always calls `wp_cache_flush()` for object caches.
+
+### 2.3.0
+
+- New: `GET /airano-mcp/v1/export` — structured JSON export (not WXR). Query params: `post_type`, `status`, `since`, `limit` (max 500), `offset`, `include_media`, `include_terms`, `include_meta`.
+
+### 2.2.0
+
+- New: `POST /airano-mcp/v1/bulk-meta` — batch post-meta writes (max 500 items per call). Per-item permission check via `current_user_can('edit_post', $post_id)`. Null meta values delete the key.
+
+### 2.1.0
+
+- New: `GET /airano-mcp/v1/capabilities` — reports the effective capabilities of the calling Application Password plus feature flags (Rank Math / Yoast / WooCommerce / multisite) and the list of companion routes this version ships. Consumed by MCP Hub's F.7e capability probe.
+
+### 2.0.0
+
+- **Rebrand**: "Airano MCP SEO Meta Bridge" → "Airano MCP Bridge". No longer SEO-only.
+- New: `GET /airano-mcp/v1/upload-limits` — returns effective PHP + WP upload limits.
+- New: `POST /airano-mcp/v1/upload-chunk` — raw-body upload route that bypasses `upload_max_filesize`.
+
+### 1.3.0
+
+- Added REST API endpoints for posts, pages, and products (GET/POST operations).
+- Added authentication requirement for all endpoints (edit_posts).
+- Fixed `rank_math_title` meta key registration.
+
+### 1.2.0
+
+- Enhanced WooCommerce product support.
+- Improved MariaDB compatibility.
+
+### 1.1.0
+
+- Added status endpoint for plugin detection.
+- Improved error handling.
+
+### 1.0.0
+
+- Initial release with Rank Math and Yoast SEO support.
+
+## Upgrade notice
+
+### 2.9.0
+
+Adds `POST /airano-mcp/v1/upload-and-attach` — upload + metadata + attach + featured in one REST round-trip. No breaking changes to existing routes.
+
+## Credits
+
+Built by the [MCP Hub](https://github.com/airano-ir/mcphub) project. Contributions welcome — see the parent repository for contribution guidelines.
diff --git a/wordpress-plugin/airano-mcp-bridge/airano-mcp-bridge.php b/wordpress-plugin/airano-mcp-bridge/airano-mcp-bridge.php
new file mode 100644
index 0000000..6ca52a4
--- /dev/null
+++ b/wordpress-plugin/airano-mcp-bridge/airano-mcp-bridge.php
@@ -0,0 +1,2861 @@
+ true,
+ 'upload_limits' => true,
+ 'upload_chunk' => true,
+ 'upload_and_attach' => true,
+ 'capabilities' => true,
+ 'bulk_meta' => true,
+ 'export' => true,
+ 'cache_purge' => true,
+ 'transient_flush' => true,
+ 'site_health' => true,
+ 'audit_hook' => true,
+ 'regenerate_thumbnails' => true,
+ ];
+ }
+
+ /**
+ * Option keys used by F.18.7 audit-hook persistence.
+ */
+ const AUDIT_OPT_ENABLED = 'airano_mcp_audit_enabled';
+ const AUDIT_OPT_ENDPOINT = 'airano_mcp_audit_endpoint';
+ const AUDIT_OPT_SECRET = 'airano_mcp_audit_secret';
+ const AUDIT_OPT_EVENTS = 'airano_mcp_audit_events';
+ const AUDIT_OPT_LAST_PUSH_GMT = 'airano_mcp_audit_last_push_gmt';
+ const AUDIT_OPT_FAILURE_COUNT = 'airano_mcp_audit_failure_count';
+ const AUDIT_OPT_LAST_ERROR = 'airano_mcp_audit_last_error';
+
+ /**
+ * Default event list if the caller doesn't specify.
+ */
+ const AUDIT_DEFAULT_EVENTS = [
+ 'transition_post_status',
+ 'deleted_post',
+ 'user_register',
+ 'profile_update',
+ 'deleted_user',
+ 'activated_plugin',
+ 'deactivated_plugin',
+ 'switch_theme',
+ ];
+
+ /**
+ * Supported post types
+ */
+ private $supported_post_types = ['post', 'page', 'product'];
+
+ /**
+ * Constructor
+ */
+ public function __construct() {
+ add_action('rest_api_init', [$this, 'register_meta_fields']);
+ add_action('rest_api_init', [$this, 'register_rest_routes']);
+ add_action('admin_notices', [$this, 'admin_notices']);
+
+ // Add product support message if WooCommerce is active
+ if ($this->is_woocommerce_active()) {
+ $this->supported_post_types[] = 'product_variation'; // Also support variations
+ }
+
+ // F.18.7: register audit hooks if the operator has configured a
+ // MCPHub webhook endpoint. Hooks are idempotent when disabled.
+ $this->audit_register_hooks();
+ }
+
+ /**
+ * Register REST API routes
+ */
+ public function register_rest_routes() {
+ // Status endpoint
+ register_rest_route('airano-mcp-bridge/v1', '/status', [
+ 'methods' => 'GET',
+ 'callback' => [$this, 'get_status'],
+ 'permission_callback' => function() {
+ return is_user_logged_in();
+ }
+ ]);
+
+ // Post SEO endpoints
+ register_rest_route('airano-mcp-bridge/v1', '/posts/(?P\d+)/seo', [
+ [
+ 'methods' => 'GET',
+ 'callback' => [$this, 'get_post_seo'],
+ 'permission_callback' => function() {
+ return current_user_can('edit_posts');
+ },
+ 'args' => [
+ 'id' => [
+ 'validate_callback' => function($param) {
+ return is_numeric($param);
+ }
+ ]
+ ]
+ ],
+ [
+ 'methods' => 'POST',
+ 'callback' => [$this, 'update_post_seo'],
+ 'permission_callback' => function() {
+ return current_user_can('edit_posts');
+ },
+ 'args' => [
+ 'id' => [
+ 'validate_callback' => function($param) {
+ return is_numeric($param);
+ }
+ ]
+ ]
+ ]
+ ]);
+
+ // Page SEO endpoints
+ register_rest_route('airano-mcp-bridge/v1', '/pages/(?P\d+)/seo', [
+ [
+ 'methods' => 'GET',
+ 'callback' => [$this, 'get_page_seo'],
+ 'permission_callback' => function() {
+ return current_user_can('edit_posts');
+ },
+ 'args' => [
+ 'id' => [
+ 'validate_callback' => function($param) {
+ return is_numeric($param);
+ }
+ ]
+ ]
+ ],
+ [
+ 'methods' => 'POST',
+ 'callback' => [$this, 'update_page_seo'],
+ 'permission_callback' => function() {
+ return current_user_can('edit_pages');
+ },
+ 'args' => [
+ 'id' => [
+ 'validate_callback' => function($param) {
+ return is_numeric($param);
+ }
+ ]
+ ]
+ ]
+ ]);
+
+ // Product SEO endpoints (WooCommerce)
+ register_rest_route('airano-mcp-bridge/v1', '/products/(?P\d+)/seo', [
+ [
+ 'methods' => 'GET',
+ 'callback' => [$this, 'get_product_seo'],
+ 'permission_callback' => function() {
+ return current_user_can('edit_posts');
+ },
+ 'args' => [
+ 'id' => [
+ 'validate_callback' => function($param) {
+ return is_numeric($param);
+ }
+ ]
+ ]
+ ],
+ [
+ 'methods' => 'POST',
+ 'callback' => [$this, 'update_product_seo'],
+ 'permission_callback' => function() {
+ return current_user_can('edit_posts');
+ },
+ 'args' => [
+ 'id' => [
+ 'validate_callback' => function($param) {
+ return is_numeric($param);
+ }
+ ]
+ ]
+ ]
+ ]);
+
+ // --- F.5a.7: MCP Bridge upload helper routes -----------------------
+ // Namespace: airano-mcp/v1 (separate from airano-mcp-bridge/v1 so
+ // the SEO namespace stays focused and MCPHub probes a short URL).
+
+ // GET /wp-json/airano-mcp/v1/upload-limits
+ register_rest_route('airano-mcp/v1', '/upload-limits', [
+ 'methods' => 'GET',
+ 'callback' => [$this, 'get_upload_limits'],
+ 'permission_callback' => [$this, 'require_upload_capability'],
+ ]);
+
+ // POST /wp-json/airano-mcp/v1/upload-chunk
+ // Accepts raw body (php://input) + Content-Type + Content-Disposition.
+ // Bypasses PHP upload_max_filesize (still bounded by post_max_size).
+ register_rest_route('airano-mcp/v1', '/upload-chunk', [
+ 'methods' => 'POST',
+ 'callback' => [$this, 'handle_upload_chunk'],
+ 'permission_callback' => [$this, 'require_upload_capability'],
+ ]);
+
+ // --- F.5a.8.5: single-call upload + metadata + attach + featured -
+ // POST /wp-json/airano-mcp/v1/upload-and-attach
+ // Same raw-body semantics as /upload-chunk, plus query params:
+ // ?attach_to_post=42
+ // ?set_featured=true|false
+ // ?title=... ?alt_text=... ?caption=...
+ // All metadata application happens in-process so the caller saves
+ // 2-3 round-trips compared to the upload → PATCH /media/{id} →
+ // PATCH /posts/{id} sequence.
+ register_rest_route('airano-mcp/v1', '/upload-and-attach', [
+ 'methods' => 'POST',
+ 'callback' => [$this, 'handle_upload_and_attach'],
+ 'permission_callback' => [$this, 'require_upload_capability'],
+ ]);
+
+ // --- F.18.1: Capability probe ------------------------------------
+ // GET /wp-json/airano-mcp/v1/capabilities
+ // Reports the exact capability set of the calling application password
+ // plus feature flags + available routes. Consumed by MCPHub's F.7e
+ // probe to decide which tools to expose to the user.
+ register_rest_route('airano-mcp/v1', '/capabilities', [
+ 'methods' => 'GET',
+ 'callback' => [$this, 'get_capabilities'],
+ 'permission_callback' => function () {
+ return is_user_logged_in();
+ },
+ ]);
+
+ // --- F.18.2: Bulk meta writes ------------------------------------
+ // POST /wp-json/airano-mcp/v1/bulk-meta
+ // Body: {"updates": [{"post_id": 123, "meta": {"k": "v", ...}}, ...]}
+ // Loops in PHP: one HTTP round-trip for many post_meta writes.
+ register_rest_route('airano-mcp/v1', '/bulk-meta', [
+ 'methods' => 'POST',
+ 'callback' => [$this, 'handle_bulk_meta'],
+ 'permission_callback' => function () {
+ return current_user_can('edit_posts');
+ },
+ ]);
+
+ // --- F.18.3: Structured JSON export ------------------------------
+ // GET /wp-json/airano-mcp/v1/export
+ // Query: post_type, status, since, limit, offset,
+ // include_media, include_terms, include_meta
+ register_rest_route('airano-mcp/v1', '/export', [
+ 'methods' => 'GET',
+ 'callback' => [$this, 'handle_export'],
+ 'permission_callback' => function () {
+ return current_user_can('edit_posts');
+ },
+ ]);
+
+ // --- F.18.4: Cache purge (auto-detects active cache plugin) ------
+ // POST /wp-json/airano-mcp/v1/cache-purge
+ // Body (optional): {} — currently always purges "all" + object cache.
+ register_rest_route('airano-mcp/v1', '/cache-purge', [
+ 'methods' => 'POST',
+ 'callback' => [$this, 'handle_cache_purge'],
+ 'permission_callback' => function () {
+ return current_user_can('manage_options');
+ },
+ ]);
+
+ // --- F.18.5: Transient flush ------------------------------------
+ // POST /wp-json/airano-mcp/v1/transient-flush
+ // Body (optional): {"scope": "expired|all|pattern", "pattern": "foo_*",
+ // "include_site_transients": true}
+ register_rest_route('airano-mcp/v1', '/transient-flush', [
+ 'methods' => 'POST',
+ 'callback' => [$this, 'handle_transient_flush'],
+ 'permission_callback' => function () {
+ return current_user_can('manage_options');
+ },
+ ]);
+
+ // --- F.18.6: Unified site-health snapshot -----------------------
+ // GET /wp-json/airano-mcp/v1/site-health
+ register_rest_route('airano-mcp/v1', '/site-health', [
+ 'methods' => 'GET',
+ 'callback' => [$this, 'handle_site_health'],
+ 'permission_callback' => function () {
+ return current_user_can('manage_options');
+ },
+ ]);
+
+ // --- F.18.7: Audit hook configuration / status ------------------
+ // GET → current config (secret masked)
+ // POST → set endpoint_url / secret / enabled / events
+ // DELETE → clear config (disables pushing)
+ register_rest_route('airano-mcp/v1', '/audit-hook', [
+ [
+ 'methods' => 'GET',
+ 'callback' => [$this, 'handle_audit_hook_get'],
+ 'permission_callback' => function () {
+ return current_user_can('manage_options');
+ },
+ ],
+ [
+ 'methods' => 'POST',
+ 'callback' => [$this, 'handle_audit_hook_set'],
+ 'permission_callback' => function () {
+ return current_user_can('manage_options');
+ },
+ ],
+ [
+ 'methods' => 'DELETE',
+ 'callback' => [$this, 'handle_audit_hook_clear'],
+ 'permission_callback' => function () {
+ return current_user_can('manage_options');
+ },
+ ],
+ ]);
+
+ // --- F.5a.8.2: Regenerate attachment thumbnails -----------------
+ // POST /wp-json/airano-mcp/v1/regenerate-thumbnails
+ // Body: { "ids": [int, ...] } → regenerate sub-sizes for those
+ // attachments only.
+ // { "all": true, "offset": N, "limit": M }
+ // → batch mode; caps limit at 50 per request, returns
+ // { "processed": int, "errors": [...], "has_more": bool,
+ // "next_offset": int }.
+ register_rest_route('airano-mcp/v1', '/regenerate-thumbnails', [
+ 'methods' => 'POST',
+ 'callback' => [$this, 'handle_regenerate_thumbnails'],
+ 'permission_callback' => function () {
+ return current_user_can('upload_files') || current_user_can('manage_options');
+ },
+ ]);
+ }
+
+ /**
+ * Permission callback for upload helper routes.
+ *
+ * Allows users with either `upload_files` or `manage_options` capability.
+ * `upload_files` is granted to Author+ roles by default.
+ */
+ public function require_upload_capability() {
+ return current_user_can('upload_files') || current_user_can('manage_options');
+ }
+
+ /**
+ * Get plugin status endpoint
+ */
+ public function get_status() {
+ $rank_math_active = $this->is_rank_math_active();
+ $yoast_active = $this->is_yoast_active();
+
+ $response = [
+ 'plugin' => 'Airano MCP Bridge',
+ 'version' => self::VERSION,
+ 'active' => true,
+ 'capabilities' => [
+ 'seo_meta' => true,
+ 'upload_limits' => true,
+ 'upload_chunk' => true,
+ 'capabilities' => true,
+ 'bulk_meta' => true,
+ 'export' => true,
+ 'cache_purge' => true,
+ 'transient_flush' => true,
+ 'site_health' => true,
+ 'audit_hook' => true,
+ 'regenerate_thumbnails' => true,
+ 'upload_and_attach' => true,
+ ],
+ 'seo_plugins' => [
+ 'rank_math' => [
+ 'active' => $rank_math_active,
+ 'version' => $rank_math_active && defined('RANK_MATH_VERSION') ? RANK_MATH_VERSION : null
+ ],
+ 'yoast' => [
+ 'active' => $yoast_active,
+ 'version' => $yoast_active && defined('WPSEO_VERSION') ? WPSEO_VERSION : null
+ ]
+ ],
+ 'supported_post_types' => $this->supported_post_types,
+ 'message' => $this->get_status_message($rank_math_active, $yoast_active)
+ ];
+
+ return rest_ensure_response($response);
+ }
+
+ /**
+ * Get status message based on active plugins
+ */
+ private function get_status_message($rank_math_active, $yoast_active) {
+ if (!$rank_math_active && !$yoast_active) {
+ return 'No SEO plugin detected. Please install and activate Rank Math SEO or Yoast SEO.';
+ }
+
+ $active_plugins = [];
+ if ($rank_math_active) $active_plugins[] = 'Rank Math SEO';
+ if ($yoast_active) $active_plugins[] = 'Yoast SEO';
+
+ return 'Airano MCP SEO Meta Bridge is active and working with ' . implode(' and ', $active_plugins) . '.';
+ }
+
+ /**
+ * Register all meta fields for REST API
+ */
+ public function register_meta_fields() {
+ // Register Rank Math fields if plugin is active
+ if ($this->is_rank_math_active()) {
+ $this->register_rank_math_fields();
+ }
+
+ // Register Yoast SEO fields if plugin is active
+ if ($this->is_yoast_active()) {
+ $this->register_yoast_fields();
+ }
+ }
+
+ /**
+ * Check if Rank Math is active
+ */
+ private function is_rank_math_active() {
+ return defined('RANK_MATH_VERSION') || class_exists('RankMath');
+ }
+
+ /**
+ * Check if Yoast SEO is active
+ */
+ private function is_yoast_active() {
+ return defined('WPSEO_VERSION') || class_exists('WPSEO_Options');
+ }
+
+ /**
+ * Check if WooCommerce is active
+ */
+ private function is_woocommerce_active() {
+ return class_exists('WooCommerce') || defined('WC_VERSION');
+ }
+
+ /**
+ * Register Rank Math meta fields
+ */
+ private function register_rank_math_fields() {
+ $rank_math_fields = [
+ // Core SEO Fields
+ 'rank_math_focus_keyword' => [
+ 'type' => 'string',
+ 'description' => 'Rank Math focus keyword',
+ 'single' => true,
+ ],
+ 'rank_math_title' => [
+ 'type' => 'string',
+ 'description' => 'Rank Math SEO title (meta title)',
+ 'single' => true,
+ ],
+ 'rank_math_description' => [
+ 'type' => 'string',
+ 'description' => 'Rank Math meta description',
+ 'single' => true,
+ ],
+
+ // Additional Keywords
+ 'rank_math_additional_keywords' => [
+ 'type' => 'string',
+ 'description' => 'Rank Math additional keywords (comma-separated)',
+ 'single' => true,
+ ],
+
+ // Advanced Fields
+ 'rank_math_canonical_url' => [
+ 'type' => 'string',
+ 'description' => 'Rank Math canonical URL',
+ 'single' => true,
+ ],
+ 'rank_math_robots' => [
+ 'type' => 'array',
+ 'description' => 'Rank Math robots meta (noindex, nofollow, etc.)',
+ 'single' => true,
+ ],
+ 'rank_math_breadcrumb_title' => [
+ 'type' => 'string',
+ 'description' => 'Rank Math breadcrumb title',
+ 'single' => true,
+ ],
+
+ // Open Graph Fields
+ 'rank_math_facebook_title' => [
+ 'type' => 'string',
+ 'description' => 'Rank Math Facebook Open Graph title',
+ 'single' => true,
+ ],
+ 'rank_math_facebook_description' => [
+ 'type' => 'string',
+ 'description' => 'Rank Math Facebook Open Graph description',
+ 'single' => true,
+ ],
+ 'rank_math_facebook_image' => [
+ 'type' => 'string',
+ 'description' => 'Rank Math Facebook Open Graph image URL',
+ 'single' => true,
+ ],
+ 'rank_math_facebook_image_id' => [
+ 'type' => 'integer',
+ 'description' => 'Rank Math Facebook Open Graph image ID',
+ 'single' => true,
+ ],
+
+ // Twitter Card Fields
+ 'rank_math_twitter_title' => [
+ 'type' => 'string',
+ 'description' => 'Rank Math Twitter Card title',
+ 'single' => true,
+ ],
+ 'rank_math_twitter_description' => [
+ 'type' => 'string',
+ 'description' => 'Rank Math Twitter Card description',
+ 'single' => true,
+ ],
+ 'rank_math_twitter_image' => [
+ 'type' => 'string',
+ 'description' => 'Rank Math Twitter Card image URL',
+ 'single' => true,
+ ],
+ 'rank_math_twitter_image_id' => [
+ 'type' => 'integer',
+ 'description' => 'Rank Math Twitter Card image ID',
+ 'single' => true,
+ ],
+ 'rank_math_twitter_card_type' => [
+ 'type' => 'string',
+ 'description' => 'Rank Math Twitter Card type (summary, summary_large_image)',
+ 'single' => true,
+ ],
+ ];
+
+ $this->register_fields($rank_math_fields);
+ }
+
+ /**
+ * Register Yoast SEO meta fields
+ */
+ private function register_yoast_fields() {
+ $yoast_fields = [
+ // Core SEO Fields
+ '_yoast_wpseo_focuskw' => [
+ 'type' => 'string',
+ 'description' => 'Yoast SEO focus keyword',
+ 'single' => true,
+ ],
+ '_yoast_wpseo_title' => [
+ 'type' => 'string',
+ 'description' => 'Yoast SEO title (meta title)',
+ 'single' => true,
+ ],
+ '_yoast_wpseo_metadesc' => [
+ 'type' => 'string',
+ 'description' => 'Yoast SEO meta description',
+ 'single' => true,
+ ],
+
+ // Advanced Fields
+ '_yoast_wpseo_canonical' => [
+ 'type' => 'string',
+ 'description' => 'Yoast SEO canonical URL',
+ 'single' => true,
+ ],
+ '_yoast_wpseo_meta-robots-noindex' => [
+ 'type' => 'string',
+ 'description' => 'Yoast SEO noindex setting (0 = index, 1 = noindex)',
+ 'single' => true,
+ ],
+ '_yoast_wpseo_meta-robots-nofollow' => [
+ 'type' => 'string',
+ 'description' => 'Yoast SEO nofollow setting (0 = follow, 1 = nofollow)',
+ 'single' => true,
+ ],
+ '_yoast_wpseo_bctitle' => [
+ 'type' => 'string',
+ 'description' => 'Yoast SEO breadcrumb title',
+ 'single' => true,
+ ],
+
+ // Open Graph Fields
+ '_yoast_wpseo_opengraph-title' => [
+ 'type' => 'string',
+ 'description' => 'Yoast SEO Open Graph title',
+ 'single' => true,
+ ],
+ '_yoast_wpseo_opengraph-description' => [
+ 'type' => 'string',
+ 'description' => 'Yoast SEO Open Graph description',
+ 'single' => true,
+ ],
+ '_yoast_wpseo_opengraph-image' => [
+ 'type' => 'string',
+ 'description' => 'Yoast SEO Open Graph image URL',
+ 'single' => true,
+ ],
+ '_yoast_wpseo_opengraph-image-id' => [
+ 'type' => 'integer',
+ 'description' => 'Yoast SEO Open Graph image ID',
+ 'single' => true,
+ ],
+
+ // Twitter Card Fields
+ '_yoast_wpseo_twitter-title' => [
+ 'type' => 'string',
+ 'description' => 'Yoast SEO Twitter title',
+ 'single' => true,
+ ],
+ '_yoast_wpseo_twitter-description' => [
+ 'type' => 'string',
+ 'description' => 'Yoast SEO Twitter description',
+ 'single' => true,
+ ],
+ '_yoast_wpseo_twitter-image' => [
+ 'type' => 'string',
+ 'description' => 'Yoast SEO Twitter image URL',
+ 'single' => true,
+ ],
+ '_yoast_wpseo_twitter-image-id' => [
+ 'type' => 'integer',
+ 'description' => 'Yoast SEO Twitter image ID',
+ 'single' => true,
+ ],
+ ];
+
+ $this->register_fields($yoast_fields);
+ }
+
+ /**
+ * Register fields for all supported post types
+ */
+ private function register_fields($fields) {
+ foreach ($this->supported_post_types as $post_type) {
+ foreach ($fields as $meta_key => $args) {
+ $show_in_rest = $args['type'] === 'array'
+ ? [ 'schema' => [ 'type' => 'array', 'items' => [ 'type' => 'string' ] ] ]
+ : true;
+
+ register_post_meta($post_type, $meta_key, [
+ 'show_in_rest' => $show_in_rest,
+ 'single' => $args['single'],
+ 'type' => $args['type'],
+ 'description' => $args['description'],
+ 'auth_callback' => function() {
+ return current_user_can('edit_posts');
+ }
+ ]);
+ }
+ }
+ }
+
+ /**
+ * Get SEO data for a post
+ */
+ public function get_post_seo($request) {
+ $post_id = $request['id'];
+ return $this->get_seo_data($post_id, 'post');
+ }
+
+ /**
+ * Update SEO data for a post
+ */
+ public function update_post_seo($request) {
+ $post_id = $request['id'];
+ return $this->update_seo_data($post_id, $request->get_json_params(), 'post');
+ }
+
+ /**
+ * Get SEO data for a page
+ */
+ public function get_page_seo($request) {
+ $post_id = $request['id'];
+ return $this->get_seo_data($post_id, 'page');
+ }
+
+ /**
+ * Update SEO data for a page
+ */
+ public function update_page_seo($request) {
+ $post_id = $request['id'];
+ return $this->update_seo_data($post_id, $request->get_json_params(), 'page');
+ }
+
+ /**
+ * Get SEO data for a product
+ */
+ public function get_product_seo($request) {
+ $post_id = $request['id'];
+ return $this->get_seo_data($post_id, 'product');
+ }
+
+ /**
+ * Update SEO data for a product
+ */
+ public function update_product_seo($request) {
+ $post_id = $request['id'];
+ return $this->update_seo_data($post_id, $request->get_json_params(), 'product');
+ }
+
+ /**
+ * Generic method to get SEO data
+ */
+ private function get_seo_data($post_id, $post_type) {
+ // Verify post exists and is correct type
+ $post = get_post($post_id);
+ if (!$post || $post->post_type !== $post_type) {
+ return new WP_Error(
+ 'invalid_post',
+ /* translators: %s: post type label (e.g. Post, Page, Product). */
+ sprintf( __( '%s not found', 'airano-mcp-bridge' ), ucfirst( $post_type ) ),
+ ['status' => 404]
+ );
+ }
+
+ $seo_data = [
+ 'post_id' => $post_id,
+ 'post_type' => $post_type,
+ 'post_title' => $post->post_title,
+ ];
+
+ // Detect which SEO plugin is active and get data
+ if ($this->is_rank_math_active()) {
+ $seo_data['plugin'] = 'rank_math';
+ $seo_data = array_merge($seo_data, $this->get_rank_math_data($post_id));
+ } elseif ($this->is_yoast_active()) {
+ $seo_data['plugin'] = 'yoast';
+ $seo_data = array_merge($seo_data, $this->get_yoast_data($post_id));
+ } else {
+ return new WP_Error(
+ 'no_seo_plugin',
+ __( 'No supported SEO plugin found (Rank Math or Yoast required).', 'airano-mcp-bridge' ),
+ ['status' => 500]
+ );
+ }
+
+ return rest_ensure_response($seo_data);
+ }
+
+ /**
+ * Get Rank Math SEO data
+ */
+ private function get_rank_math_data($post_id) {
+ return [
+ 'focus_keyword' => get_post_meta($post_id, 'rank_math_focus_keyword', true),
+ 'seo_title' => get_post_meta($post_id, 'rank_math_title', true),
+ 'meta_description' => get_post_meta($post_id, 'rank_math_description', true),
+ 'canonical_url' => get_post_meta($post_id, 'rank_math_canonical_url', true),
+ 'robots' => get_post_meta($post_id, 'rank_math_robots', true),
+ 'og_title' => get_post_meta($post_id, 'rank_math_facebook_title', true),
+ 'og_description' => get_post_meta($post_id, 'rank_math_facebook_description', true),
+ 'og_image' => get_post_meta($post_id, 'rank_math_facebook_image', true),
+ 'twitter_title' => get_post_meta($post_id, 'rank_math_twitter_title', true),
+ 'twitter_description' => get_post_meta($post_id, 'rank_math_twitter_description', true),
+ 'twitter_image' => get_post_meta($post_id, 'rank_math_twitter_image', true),
+ ];
+ }
+
+ /**
+ * Get Yoast SEO data
+ */
+ private function get_yoast_data($post_id) {
+ return [
+ 'focus_keyword' => get_post_meta($post_id, '_yoast_wpseo_focuskw', true),
+ 'seo_title' => get_post_meta($post_id, '_yoast_wpseo_title', true),
+ 'meta_description' => get_post_meta($post_id, '_yoast_wpseo_metadesc', true),
+ 'canonical_url' => get_post_meta($post_id, '_yoast_wpseo_canonical', true),
+ 'noindex' => get_post_meta($post_id, '_yoast_wpseo_meta-robots-noindex', true),
+ 'nofollow' => get_post_meta($post_id, '_yoast_wpseo_meta-robots-nofollow', true),
+ 'og_title' => get_post_meta($post_id, '_yoast_wpseo_opengraph-title', true),
+ 'og_description' => get_post_meta($post_id, '_yoast_wpseo_opengraph-description', true),
+ 'og_image' => get_post_meta($post_id, '_yoast_wpseo_opengraph-image', true),
+ 'twitter_title' => get_post_meta($post_id, '_yoast_wpseo_twitter-title', true),
+ 'twitter_description' => get_post_meta($post_id, '_yoast_wpseo_twitter-description', true),
+ 'twitter_image' => get_post_meta($post_id, '_yoast_wpseo_twitter-image', true),
+ ];
+ }
+
+ /**
+ * Generic method to update SEO data
+ */
+ private function update_seo_data($post_id, $params, $post_type) {
+ // Verify post exists and is correct type
+ $post = get_post($post_id);
+ if (!$post || $post->post_type !== $post_type) {
+ return new WP_Error(
+ 'invalid_post',
+ /* translators: %s: post type label (e.g. Post, Page, Product). */
+ sprintf( __( '%s not found', 'airano-mcp-bridge' ), ucfirst( $post_type ) ),
+ ['status' => 404]
+ );
+ }
+
+ $updated_fields = [];
+
+ // Update based on active SEO plugin
+ if ($this->is_rank_math_active()) {
+ $updated_fields = $this->update_rank_math_data($post_id, $params);
+ } elseif ($this->is_yoast_active()) {
+ $updated_fields = $this->update_yoast_data($post_id, $params);
+ } else {
+ return new WP_Error(
+ 'no_seo_plugin',
+ __( 'No supported SEO plugin found.', 'airano-mcp-bridge' ),
+ ['status' => 500]
+ );
+ }
+
+ return rest_ensure_response([
+ 'post_id' => $post_id,
+ 'post_type' => $post_type,
+ 'updated_fields' => $updated_fields,
+ 'message' => __( 'SEO metadata updated successfully.', 'airano-mcp-bridge' ),
+ ]);
+ }
+
+ /**
+ * Update Rank Math data
+ */
+ private function update_rank_math_data($post_id, $params) {
+ $updated = [];
+ $field_map = [
+ 'focus_keyword' => 'rank_math_focus_keyword',
+ 'seo_title' => 'rank_math_title',
+ 'meta_description' => 'rank_math_description',
+ 'canonical_url' => 'rank_math_canonical_url',
+ 'robots' => 'rank_math_robots',
+ 'og_title' => 'rank_math_facebook_title',
+ 'og_description' => 'rank_math_facebook_description',
+ 'og_image' => 'rank_math_facebook_image',
+ 'twitter_title' => 'rank_math_twitter_title',
+ 'twitter_description' => 'rank_math_twitter_description',
+ 'twitter_image' => 'rank_math_twitter_image',
+ ];
+
+ foreach ($field_map as $param_key => $meta_key) {
+ if (isset($params[$param_key])) {
+ update_post_meta($post_id, $meta_key, $params[$param_key]);
+ $updated[] = $meta_key;
+ }
+ }
+
+ return $updated;
+ }
+
+ /**
+ * Update Yoast data
+ */
+ private function update_yoast_data($post_id, $params) {
+ $updated = [];
+ $field_map = [
+ 'focus_keyword' => '_yoast_wpseo_focuskw',
+ 'seo_title' => '_yoast_wpseo_title',
+ 'meta_description' => '_yoast_wpseo_metadesc',
+ 'canonical_url' => '_yoast_wpseo_canonical',
+ 'og_title' => '_yoast_wpseo_opengraph-title',
+ 'og_description' => '_yoast_wpseo_opengraph-description',
+ 'og_image' => '_yoast_wpseo_opengraph-image',
+ 'twitter_title' => '_yoast_wpseo_twitter-title',
+ 'twitter_description' => '_yoast_wpseo_twitter-description',
+ 'twitter_image' => '_yoast_wpseo_twitter-image',
+ ];
+
+ foreach ($field_map as $param_key => $meta_key) {
+ if (isset($params[$param_key])) {
+ update_post_meta($post_id, $meta_key, $params[$param_key]);
+ $updated[] = $meta_key;
+ }
+ }
+
+ return $updated;
+ }
+
+ // ------------------------------------------------------------------
+ // F.5a.7 — Upload helper routes (airano-mcp/v1 namespace)
+ // ------------------------------------------------------------------
+
+ /**
+ * GET /airano-mcp/v1/upload-limits
+ *
+ * Returns the effective PHP + WordPress upload limits so MCPHub can
+ * decide which upload path to use (REST /wp/v2/media vs companion
+ * upload-chunk). Shape matches plugins/wordpress/handlers/media_probe.py.
+ */
+ public function get_upload_limits() {
+ $wp_max = function_exists('wp_max_upload_size') ? (int) wp_max_upload_size() : null;
+
+ $response = [
+ 'upload_max_filesize' => (string) ini_get('upload_max_filesize'),
+ 'post_max_size' => (string) ini_get('post_max_size'),
+ 'memory_limit' => (string) ini_get('memory_limit'),
+ 'max_input_time' => (string) ini_get('max_input_time'),
+ 'wp_max_upload_size' => $wp_max,
+ 'plugin_version' => self::VERSION,
+ 'companion' => true,
+ ];
+
+ return rest_ensure_response($response);
+ }
+
+ /**
+ * POST /airano-mcp/v1/upload-chunk
+ *
+ * Accepts a raw binary body (via php://input) plus Content-Type and
+ * Content-Disposition headers, writes it to the media library via
+ * wp_handle_sideload() + wp_insert_attachment() + wp_update_attachment_metadata().
+ *
+ * Returns a JSON attachment shape matching /wp/v2/media so MCPHub can
+ * reuse the same parser.
+ *
+ * Security:
+ * - Auth: application password + upload_files or manage_options capability
+ * - MIME: validated against WP's allowed list via wp_check_filetype()
+ * - Filename: sanitized via sanitize_file_name()
+ */
+ public function handle_upload_chunk($request) {
+ // Read raw body. Note: php://input is not affected by upload_max_filesize,
+ // but IS still bounded by post_max_size and memory_limit.
+ $body = $request->get_body();
+ if ($body === null || $body === '') {
+ return new WP_Error(
+ 'empty_body',
+ __( 'Request body is empty. Send raw binary file data.', 'airano-mcp-bridge' ),
+ ['status' => 400]
+ );
+ }
+
+ $content_type = $request->get_header('content_type');
+ if (empty($content_type)) {
+ $content_type = 'application/octet-stream';
+ }
+
+ // Parse Content-Disposition to extract filename.
+ $disposition = $request->get_header('content_disposition');
+ $filename = $this->parse_content_disposition_filename($disposition);
+ if (empty($filename)) {
+ // Fall back to a safe generic filename; wp_check_filetype will
+ // derive an extension from the MIME if one is mapped.
+ $ext = '';
+ $guess = wp_check_filetype('x.' . $this->mime_to_ext($content_type));
+ if (!empty($guess['ext'])) {
+ $ext = '.' . $guess['ext'];
+ }
+ $filename = 'upload-' . time() . $ext;
+ }
+ $filename = sanitize_file_name($filename);
+
+ // Validate MIME against WP's allowed types.
+ $check = wp_check_filetype($filename);
+ if (empty($check['type'])) {
+ return new WP_Error(
+ 'invalid_mime',
+ __( 'File type is not allowed by this WordPress installation.', 'airano-mcp-bridge' ),
+ ['status' => 400, 'filename' => $filename]
+ );
+ }
+
+ // Stage to tmp + hand off to wp_handle_sideload().
+ require_once ABSPATH . 'wp-admin/includes/file.php';
+ require_once ABSPATH . 'wp-admin/includes/media.php';
+ require_once ABSPATH . 'wp-admin/includes/image.php';
+
+ $tmp = wp_tempnam($filename);
+ if (!$tmp) {
+ return new WP_Error(
+ 'tmp_failed',
+ __( 'Could not create temp file.', 'airano-mcp-bridge' ),
+ ['status' => 500]
+ );
+ }
+ $written = $this->write_temp_body( $tmp, $body );
+ if ($written === false) {
+ wp_delete_file($tmp);
+ return new WP_Error(
+ 'write_failed',
+ __( 'Could not write payload to temp file.', 'airano-mcp-bridge' ),
+ ['status' => 500]
+ );
+ }
+
+ $file_array = [
+ 'name' => $filename,
+ 'type' => $content_type,
+ 'tmp_name' => $tmp,
+ 'error' => 0,
+ 'size' => filesize($tmp),
+ ];
+
+ $overrides = [
+ 'test_form' => false,
+ 'test_size' => true,
+ ];
+
+ $sideload = wp_handle_sideload($file_array, $overrides);
+ if (isset($sideload['error'])) {
+ wp_delete_file($tmp);
+ return new WP_Error(
+ 'sideload_failed',
+ (string) $sideload['error'],
+ ['status' => 400]
+ );
+ }
+
+ $attachment = [
+ 'post_mime_type' => $sideload['type'],
+ 'post_title' => sanitize_text_field(pathinfo($filename, PATHINFO_FILENAME)),
+ 'post_content' => '',
+ 'post_status' => 'inherit',
+ ];
+
+ $attach_id = wp_insert_attachment($attachment, $sideload['file']);
+ if (is_wp_error($attach_id) || !$attach_id) {
+ wp_delete_file($sideload['file']);
+ return new WP_Error(
+ 'insert_failed',
+ is_wp_error($attach_id)
+ ? $attach_id->get_error_message()
+ : __( 'wp_insert_attachment failed.', 'airano-mcp-bridge' ),
+ ['status' => 500]
+ );
+ }
+
+ $metadata = wp_generate_attachment_metadata($attach_id, $sideload['file']);
+ wp_update_attachment_metadata($attach_id, $metadata);
+
+ // Build response with same shape as /wp/v2/media so MCPHub's parser
+ // continues to work unchanged.
+ $response = $this->format_attachment_response($attach_id, $sideload, $metadata);
+ return rest_ensure_response($response);
+ }
+
+ /**
+ * POST /airano-mcp/v1/upload-and-attach
+ *
+ * F.5a.8.5: raw-body upload, then in one PHP call apply metadata and
+ * optionally attach to a post and mark as the post's featured image.
+ * Replaces 2-3 REST round-trips with one.
+ *
+ * Same raw-body semantics as /upload-chunk. Metadata comes from query
+ * params (URL-encoded):
+ * attach_to_post — positive int
+ * set_featured — "1" / "true" to mark as the post's featured image;
+ * requires attach_to_post to also be set.
+ * title — attachment title override (otherwise derived from filename)
+ * alt_text — stored as post_meta _wp_attachment_image_alt
+ * caption — stored as post_excerpt
+ * description — stored as post_content
+ *
+ * Returns the same /wp/v2/media-shaped response as upload-chunk, plus
+ * ``_upload_and_attach: { attach_to_post, set_featured, applied_meta }``
+ * so the caller can verify what actually happened.
+ */
+ public function handle_upload_and_attach($request) {
+ // --- F.X.fix #7: idempotent replay --------------------------------
+ // Clients generate a stable ``Idempotency-Key`` header per
+ // logical upload. If a client timed out mid-call and retried,
+ // we return the already-created attachment rather than
+ // producing an "-2.webp" orphan. Transient TTL 24h.
+ $idempotency_key = $request->get_header('idempotency_key');
+ if (is_string($idempotency_key) && $idempotency_key !== ''
+ && preg_match('/^[A-Za-z0-9_\-]{1,128}$/', $idempotency_key)) {
+ $cached_id = get_transient('airano_idemp_' . md5($idempotency_key));
+ if ($cached_id) {
+ $cached_post = get_post((int) $cached_id);
+ if ($cached_post && $cached_post->post_type === 'attachment') {
+ $replay = $this->format_idempotent_replay((int) $cached_id);
+ return rest_ensure_response($replay);
+ }
+ }
+ }
+
+ // --- Same raw-body upload as handle_upload_chunk -----------------
+ $body = $request->get_body();
+ if ($body === null || $body === '') {
+ return new WP_Error(
+ 'empty_body',
+ __( 'Request body is empty. Send raw binary file data.', 'airano-mcp-bridge' ),
+ ['status' => 400]
+ );
+ }
+
+ $content_type = $request->get_header('content_type');
+ if (empty($content_type)) {
+ $content_type = 'application/octet-stream';
+ }
+
+ $disposition = $request->get_header('content_disposition');
+ $filename = $this->parse_content_disposition_filename($disposition);
+ if (empty($filename)) {
+ $ext = '';
+ $guess = wp_check_filetype('x.' . $this->mime_to_ext($content_type));
+ if (!empty($guess['ext'])) {
+ $ext = '.' . $guess['ext'];
+ }
+ $filename = 'upload-' . time() . $ext;
+ }
+ $filename = sanitize_file_name($filename);
+
+ $check = wp_check_filetype($filename);
+ if (empty($check['type'])) {
+ return new WP_Error(
+ 'invalid_mime',
+ __( 'File type is not allowed by this WordPress installation.', 'airano-mcp-bridge' ),
+ ['status' => 400, 'filename' => $filename]
+ );
+ }
+
+ require_once ABSPATH . 'wp-admin/includes/file.php';
+ require_once ABSPATH . 'wp-admin/includes/media.php';
+ require_once ABSPATH . 'wp-admin/includes/image.php';
+
+ $tmp = wp_tempnam($filename);
+ if (!$tmp) {
+ return new WP_Error(
+ 'tmp_failed',
+ __( 'Could not create temp file.', 'airano-mcp-bridge' ),
+ ['status' => 500]
+ );
+ }
+ $written = $this->write_temp_body( $tmp, $body );
+ if ($written === false) {
+ wp_delete_file($tmp);
+ return new WP_Error(
+ 'write_failed',
+ __( 'Could not write payload to temp file.', 'airano-mcp-bridge' ),
+ ['status' => 500]
+ );
+ }
+
+ $file_array = [
+ 'name' => $filename,
+ 'type' => $content_type,
+ 'tmp_name' => $tmp,
+ 'error' => 0,
+ 'size' => filesize($tmp),
+ ];
+ $sideload = wp_handle_sideload($file_array, ['test_form' => false, 'test_size' => true]);
+ if (isset($sideload['error'])) {
+ wp_delete_file($tmp);
+ return new WP_Error('sideload_failed', (string) $sideload['error'], ['status' => 400]);
+ }
+
+ // --- Gather query params -----------------------------------------
+ $attach_to_post = (int) $request->get_param('attach_to_post');
+ $set_featured = filter_var(
+ $request->get_param('set_featured'),
+ FILTER_VALIDATE_BOOLEAN,
+ FILTER_NULL_ON_FAILURE
+ );
+ $title_override = $request->get_param('title');
+ $alt_text = $request->get_param('alt_text');
+ $caption = $request->get_param('caption');
+ $description = $request->get_param('description');
+
+ // set_featured requires a target post; silently drop otherwise.
+ if ($attach_to_post <= 0) {
+ $attach_to_post = 0;
+ $set_featured = false;
+ }
+
+ // Per-target permission check when attaching.
+ if ($attach_to_post > 0 && ! current_user_can('edit_post', $attach_to_post)) {
+ wp_delete_file($sideload['file']);
+ return new WP_Error(
+ 'cannot_edit_target',
+ __( 'Current user lacks edit_post on attach_to_post target.', 'airano-mcp-bridge' ),
+ ['status' => 403, 'attach_to_post' => $attach_to_post]
+ );
+ }
+
+ // --- Insert attachment with desired parent + title ---------------
+ $attachment = [
+ 'post_mime_type' => $sideload['type'],
+ 'post_title' => is_string($title_override) && $title_override !== ''
+ ? sanitize_text_field($title_override)
+ : sanitize_text_field(pathinfo($filename, PATHINFO_FILENAME)),
+ 'post_content' => is_string($description) ? wp_kses_post($description) : '',
+ 'post_excerpt' => is_string($caption) ? sanitize_text_field($caption) : '',
+ 'post_status' => 'inherit',
+ 'post_parent' => $attach_to_post,
+ ];
+
+ $attach_id = wp_insert_attachment($attachment, $sideload['file'], $attach_to_post);
+ if (is_wp_error($attach_id) || !$attach_id) {
+ wp_delete_file($sideload['file']);
+ return new WP_Error(
+ 'insert_failed',
+ is_wp_error($attach_id)
+ ? $attach_id->get_error_message()
+ : __( 'wp_insert_attachment failed.', 'airano-mcp-bridge' ),
+ ['status' => 500]
+ );
+ }
+
+ // --- Alt text (stored in post_meta, not post_excerpt) ------------
+ $applied_alt = false;
+ if (is_string($alt_text) && $alt_text !== '') {
+ update_post_meta($attach_id, '_wp_attachment_image_alt', sanitize_text_field($alt_text));
+ $applied_alt = true;
+ }
+
+ // --- Metadata rebuild (thumbnails, EXIF, etc.) -------------------
+ $metadata = wp_generate_attachment_metadata($attach_id, $sideload['file']);
+ wp_update_attachment_metadata($attach_id, $metadata);
+
+ // --- Set as featured image ---------------------------------------
+ $applied_featured = false;
+ if ($set_featured && $attach_to_post > 0) {
+ $ok = update_post_meta($attach_to_post, '_thumbnail_id', $attach_id);
+ $applied_featured = (bool) $ok;
+ }
+
+ $response = $this->format_attachment_response($attach_id, $sideload, $metadata);
+ if (is_array($response)) {
+ $response['_upload_and_attach'] = [
+ 'attach_to_post' => $attach_to_post ?: null,
+ 'set_featured' => $applied_featured,
+ 'applied_meta' => [
+ 'title' => isset($attachment['post_title']) ? $attachment['post_title'] : null,
+ 'alt_text' => $applied_alt,
+ 'caption' => is_string($caption) && $caption !== '',
+ 'description' => is_string($description) && $description !== '',
+ ],
+ ];
+ }
+
+ // F.X.fix #7: record the (idempotency_key -> attach_id) mapping
+ // so a retry within 24h gets the same id back.
+ if (is_string($idempotency_key) && $idempotency_key !== ''
+ && preg_match('/^[A-Za-z0-9_\-]{1,128}$/', $idempotency_key)) {
+ set_transient(
+ 'airano_idemp_' . md5($idempotency_key),
+ (int) $attach_id,
+ 24 * HOUR_IN_SECONDS
+ );
+ }
+
+ return rest_ensure_response($response);
+ }
+
+ /**
+ * Build a replay response for an already-created attachment.
+ *
+ * Called when the client retries with a matching Idempotency-Key.
+ * We re-derive just enough of the ``/wp/v2/media`` shape from the
+ * stored attachment so the Python side sees the same ``id``,
+ * ``source_url``, ``mime_type`` as the original response, plus an
+ * ``_idempotent_replay: true`` marker for audit logs.
+ */
+ private function format_idempotent_replay($attach_id) {
+ $post = get_post($attach_id);
+ $mime = $post ? (string) $post->post_mime_type : '';
+ $source_url = wp_get_attachment_url($attach_id);
+ return [
+ 'id' => (int) $attach_id,
+ 'date' => $post ? mysql_to_rfc3339($post->post_date) : null,
+ 'slug' => $post ? $post->post_name : '',
+ 'type' => 'attachment',
+ 'link' => get_permalink($attach_id),
+ 'title' => ['rendered' => $post ? $post->post_title : ''],
+ 'author' => $post ? (int) $post->post_author : 0,
+ 'post' => $post ? (int) $post->post_parent : 0,
+ 'mime_type' => $mime,
+ 'media_type' => $this->mime_to_media_type($mime),
+ 'source_url' => $source_url,
+ 'media_details' => wp_get_attachment_metadata($attach_id) ?: [],
+ 'companion' => true,
+ '_idempotent_replay' => true,
+ ];
+ }
+
+ /**
+ * Write a raw upload body to a temp path using WP_Filesystem.
+ *
+ * Plugin Check flags direct file_put_contents(). WP_Filesystem's
+ * put_contents() provides the wp.org-approved wrapper and normalises
+ * permissions via FS_CHMOD_FILE. The temp path is expected to be the
+ * result of wp_tempnam(), which is already in WP's upload directory.
+ *
+ * Returns the byte count on success, false on failure.
+ *
+ * @param string $tmp Absolute path created by wp_tempnam().
+ * @param string $body Raw request body bytes.
+ * @return int|false
+ */
+ private function write_temp_body( $tmp, $body ) {
+ global $wp_filesystem;
+ if ( empty( $wp_filesystem ) ) {
+ require_once ABSPATH . 'wp-admin/includes/file.php';
+ WP_Filesystem();
+ }
+ if ( empty( $wp_filesystem ) || ! is_object( $wp_filesystem ) ) {
+ return false;
+ }
+ $ok = $wp_filesystem->put_contents( $tmp, $body, FS_CHMOD_FILE );
+ if ( ! $ok ) {
+ return false;
+ }
+ return strlen( $body );
+ }
+
+ /**
+ * Extract filename from a Content-Disposition header.
+ *
+ * Supports:
+ * - filename="photo.jpg"
+ * - filename=photo.jpg
+ * - filename*=UTF-8''%D8%B9%DA%A9%D8%B3.jpg (RFC 5987)
+ *
+ * Returns null if no filename can be extracted.
+ */
+ private function parse_content_disposition_filename($disposition) {
+ if (empty($disposition)) {
+ return null;
+ }
+ // RFC 5987: filename*=charset''percent-encoded
+ if (preg_match("/filename\\*\\s*=\\s*(?:UTF-8'')?([^;]+)/i", $disposition, $m)) {
+ $val = trim($m[1], " \t\"'");
+ $decoded = rawurldecode($val);
+ if (!empty($decoded)) {
+ return $decoded;
+ }
+ }
+ // filename=... or filename="..."
+ if (preg_match('/filename\\s*=\\s*"([^"]+)"/i', $disposition, $m)) {
+ return $m[1];
+ }
+ if (preg_match('/filename\\s*=\\s*([^;]+)/i', $disposition, $m)) {
+ return trim($m[1], " \t\"'");
+ }
+ return null;
+ }
+
+ /**
+ * Best-effort MIME → extension mapping used only when Content-Disposition
+ * is missing so we can still pick a non-ambiguous filename.
+ */
+ private function mime_to_ext($mime) {
+ $map = [
+ 'image/png' => 'png',
+ 'image/jpeg' => 'jpg',
+ 'image/gif' => 'gif',
+ 'image/webp' => 'webp',
+ 'image/svg+xml' => 'svg',
+ 'application/pdf' => 'pdf',
+ 'video/mp4' => 'mp4',
+ 'audio/mpeg' => 'mp3',
+ ];
+ return isset($map[$mime]) ? $map[$mime] : 'bin';
+ }
+
+ /**
+ * Build a JSON shape matching /wp/v2/media so existing MCPHub parsers
+ * continue to work unchanged.
+ */
+ private function format_attachment_response($attach_id, $sideload, $metadata) {
+ $post = get_post($attach_id);
+ $source_url = wp_get_attachment_url($attach_id);
+ $title = $post ? $post->post_title : '';
+
+ return [
+ 'id' => (int) $attach_id,
+ 'date' => $post ? mysql_to_rfc3339($post->post_date) : null,
+ 'slug' => $post ? $post->post_name : '',
+ 'type' => 'attachment',
+ 'link' => get_permalink($attach_id),
+ 'title' => ['rendered' => $title],
+ 'author' => $post ? (int) $post->post_author : 0,
+ 'mime_type' => $sideload['type'],
+ 'media_type' => $this->mime_to_media_type($sideload['type']),
+ 'source_url' => $source_url,
+ 'media_details' => is_array($metadata) ? $metadata : [],
+ 'companion' => true,
+ ];
+ }
+
+ private function mime_to_media_type($mime) {
+ if (strpos($mime, 'image/') === 0) return 'image';
+ if (strpos($mime, 'video/') === 0) return 'video';
+ if (strpos($mime, 'audio/') === 0) return 'audio';
+ return 'file';
+ }
+
+ // ------------------------------------------------------------------
+ // F.18.1 — Capability probe (airano-mcp/v1/capabilities)
+ // ------------------------------------------------------------------
+
+ /**
+ * GET /airano-mcp/v1/capabilities
+ *
+ * Reports the effective capability set of the currently-authenticated
+ * user (i.e. the owner of the application password MCPHub is using) so
+ * MCPHub's F.7e probe can decide which tool families to expose.
+ *
+ * Response shape:
+ * {
+ * "plugin_version": "2.1.0",
+ * "companion": true,
+ * "user": {
+ * "id": int,
+ * "login": "string",
+ * "roles": ["administrator"],
+ * "capabilities": { "upload_files": true, "edit_posts": true, ... }
+ * },
+ * "features": { "rank_math": bool, "yoast": bool, "woocommerce": bool, "multisite": bool },
+ * "routes": { "capabilities": true, "upload_limits": true, ... },
+ * "wordpress": { "version": "6.5.3", "php_version": "8.1.x", "rest_enabled": true }
+ * }
+ */
+ public function get_capabilities() {
+ $user = wp_get_current_user();
+ if (!$user || !$user->ID) {
+ return new WP_Error(
+ 'not_authenticated',
+ __( 'No authenticated user found for capability probe.', 'airano-mcp-bridge' ),
+ ['status' => 401]
+ );
+ }
+
+ // Report the explicit capability set MCPHub cares about. We do NOT
+ // return the entire $user->allcaps map: we curate a stable shape so
+ // the Python side can type-check it cleanly.
+ $caps = [
+ 'upload_files' => user_can($user, 'upload_files'),
+ 'edit_posts' => user_can($user, 'edit_posts'),
+ 'publish_posts' => user_can($user, 'publish_posts'),
+ 'edit_others_posts' => user_can($user, 'edit_others_posts'),
+ 'delete_posts' => user_can($user, 'delete_posts'),
+ 'edit_pages' => user_can($user, 'edit_pages'),
+ 'publish_pages' => user_can($user, 'publish_pages'),
+ 'manage_categories' => user_can($user, 'manage_categories'),
+ 'moderate_comments' => user_can($user, 'moderate_comments'),
+ 'manage_options' => user_can($user, 'manage_options'),
+ 'edit_users' => user_can($user, 'edit_users'),
+ 'list_users' => user_can($user, 'list_users'),
+ 'manage_woocommerce'=> user_can($user, 'manage_woocommerce'),
+ 'edit_shop_orders' => user_can($user, 'edit_shop_orders'),
+ 'edit_products' => user_can($user, 'edit_products'),
+ ];
+
+ $features = [
+ 'rank_math' => $this->is_rank_math_active(),
+ 'yoast' => $this->is_yoast_active(),
+ 'woocommerce' => $this->is_woocommerce_active(),
+ 'multisite' => is_multisite(),
+ ];
+
+ // F.X.fix #10: read from the single-source route map so
+ // capabilities and site_health can never drift again.
+ $routes = self::get_route_map();
+
+ $wp_env = [
+ 'version' => get_bloginfo('version'),
+ 'php_version' => PHP_VERSION,
+ 'rest_enabled' => true,
+ ];
+
+ $response = [
+ 'plugin_version' => self::VERSION,
+ 'companion' => true,
+ 'user' => [
+ 'id' => (int) $user->ID,
+ 'login' => $user->user_login,
+ 'roles' => array_values((array) $user->roles),
+ 'capabilities' => $caps,
+ ],
+ 'features' => $features,
+ 'routes' => $routes,
+ 'wordpress'=> $wp_env,
+ ];
+
+ return rest_ensure_response($response);
+ }
+
+ // ------------------------------------------------------------------
+ // F.18.2 — Bulk meta writes (airano-mcp/v1/bulk-meta)
+ // ------------------------------------------------------------------
+
+ /**
+ * Maximum number of {post_id, meta} items accepted in a single request.
+ * Protects the site from accidental DoS through runaway batches.
+ */
+ const BULK_META_MAX_ITEMS = 500;
+
+ /**
+ * POST /airano-mcp/v1/bulk-meta
+ *
+ * Body shape:
+ * { "updates": [ { "post_id": 123, "meta": { "key": "value", ... } }, ... ] }
+ *
+ * Per item:
+ * - Skipped if post doesn't exist (status=not_found)
+ * - Skipped if current user cannot edit it (status=forbidden)
+ * - Otherwise each meta key is written via update_post_meta().
+ * - Values of null delete the meta key (delete_post_meta).
+ *
+ * Response:
+ * {
+ * "total": N, "updated": U, "failed": F, "skipped": S,
+ * "results": [ { "post_id": int, "status": "ok|forbidden|not_found|error",
+ * "updated_keys": [...], "error": "..." }, ... ]
+ * }
+ */
+ public function handle_bulk_meta($request) {
+ $params = $request->get_json_params();
+ if (!is_array($params)) {
+ return new WP_Error(
+ 'invalid_body',
+ __( 'Expected JSON body with `updates` array.', 'airano-mcp-bridge' ),
+ ['status' => 400]
+ );
+ }
+
+ // Accept either {"updates": [...]} or a bare top-level array for
+ // convenience when calling from the CLI.
+ $updates = isset($params['updates']) ? $params['updates'] : $params;
+ if (!is_array($updates) || empty($updates)) {
+ return new WP_Error(
+ 'invalid_updates',
+ __( 'No updates supplied. Send `{"updates": [{"post_id": N, "meta": {...}}, ...]}`.', 'airano-mcp-bridge' ),
+ ['status' => 400]
+ );
+ }
+
+ if (count($updates) > self::BULK_META_MAX_ITEMS) {
+ return new WP_Error(
+ 'too_many_items',
+ /* translators: %d: maximum items per bulk-meta request. */
+ sprintf( __( 'bulk-meta supports at most %d items per request.', 'airano-mcp-bridge' ), self::BULK_META_MAX_ITEMS ),
+ ['status' => 413]
+ );
+ }
+
+ $results = [];
+ $updated_count = 0;
+ $failed_count = 0;
+ $skipped_count = 0;
+
+ foreach ($updates as $idx => $item) {
+ if (!is_array($item)) {
+ $results[] = [
+ 'index' => $idx,
+ 'post_id' => null,
+ 'status' => 'error',
+ 'error' => 'invalid_item',
+ 'message' => __( 'Each update must be an object with post_id + meta.', 'airano-mcp-bridge' ),
+ ];
+ $failed_count++;
+ continue;
+ }
+
+ $post_id = isset($item['post_id']) ? (int) $item['post_id'] : 0;
+ $meta = isset($item['meta']) && is_array($item['meta']) ? $item['meta'] : null;
+
+ if ($post_id <= 0 || $meta === null) {
+ $results[] = [
+ 'index' => $idx,
+ 'post_id' => $post_id ?: null,
+ 'status' => 'error',
+ 'error' => 'invalid_item',
+ 'message' => __( 'Each item needs a positive post_id and a meta object.', 'airano-mcp-bridge' ),
+ ];
+ $failed_count++;
+ continue;
+ }
+
+ $post = get_post($post_id);
+ if (!$post) {
+ $results[] = [
+ 'index' => $idx,
+ 'post_id' => $post_id,
+ 'status' => 'not_found',
+ 'error' => 'post_not_found',
+ ];
+ $skipped_count++;
+ continue;
+ }
+
+ if (!current_user_can('edit_post', $post_id)) {
+ $results[] = [
+ 'index' => $idx,
+ 'post_id' => $post_id,
+ 'status' => 'forbidden',
+ 'error' => 'cannot_edit_post',
+ ];
+ $skipped_count++;
+ continue;
+ }
+
+ $updated_keys = [];
+ $deleted_keys = [];
+ $item_error = null;
+
+ foreach ($meta as $meta_key => $meta_value) {
+ if (!is_string($meta_key) || $meta_key === '') {
+ $item_error = 'invalid_meta_key';
+ break;
+ }
+ // Block `_` (private) keys unless the user can edit others'
+ // posts — matches WP's own guardrail for register_post_meta
+ // with non-exposed keys. Application passwords typically
+ // belong to admins so this is rarely limiting in practice.
+ if (strpos($meta_key, '_') === 0 && !current_user_can('edit_others_posts')) {
+ $item_error = 'private_meta_key_denied';
+ break;
+ }
+
+ if ($meta_value === null) {
+ delete_post_meta($post_id, $meta_key);
+ $deleted_keys[] = $meta_key;
+ } else {
+ update_post_meta($post_id, $meta_key, $meta_value);
+ $updated_keys[] = $meta_key;
+ }
+ }
+
+ if ($item_error !== null) {
+ $results[] = [
+ 'index' => $idx,
+ 'post_id' => $post_id,
+ 'status' => 'error',
+ 'error' => $item_error,
+ 'updated_keys' => $updated_keys,
+ 'deleted_keys' => $deleted_keys,
+ ];
+ $failed_count++;
+ continue;
+ }
+
+ $results[] = [
+ 'index' => $idx,
+ 'post_id' => $post_id,
+ 'status' => 'ok',
+ 'updated_keys' => $updated_keys,
+ 'deleted_keys' => $deleted_keys,
+ ];
+ $updated_count++;
+ }
+
+ return rest_ensure_response([
+ 'total' => count($updates),
+ 'updated' => $updated_count,
+ 'failed' => $failed_count,
+ 'skipped' => $skipped_count,
+ 'results' => $results,
+ ]);
+ }
+
+ // ------------------------------------------------------------------
+ // F.18.3 — Structured JSON export (airano-mcp/v1/export)
+ // ------------------------------------------------------------------
+
+ /**
+ * Maximum number of posts returned in a single export page. Larger
+ * datasets must be paginated via offset.
+ */
+ const EXPORT_MAX_LIMIT = 500;
+ const EXPORT_DEFAULT_LIMIT = 100;
+
+ /**
+ * GET /airano-mcp/v1/export
+ *
+ * Query params:
+ * post_type — comma-list, default "post" ("post,page,product,...")
+ * status — comma-list or "any", default "publish"
+ * since — ISO8601 date; only posts modified after this
+ * limit — 1..500, default 100
+ * offset — default 0
+ * include_media — bool, default true
+ * include_terms — bool, default true
+ * include_meta — bool, default true
+ *
+ * Response is a single JSON envelope (no chunking) so chunking/paging
+ * is the caller's responsibility via `offset` + `has_more` + `next_offset`.
+ */
+ public function handle_export($request) {
+ $post_types_raw = (string) ($request->get_param('post_type') ?: 'post');
+ $status_raw = (string) ($request->get_param('status') ?: 'publish');
+ $since = $request->get_param('since');
+ $limit = (int) ($request->get_param('limit') ?: self::EXPORT_DEFAULT_LIMIT);
+ $offset = (int) ($request->get_param('offset') ?: 0);
+
+ $include_media = $this->bool_param($request->get_param('include_media'), true);
+ $include_terms = $this->bool_param($request->get_param('include_terms'), true);
+ $include_meta = $this->bool_param($request->get_param('include_meta'), true);
+
+ // Normalise the input.
+ $post_types = array_values(array_filter(array_map('trim', explode(',', $post_types_raw))));
+ if (empty($post_types)) {
+ $post_types = ['post'];
+ }
+ $statuses = array_values(array_filter(array_map('trim', explode(',', $status_raw))));
+ if (empty($statuses)) {
+ $statuses = ['publish'];
+ }
+ if (in_array('any', $statuses, true)) {
+ $statuses = 'any';
+ }
+
+ if ($limit <= 0) {
+ $limit = self::EXPORT_DEFAULT_LIMIT;
+ }
+ if ($limit > self::EXPORT_MAX_LIMIT) {
+ $limit = self::EXPORT_MAX_LIMIT;
+ }
+ if ($offset < 0) {
+ $offset = 0;
+ }
+
+ // Validate each requested post_type is real + readable.
+ foreach ($post_types as $pt) {
+ if (!post_type_exists($pt)) {
+ return new WP_Error(
+ 'unknown_post_type',
+ /* translators: %s: post type slug supplied by caller. */
+ sprintf( __( 'Unknown post type: %s', 'airano-mcp-bridge' ), $pt ),
+ ['status' => 400]
+ );
+ }
+ }
+
+ $query_args = [
+ 'post_type' => $post_types,
+ 'post_status' => $statuses,
+ 'posts_per_page' => $limit,
+ 'offset' => $offset,
+ 'orderby' => 'modified',
+ 'order' => 'DESC',
+ 'no_found_rows' => false,
+ 'suppress_filters' => false,
+ ];
+
+ // Validate and translate `since` to a date_query.
+ if ($since) {
+ $ts = strtotime((string) $since);
+ if (!$ts) {
+ return new WP_Error(
+ 'invalid_since',
+ __( '`since` must be a parseable ISO8601 / strtotime date.', 'airano-mcp-bridge' ),
+ ['status' => 400]
+ );
+ }
+ $query_args['date_query'] = [
+ [
+ 'column' => 'post_modified_gmt',
+ 'after' => gmdate('Y-m-d H:i:s', $ts),
+ 'inclusive' => false,
+ ],
+ ];
+ }
+
+ $q = new WP_Query($query_args);
+ $posts = [];
+ $media_ids = [];
+
+ foreach ($q->posts as $post) {
+ $item = [
+ 'id' => (int) $post->ID,
+ 'post_type' => $post->post_type,
+ 'status' => $post->post_status,
+ 'title' => $post->post_title,
+ 'slug' => $post->post_name,
+ 'content' => $post->post_content,
+ 'excerpt' => $post->post_excerpt,
+ 'date_gmt' => mysql_to_rfc3339($post->post_date_gmt),
+ 'modified_gmt' => mysql_to_rfc3339($post->post_modified_gmt),
+ 'author_id' => (int) $post->post_author,
+ 'parent_id' => (int) $post->post_parent,
+ 'menu_order' => (int) $post->menu_order,
+ 'link' => get_permalink($post),
+ ];
+
+ $featured = (int) get_post_thumbnail_id($post->ID);
+ $item['featured_media_id'] = $featured ?: null;
+ if ($featured) {
+ $media_ids[$featured] = true;
+ }
+
+ if ($include_meta) {
+ // get_post_meta with no key returns all meta — keys are arrays
+ // because meta is multi-value; we flatten single-value keys for
+ // ergonomics but preserve arrays when > 1.
+ $raw_meta = get_post_meta($post->ID);
+ $flat = [];
+ if (is_array($raw_meta)) {
+ foreach ($raw_meta as $mk => $mv) {
+ if (is_array($mv) && count($mv) === 1) {
+ $flat[$mk] = maybe_unserialize($mv[0]);
+ } else {
+ $flat[$mk] = array_map('maybe_unserialize', (array) $mv);
+ }
+ }
+ }
+ $item['meta'] = $flat;
+ }
+
+ if ($include_terms) {
+ $taxonomies = get_object_taxonomies($post->post_type, 'objects');
+ $terms_out = [];
+ foreach ($taxonomies as $tx_name => $tx_obj) {
+ if (!$tx_obj->show_in_rest && !$tx_obj->public) {
+ continue;
+ }
+ $terms = wp_get_post_terms($post->ID, $tx_name, ['fields' => 'all']);
+ if (is_wp_error($terms) || empty($terms)) {
+ continue;
+ }
+ $terms_out[$tx_name] = array_map(function ($t) {
+ return [
+ 'id' => (int) $t->term_id,
+ 'name' => $t->name,
+ 'slug' => $t->slug,
+ ];
+ }, $terms);
+ }
+ $item['terms'] = $terms_out;
+ }
+
+ $posts[] = $item;
+ }
+
+ $media = [];
+ if ($include_media && !empty($media_ids)) {
+ $ids = array_keys($media_ids);
+ // Fetch media in a single WP_Query for efficiency.
+ $mq = new WP_Query([
+ 'post_type' => 'attachment',
+ 'post_status' => 'inherit',
+ 'post__in' => $ids,
+ 'posts_per_page' => count($ids),
+ 'no_found_rows' => true,
+ ]);
+ foreach ($mq->posts as $m) {
+ $media[] = [
+ 'id' => (int) $m->ID,
+ 'source_url' => wp_get_attachment_url($m->ID),
+ 'mime_type' => $m->post_mime_type,
+ 'alt_text' => (string) get_post_meta($m->ID, '_wp_attachment_image_alt', true),
+ 'title' => $m->post_title,
+ ];
+ }
+ }
+
+ $total = (int) $q->found_posts;
+ $returned = count($posts);
+ $has_more = ($offset + $returned) < $total;
+
+ return rest_ensure_response([
+ 'post_types' => $post_types,
+ 'status' => is_array($statuses) ? $statuses : ['any'],
+ 'since' => $since ?: null,
+ 'limit' => $limit,
+ 'offset' => $offset,
+ 'returned' => $returned,
+ 'total_matching' => $total,
+ 'has_more' => $has_more,
+ 'next_offset' => $has_more ? $offset + $returned : null,
+ 'include_media' => $include_media,
+ 'include_terms' => $include_terms,
+ 'include_meta' => $include_meta,
+ 'posts' => $posts,
+ 'media' => $media,
+ 'exported_at_gmt' => gmdate('Y-m-d\TH:i:s\Z'),
+ 'plugin_version' => self::VERSION,
+ ]);
+ }
+
+ /**
+ * Coerce a REST query param into a bool.
+ * Accepts true/false/"true"/"false"/"1"/"0"/1/0; anything else → $default.
+ */
+ private function bool_param($v, $default) {
+ if ($v === null || $v === '') {
+ return (bool) $default;
+ }
+ if (is_bool($v)) {
+ return $v;
+ }
+ if (is_numeric($v)) {
+ return (int) $v !== 0;
+ }
+ $s = strtolower((string) $v);
+ if (in_array($s, ['true', 'yes', 'on'], true)) {
+ return true;
+ }
+ if (in_array($s, ['false', 'no', 'off'], true)) {
+ return false;
+ }
+ return (bool) $default;
+ }
+
+ // ------------------------------------------------------------------
+ // F.18.4 — Cache purge (airano-mcp/v1/cache-purge)
+ // ------------------------------------------------------------------
+
+ /**
+ * POST /airano-mcp/v1/cache-purge
+ *
+ * Auto-detects active cache plugins and invokes their "purge all"
+ * API. Always flushes the WP object cache (safe no-op when no
+ * persistent object cache is configured). Makes the Docker-socket +
+ * WP-CLI fallback unnecessary for cache maintenance on platforms
+ * that don't allow the socket mount.
+ *
+ * Response shape:
+ * {
+ * "detected": ["wp_rocket", "litespeed"],
+ * "purged": ["wp_rocket_all", "litespeed_all", "object_cache"],
+ * "skipped": [],
+ * "errors": [],
+ * "plugin_version": "2.4.0"
+ * }
+ */
+ public function handle_cache_purge($request) {
+ $detected = [];
+ $purged = [];
+ $errors = [];
+
+ // --- WP Rocket ---
+ if (function_exists('rocket_clean_domain')) {
+ $detected[] = 'wp_rocket';
+ try {
+ rocket_clean_domain();
+ if (function_exists('rocket_clean_minify')) {
+ rocket_clean_minify();
+ }
+ $purged[] = 'wp_rocket_all';
+ } catch (\Throwable $e) {
+ $errors[] = ['plugin' => 'wp_rocket', 'message' => $e->getMessage()];
+ }
+ }
+
+ // --- W3 Total Cache ---
+ if (function_exists('w3tc_flush_all')) {
+ $detected[] = 'w3_total_cache';
+ try {
+ w3tc_flush_all();
+ $purged[] = 'w3_total_cache_all';
+ } catch (\Throwable $e) {
+ $errors[] = ['plugin' => 'w3_total_cache', 'message' => $e->getMessage()];
+ }
+ }
+
+ // --- WP Super Cache ---
+ if (function_exists('wp_cache_clear_cache')) {
+ $detected[] = 'wp_super_cache';
+ try {
+ wp_cache_clear_cache();
+ $purged[] = 'wp_super_cache_all';
+ } catch (\Throwable $e) {
+ $errors[] = ['plugin' => 'wp_super_cache', 'message' => $e->getMessage()];
+ }
+ }
+
+ // --- LiteSpeed Cache ---
+ if (defined('LSCWP_V') || class_exists('\\LiteSpeed\\Purge')) {
+ $detected[] = 'litespeed';
+ try {
+ // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedHooknameFound -- third-party hook
+ do_action('litespeed_purge_all');
+ $purged[] = 'litespeed_all';
+ } catch (\Throwable $e) {
+ $errors[] = ['plugin' => 'litespeed', 'message' => $e->getMessage()];
+ }
+ }
+
+ // --- WP Fastest Cache ---
+ if (class_exists('WpFastestCache')) {
+ $detected[] = 'wp_fastest_cache';
+ try {
+ $wpfc = new \WpFastestCache();
+ if (method_exists($wpfc, 'deleteCache')) {
+ $wpfc->deleteCache(true);
+ $purged[] = 'wp_fastest_cache_all';
+ } else {
+ // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedHooknameFound -- third-party hook
+ do_action('wpfc_clear_all_cache', true);
+ $purged[] = 'wp_fastest_cache_all';
+ }
+ } catch (\Throwable $e) {
+ $errors[] = ['plugin' => 'wp_fastest_cache', 'message' => $e->getMessage()];
+ }
+ }
+
+ // --- SiteGround Optimizer ---
+ if (
+ function_exists('sg_cachepress_purge_cache')
+ || class_exists('SiteGround_Optimizer\\Supercacher\\Supercacher')
+ ) {
+ $detected[] = 'siteground_optimizer';
+ try {
+ if (function_exists('sg_cachepress_purge_cache')) {
+ sg_cachepress_purge_cache();
+ } elseif (method_exists('SiteGround_Optimizer\\Supercacher\\Supercacher', 'purge_cache')) {
+ \SiteGround_Optimizer\Supercacher\Supercacher::purge_cache();
+ }
+ $purged[] = 'siteground_optimizer_all';
+ } catch (\Throwable $e) {
+ $errors[] = ['plugin' => 'siteground_optimizer', 'message' => $e->getMessage()];
+ }
+ }
+
+ // --- Cloudflare (WP) ---
+ // CF has no canonical "purge all" helper exposed publicly; skip unless
+ // we detect the official plugin and even then we only advertise
+ // detection so the user can act.
+ if (class_exists('CF\\WordPress\\Hooks')) {
+ $detected[] = 'cloudflare';
+ // no-op — CF's purge requires the user's API token we don't have.
+ }
+
+ // --- Object cache (always) ---
+ // Safe no-op if no persistent object cache is configured.
+ if (function_exists('wp_cache_flush')) {
+ try {
+ wp_cache_flush();
+ $purged[] = 'object_cache';
+ } catch (\Throwable $e) {
+ $errors[] = ['plugin' => 'object_cache', 'message' => $e->getMessage()];
+ }
+ }
+
+ return rest_ensure_response([
+ 'detected' => array_values(array_unique($detected)),
+ 'purged' => array_values(array_unique($purged)),
+ 'skipped' => [],
+ 'errors' => $errors,
+ 'ok' => empty($errors),
+ 'plugin_version' => self::VERSION,
+ ]);
+ }
+
+ // ------------------------------------------------------------------
+ // F.18.5 — Transient flush (airano-mcp/v1/transient-flush)
+ // ------------------------------------------------------------------
+
+ /**
+ * Cap on the number of deleted-key samples returned so the response
+ * stays small even when thousands of transients are purged.
+ */
+ const TRANSIENT_SAMPLE_MAX = 100;
+
+ /**
+ * POST /airano-mcp/v1/transient-flush
+ *
+ * Body (optional):
+ * {
+ * "scope": "expired" | "all" | "pattern", // default "expired"
+ * "pattern": "foo_*", // required when scope=pattern
+ * "include_site_transients": true // default true
+ * }
+ *
+ * Response:
+ * {
+ * "ok": bool, "scope": ..., "pattern": ..., "include_site_transients": ...,
+ * "deleted_count": N,
+ * "deleted_sample": [...], // capped at TRANSIENT_SAMPLE_MAX
+ * "plugin_version": "2.5.0"
+ * }
+ */
+ public function handle_transient_flush($request) {
+ global $wpdb;
+
+ $params = $request->get_json_params();
+ if (!is_array($params)) {
+ $params = [];
+ }
+
+ $scope = isset($params['scope']) ? strtolower((string) $params['scope']) : 'expired';
+ $pattern = isset($params['pattern']) ? (string) $params['pattern'] : '';
+ $include_site = array_key_exists('include_site_transients', $params)
+ ? $this->bool_param($params['include_site_transients'], true)
+ : true;
+
+ if (!in_array($scope, ['expired', 'all', 'pattern'], true)) {
+ return new WP_Error(
+ 'invalid_scope',
+ __( '`scope` must be one of: "expired", "all", "pattern".', 'airano-mcp-bridge' ),
+ ['status' => 400]
+ );
+ }
+ if ($scope === 'pattern' && $pattern === '') {
+ return new WP_Error(
+ 'pattern_required',
+ __( '`pattern` is required when scope="pattern" (e.g. "rank_math_*").', 'airano-mcp-bridge' ),
+ ['status' => 400]
+ );
+ }
+
+ $deleted_keys = [];
+
+ if ($scope === 'expired') {
+ // Count the timeout rows that will be considered expired by
+ // delete_expired_transients() (option_value < UNIX_TIMESTAMP())
+ // so we can report a stable count.
+ $now = time();
+ // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- transient inventory has no WP API; cache flush follows below
+ $rows = $wpdb->get_col($wpdb->prepare(
+ "SELECT option_name FROM {$wpdb->options}
+ WHERE option_name LIKE %s AND option_value < %d",
+ $wpdb->esc_like('_transient_timeout_') . '%',
+ $now
+ ));
+ foreach ($rows as $row) {
+ $deleted_keys[] = preg_replace('/^_transient_timeout_/', '', $row);
+ }
+
+ if (function_exists('delete_expired_transients')) {
+ delete_expired_transients($include_site);
+ }
+ } elseif ($scope === 'all') {
+ // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- transient inventory has no WP API; cache flush follows below
+ $option_names = $wpdb->get_col($wpdb->prepare(
+ "SELECT option_name FROM {$wpdb->options}
+ WHERE option_name LIKE %s AND option_name NOT LIKE %s",
+ $wpdb->esc_like('_transient_') . '%',
+ $wpdb->esc_like('_transient_timeout_') . '%'
+ ));
+ foreach ($option_names as $name) {
+ $key = preg_replace('/^_transient_/', '', $name);
+ if (delete_transient($key)) {
+ $deleted_keys[] = $key;
+ }
+ }
+
+ if ($include_site && is_multisite()) {
+ // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- site-transient inventory has no WP API
+ $site_names = $wpdb->get_col($wpdb->prepare(
+ "SELECT meta_key FROM {$wpdb->sitemeta}
+ WHERE meta_key LIKE %s AND meta_key NOT LIKE %s",
+ $wpdb->esc_like('_site_transient_') . '%',
+ $wpdb->esc_like('_site_transient_timeout_') . '%'
+ ));
+ foreach ($site_names as $name) {
+ $key = preg_replace('/^_site_transient_/', '', $name);
+ if (delete_site_transient($key)) {
+ $deleted_keys[] = 'site:' . $key;
+ }
+ }
+ }
+ } else {
+ // pattern: convert shell-style * glob to SQL LIKE %.
+ $like = $wpdb->esc_like('_transient_') . str_replace('*', '%', $pattern);
+ // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- pattern-match transient inventory has no WP API
+ $option_names = $wpdb->get_col($wpdb->prepare(
+ "SELECT option_name FROM {$wpdb->options}
+ WHERE option_name LIKE %s AND option_name NOT LIKE %s",
+ $like,
+ $wpdb->esc_like('_transient_timeout_') . '%'
+ ));
+ foreach ($option_names as $name) {
+ $key = preg_replace('/^_transient_/', '', $name);
+ if (delete_transient($key)) {
+ $deleted_keys[] = $key;
+ }
+ }
+
+ if ($include_site && is_multisite()) {
+ $site_like = $wpdb->esc_like('_site_transient_') . str_replace('*', '%', $pattern);
+ // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- site-transient pattern inventory has no WP API
+ $site_names = $wpdb->get_col($wpdb->prepare(
+ "SELECT meta_key FROM {$wpdb->sitemeta}
+ WHERE meta_key LIKE %s AND meta_key NOT LIKE %s",
+ $site_like,
+ $wpdb->esc_like('_site_transient_timeout_') . '%'
+ ));
+ foreach ($site_names as $name) {
+ $key = preg_replace('/^_site_transient_/', '', $name);
+ if (delete_site_transient($key)) {
+ $deleted_keys[] = 'site:' . $key;
+ }
+ }
+ }
+ }
+
+ wp_cache_flush();
+
+ return rest_ensure_response([
+ 'ok' => true,
+ 'scope' => $scope,
+ 'pattern' => $scope === 'pattern' ? $pattern : null,
+ 'include_site_transients' => $include_site,
+ 'deleted_count' => count($deleted_keys),
+ 'deleted_sample' => array_slice($deleted_keys, 0, self::TRANSIENT_SAMPLE_MAX),
+ 'plugin_version' => self::VERSION,
+ ]);
+ }
+
+ // ------------------------------------------------------------------
+ // F.18.6 — Unified site health (airano-mcp/v1/site-health)
+ // ------------------------------------------------------------------
+
+ /**
+ * GET /airano-mcp/v1/site-health
+ *
+ * Single JSON envelope with everything MCPHub needs to render a
+ * "is this site healthy" dashboard. Collects in one request what
+ * otherwise requires 5+ round-trips and/or WP-CLI.
+ */
+ public function handle_site_health() {
+ global $wp_version, $wpdb;
+
+ // --- WordPress environment ----------------------------------------
+ $wp_env = [
+ 'version' => $wp_version,
+ 'multisite' => is_multisite(),
+ 'home_url' => home_url(),
+ 'site_url' => site_url(),
+ 'language' => get_locale(),
+ 'timezone' => wp_timezone_string(),
+ 'rest_enabled' => true,
+ 'application_passwords_available'=> function_exists('wp_is_application_passwords_available')
+ ? (bool) wp_is_application_passwords_available()
+ : true,
+ 'debug_mode' => defined('WP_DEBUG') && WP_DEBUG,
+ 'script_debug' => defined('SCRIPT_DEBUG') && SCRIPT_DEBUG,
+ 'savequeries' => defined('SAVEQUERIES') && SAVEQUERIES,
+ 'https_home' => strpos(home_url(), 'https://') === 0,
+ ];
+
+ // --- PHP ---------------------------------------------------------
+ $extensions = get_loaded_extensions();
+ sort($extensions);
+ $php_env = [
+ 'version' => PHP_VERSION,
+ 'memory_limit' => (string) ini_get('memory_limit'),
+ 'max_execution_time' => (string) ini_get('max_execution_time'),
+ 'upload_max_filesize' => (string) ini_get('upload_max_filesize'),
+ 'post_max_size' => (string) ini_get('post_max_size'),
+ 'max_input_vars' => (int) ini_get('max_input_vars'),
+ 'extensions' => array_values($extensions),
+ 'has_mbstring' => extension_loaded('mbstring'),
+ 'has_curl' => extension_loaded('curl'),
+ 'has_gd' => extension_loaded('gd'),
+ 'has_imagick' => extension_loaded('imagick'),
+ 'has_intl' => extension_loaded('intl'),
+ ];
+
+ // --- Server ------------------------------------------------------
+ $server_software = '';
+ if ( isset( $_SERVER['SERVER_SOFTWARE'] ) ) {
+ // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- sanitised on the next line
+ $raw_software = wp_unslash( $_SERVER['SERVER_SOFTWARE'] );
+ $server_software = sanitize_text_field( $raw_software );
+ }
+
+ $disk_free = @disk_free_space(ABSPATH);
+ $disk_total = @disk_total_space(ABSPATH);
+ $server_env = [
+ 'software' => $server_software,
+ 'disk_free_bytes' => $disk_free !== false ? (int) $disk_free : null,
+ 'disk_total_bytes' => $disk_total !== false ? (int) $disk_total : null,
+ ];
+
+ // --- MySQL / MariaDB --------------------------------------------
+ $db_version = '';
+ if (is_object($wpdb) && method_exists($wpdb, 'db_version')) {
+ $db_version = (string) $wpdb->db_version();
+ }
+ $db_env = [
+ 'version' => $db_version,
+ 'server_info' => is_object($wpdb) && isset($wpdb->dbh) && is_object($wpdb->dbh)
+ && method_exists($wpdb->dbh, 'get_server_info')
+ ? $wpdb->dbh->get_server_info()
+ : null,
+ 'charset' => (string) $wpdb->charset,
+ 'collate' => (string) $wpdb->collate,
+ 'prefix' => (string) $wpdb->prefix,
+ ];
+
+ // --- Plugins -----------------------------------------------------
+ if (!function_exists('get_plugins')) {
+ require_once ABSPATH . 'wp-admin/includes/plugin.php';
+ }
+ $all_plugins = get_plugins();
+ $active_paths = (array) get_option('active_plugins', []);
+ $active_list = [];
+ foreach ($active_paths as $path) {
+ if (isset($all_plugins[$path])) {
+ $meta = $all_plugins[$path];
+ $active_list[] = [
+ 'file' => $path,
+ 'name' => $meta['Name'] ?? '',
+ 'version' => $meta['Version'] ?? '',
+ ];
+ }
+ }
+ // MU plugins
+ $mu_plugins = function_exists('get_mu_plugins') ? get_mu_plugins() : [];
+
+ $plugins_env = [
+ 'total_count' => count($all_plugins),
+ 'active_count' => count($active_list),
+ 'active' => $active_list,
+ 'must_use_count' => count($mu_plugins),
+ ];
+
+ // --- Theme -------------------------------------------------------
+ $active_theme = wp_get_theme();
+ $parent_theme = $active_theme->parent();
+ $theme_env = [
+ 'active' => [
+ 'name' => $active_theme ? $active_theme->get('Name') : null,
+ 'version' => $active_theme ? $active_theme->get('Version') : null,
+ 'stylesheet' => $active_theme ? $active_theme->get_stylesheet() : null,
+ ],
+ 'parent' => $parent_theme
+ ? [
+ 'name' => $parent_theme->get('Name'),
+ 'version' => $parent_theme->get('Version'),
+ 'stylesheet' => $parent_theme->get_stylesheet(),
+ ]
+ : null,
+ 'total_count' => count(wp_get_themes()),
+ ];
+
+ // --- Write/permission checks ------------------------------------
+ global $wp_filesystem;
+ if ( empty( $wp_filesystem ) ) {
+ require_once ABSPATH . 'wp-admin/includes/file.php';
+ WP_Filesystem();
+ }
+ $uploads = wp_get_upload_dir();
+ $checks = [
+ 'writable_wp_content' => ! empty( $wp_filesystem )
+ ? $wp_filesystem->is_writable( WP_CONTENT_DIR )
+ : null,
+ 'writable_uploads' => is_array($uploads) && !empty($uploads['basedir']) && ! empty( $wp_filesystem )
+ ? $wp_filesystem->is_writable( $uploads['basedir'] )
+ : null,
+ 'ssl_enabled' => function_exists('is_ssl') ? is_ssl() : null,
+ ];
+
+ // --- Companion self-description ---------------------------------
+ // F.X.fix #10: read from the shared route map — capabilities
+ // and site_health must agree on what's shipping.
+ $companion = [
+ 'plugin_version' => self::VERSION,
+ 'routes' => self::get_route_map(),
+ 'features' => [
+ 'rank_math' => $this->is_rank_math_active(),
+ 'yoast' => $this->is_yoast_active(),
+ 'woocommerce' => $this->is_woocommerce_active(),
+ ],
+ ];
+
+ return rest_ensure_response([
+ 'ok' => true,
+ 'wordpress' => $wp_env,
+ 'php' => $php_env,
+ 'server' => $server_env,
+ 'database' => $db_env,
+ 'plugins' => $plugins_env,
+ 'theme' => $theme_env,
+ 'checks' => $checks,
+ 'companion' => $companion,
+ 'generated_at_gmt'=> gmdate('Y-m-d\TH:i:s\Z'),
+ 'plugin_version' => self::VERSION,
+ ]);
+ }
+
+ // ------------------------------------------------------------------
+ // F.18.7 — Audit hook (airano-mcp/v1/audit-hook)
+ // ------------------------------------------------------------------
+
+ /**
+ * GET /airano-mcp/v1/audit-hook — current config + stats.
+ * Secret is returned masked; never echoed in full.
+ */
+ public function handle_audit_hook_get() {
+ $secret = (string) get_option(self::AUDIT_OPT_SECRET, '');
+ $secret_last4 = strlen($secret) >= 4 ? substr($secret, -4) : '';
+ $events = get_option(self::AUDIT_OPT_EVENTS, self::AUDIT_DEFAULT_EVENTS);
+ if (!is_array($events)) {
+ $events = self::AUDIT_DEFAULT_EVENTS;
+ }
+ return rest_ensure_response([
+ 'enabled' => (bool) get_option(self::AUDIT_OPT_ENABLED, false),
+ 'endpoint_url' => (string) get_option(self::AUDIT_OPT_ENDPOINT, ''),
+ 'secret_set' => $secret !== '',
+ 'secret_last4' => $secret_last4,
+ 'events' => array_values($events),
+ 'last_push_gmt' => (string) get_option(self::AUDIT_OPT_LAST_PUSH_GMT, ''),
+ 'failure_count' => (int) get_option(self::AUDIT_OPT_FAILURE_COUNT, 0),
+ 'last_error' => (string) get_option(self::AUDIT_OPT_LAST_ERROR, ''),
+ 'plugin_version' => self::VERSION,
+ ]);
+ }
+
+ /**
+ * POST /airano-mcp/v1/audit-hook — upsert config.
+ * Body: {endpoint_url, secret, enabled, events}
+ */
+ public function handle_audit_hook_set($request) {
+ $params = $request->get_json_params();
+ if (!is_array($params)) {
+ return new WP_Error(
+ 'invalid_body',
+ __( 'JSON body required.', 'airano-mcp-bridge' ),
+ ['status' => 400]
+ );
+ }
+
+ if (isset($params['endpoint_url'])) {
+ $raw_url = (string) $params['endpoint_url'];
+ $url = esc_url_raw( $raw_url, [ 'http', 'https' ] );
+ if ($url === '' && $raw_url !== '') {
+ return new WP_Error(
+ 'invalid_url',
+ __( 'endpoint_url must be a valid http:// or https:// URL.', 'airano-mcp-bridge' ),
+ ['status' => 400]
+ );
+ }
+ // Defence-in-depth: also reject schemes esc_url_raw accepts but
+ // we never want here (e.g. mailto:, javascript:, data:).
+ if ( $url !== '' ) {
+ $scheme = wp_parse_url( $url, PHP_URL_SCHEME );
+ if ( ! in_array( $scheme, [ 'http', 'https' ], true ) ) {
+ return new WP_Error(
+ 'invalid_url_scheme',
+ __( 'endpoint_url scheme must be http or https.', 'airano-mcp-bridge' ),
+ ['status' => 400]
+ );
+ }
+ }
+ update_option(self::AUDIT_OPT_ENDPOINT, $url, false);
+ }
+
+ if (isset($params['secret'])) {
+ $secret = (string) $params['secret'];
+ if (strlen($secret) > 0 && strlen($secret) < 16) {
+ return new WP_Error(
+ 'secret_too_short',
+ __( 'Shared secret must be at least 16 characters (use a random token).', 'airano-mcp-bridge' ),
+ ['status' => 400]
+ );
+ }
+ update_option(self::AUDIT_OPT_SECRET, $secret, false);
+ }
+
+ if (array_key_exists('enabled', $params)) {
+ update_option(
+ self::AUDIT_OPT_ENABLED,
+ $this->bool_param($params['enabled'], false),
+ false
+ );
+ }
+
+ if (isset($params['events']) && is_array($params['events'])) {
+ $clean = array_values(array_unique(array_filter(array_map(
+ function ($e) { return is_string($e) ? sanitize_key($e) : null; },
+ $params['events']
+ ))));
+ if (empty($clean)) {
+ $clean = self::AUDIT_DEFAULT_EVENTS;
+ }
+ update_option(self::AUDIT_OPT_EVENTS, $clean, false);
+ }
+
+ // Reset failure counters on successful config change so operators
+ // don't see stale errors after fixing the endpoint.
+ update_option(self::AUDIT_OPT_FAILURE_COUNT, 0, false);
+ update_option(self::AUDIT_OPT_LAST_ERROR, '', false);
+
+ // Re-register hooks immediately so the new event list takes effect
+ // without requiring a page reload on the WP side.
+ $this->audit_register_hooks();
+
+ return $this->handle_audit_hook_get();
+ }
+
+ /**
+ * DELETE /airano-mcp/v1/audit-hook — disable + clear config.
+ * Deliberately does NOT leave the secret in wp_options.
+ */
+ public function handle_audit_hook_clear() {
+ delete_option(self::AUDIT_OPT_ENABLED);
+ delete_option(self::AUDIT_OPT_ENDPOINT);
+ delete_option(self::AUDIT_OPT_SECRET);
+ delete_option(self::AUDIT_OPT_EVENTS);
+ delete_option(self::AUDIT_OPT_LAST_PUSH_GMT);
+ delete_option(self::AUDIT_OPT_FAILURE_COUNT);
+ delete_option(self::AUDIT_OPT_LAST_ERROR);
+
+ return rest_ensure_response([
+ 'enabled' => false,
+ 'endpoint_url' => '',
+ 'secret_set' => false,
+ 'cleared' => true,
+ 'plugin_version' => self::VERSION,
+ ]);
+ }
+
+ // ------------------------------------------------------------------
+ // F.5a.8.2 — Regenerate attachment thumbnails
+ // ------------------------------------------------------------------
+
+ /**
+ * POST /airano-mcp/v1/regenerate-thumbnails
+ *
+ * Body shapes:
+ * { "ids": [12, 34, 56] } — regenerate specific attachments
+ * { "all": true } — batch mode starting at offset 0
+ * { "all": true, "offset": 100, "limit": 25 } — paged batch
+ *
+ * Per request cap: 50 attachments. Callers paginate via the returned
+ * `next_offset` when `has_more` is true. Only `image/*` attachments are
+ * regenerated; other types are silently skipped.
+ *
+ * Uses `wp_generate_attachment_metadata()` which rebuilds the size set
+ * registered by `add_image_size()` plus the standard WP sizes.
+ */
+ public function handle_regenerate_thumbnails( $request ) {
+ $body = $request->get_json_params();
+ if ( ! is_array( $body ) ) {
+ $body = [];
+ }
+
+ // The helper requires the core metadata functions, which are not
+ // loaded on REST requests by default.
+ require_once ABSPATH . 'wp-admin/includes/image.php';
+
+ $per_call_cap = 50;
+ $ids = [];
+ $mode = 'ids';
+ $next_offset = null;
+ $has_more = false;
+ $total = null;
+
+ if ( ! empty( $body['ids'] ) && is_array( $body['ids'] ) ) {
+ foreach ( $body['ids'] as $raw_id ) {
+ $id = (int) $raw_id;
+ if ( $id > 0 ) {
+ $ids[] = $id;
+ }
+ }
+ $ids = array_slice( array_values( array_unique( $ids ) ), 0, $per_call_cap );
+ } elseif ( ! empty( $body['all'] ) ) {
+ $mode = 'all';
+ $offset = isset( $body['offset'] ) ? max( 0, (int) $body['offset'] ) : 0;
+ $limit = isset( $body['limit'] ) ? max( 1, (int) $body['limit'] ) : $per_call_cap;
+ $limit = min( $limit, $per_call_cap );
+
+ $query = new WP_Query( [
+ 'post_type' => 'attachment',
+ 'post_status' => 'inherit',
+ 'post_mime_type' => 'image',
+ 'fields' => 'ids',
+ 'posts_per_page' => $limit,
+ 'offset' => $offset,
+ 'orderby' => 'ID',
+ 'order' => 'ASC',
+ 'no_found_rows' => false,
+ ] );
+ $ids = array_map( 'intval', $query->posts );
+ $total = (int) $query->found_posts;
+ $next_offset = $offset + count( $ids );
+ $has_more = $next_offset < $total;
+ } else {
+ return new WP_Error(
+ 'airano_mcp_invalid_request',
+ __( 'Provide either "ids" (array of attachment IDs) or "all": true.', 'airano-mcp-bridge' ),
+ [ 'status' => 400 ]
+ );
+ }
+
+ $processed = 0;
+ $skipped = [];
+ $errors = [];
+
+ foreach ( $ids as $attachment_id ) {
+ $mime = get_post_mime_type( $attachment_id );
+ if ( ! $mime || strpos( $mime, 'image/' ) !== 0 ) {
+ $skipped[] = [ 'id' => $attachment_id, 'reason' => 'not_image' ];
+ continue;
+ }
+ if ( ! current_user_can( 'edit_post', $attachment_id ) ) {
+ $errors[] = [ 'id' => $attachment_id, 'error' => 'forbidden' ];
+ continue;
+ }
+
+ $file = get_attached_file( $attachment_id );
+ if ( ! $file || ! file_exists( $file ) ) {
+ $errors[] = [ 'id' => $attachment_id, 'error' => 'file_missing' ];
+ continue;
+ }
+
+ $meta = wp_generate_attachment_metadata( $attachment_id, $file );
+ if ( is_wp_error( $meta ) ) {
+ $errors[] = [
+ 'id' => $attachment_id,
+ 'error' => $meta->get_error_code(),
+ 'message' => $meta->get_error_message(),
+ ];
+ continue;
+ }
+ if ( ! is_array( $meta ) || empty( $meta ) ) {
+ $errors[] = [ 'id' => $attachment_id, 'error' => 'empty_metadata' ];
+ continue;
+ }
+
+ wp_update_attachment_metadata( $attachment_id, $meta );
+ $processed++;
+ }
+
+ $response = [
+ 'plugin_version' => self::VERSION,
+ 'mode' => $mode,
+ 'attempted' => count( $ids ),
+ 'processed' => $processed,
+ 'skipped' => $skipped,
+ 'errors' => $errors,
+ ];
+
+ if ( 'all' === $mode ) {
+ $response['offset'] = isset( $body['offset'] ) ? (int) $body['offset'] : 0;
+ $response['limit'] = isset( $body['limit'] ) ? (int) $body['limit'] : $per_call_cap;
+ $response['has_more'] = $has_more;
+ $response['next_offset'] = $next_offset;
+ $response['total'] = $total;
+ }
+
+ return rest_ensure_response( $response );
+ }
+
+ /**
+ * Attach WordPress action hooks for every configured audit event.
+ * Called from __construct() and after handle_audit_hook_set().
+ */
+ private function audit_register_hooks() {
+ if (!get_option(self::AUDIT_OPT_ENABLED, false)) {
+ return;
+ }
+ $events = get_option(self::AUDIT_OPT_EVENTS, self::AUDIT_DEFAULT_EVENTS);
+ if (!is_array($events)) {
+ return;
+ }
+
+ foreach ($events as $event) {
+ switch ($event) {
+ case 'transition_post_status':
+ add_action('transition_post_status', [$this, 'audit_on_transition_post'], 10, 3);
+ break;
+ case 'deleted_post':
+ add_action('deleted_post', [$this, 'audit_on_deleted_post'], 10, 1);
+ break;
+ case 'user_register':
+ add_action('user_register', [$this, 'audit_on_user_register'], 10, 1);
+ break;
+ case 'profile_update':
+ add_action('profile_update', [$this, 'audit_on_profile_update'], 10, 1);
+ break;
+ case 'deleted_user':
+ add_action('deleted_user', [$this, 'audit_on_deleted_user'], 10, 1);
+ break;
+ case 'activated_plugin':
+ add_action('activated_plugin', [$this, 'audit_on_activated_plugin'], 10, 1);
+ break;
+ case 'deactivated_plugin':
+ add_action('deactivated_plugin', [$this, 'audit_on_deactivated_plugin'], 10, 1);
+ break;
+ case 'switch_theme':
+ add_action('switch_theme', [$this, 'audit_on_switch_theme'], 10, 1);
+ break;
+ }
+ }
+ }
+
+ public function audit_on_transition_post($new_status, $old_status, $post) {
+ if ($new_status === $old_status) {
+ return;
+ }
+ $this->audit_push('transition_post_status', [
+ 'post_id' => is_object($post) ? (int) $post->ID : null,
+ 'post_type' => is_object($post) ? $post->post_type : null,
+ 'title' => is_object($post) ? $post->post_title : null,
+ 'new_status' => $new_status,
+ 'old_status' => $old_status,
+ ]);
+ }
+
+ public function audit_on_deleted_post($post_id) {
+ $this->audit_push('deleted_post', ['post_id' => (int) $post_id]);
+ }
+
+ public function audit_on_user_register($user_id) {
+ $this->audit_push('user_register', ['user_id' => (int) $user_id]);
+ }
+
+ public function audit_on_profile_update($user_id) {
+ $this->audit_push('profile_update', ['user_id' => (int) $user_id]);
+ }
+
+ public function audit_on_deleted_user($user_id) {
+ $this->audit_push('deleted_user', ['user_id' => (int) $user_id]);
+ }
+
+ public function audit_on_activated_plugin($plugin) {
+ $this->audit_push('activated_plugin', ['plugin' => (string) $plugin]);
+ }
+
+ public function audit_on_deactivated_plugin($plugin) {
+ $this->audit_push('deactivated_plugin', ['plugin' => (string) $plugin]);
+ }
+
+ public function audit_on_switch_theme($new_theme) {
+ $this->audit_push('switch_theme', ['new_theme' => (string) $new_theme]);
+ }
+
+ /**
+ * Sign + POST an event to the configured MCPHub endpoint.
+ * Non-blocking (`blocking=false`) so WP admin requests stay snappy —
+ * at the cost of not being able to detect per-event delivery failures
+ * inline. Failures are instead discovered by MCPHub's own retry /
+ * missing-event analysis.
+ */
+ private function audit_push($event, $data) {
+ $endpoint = (string) get_option(self::AUDIT_OPT_ENDPOINT, '');
+ $secret = (string) get_option(self::AUDIT_OPT_SECRET, '');
+ if ($endpoint === '' || $secret === '') {
+ return;
+ }
+
+ $envelope = [
+ 'event' => (string) $event,
+ 'site_url' => site_url(),
+ 'timestamp' => gmdate('Y-m-d\TH:i:s\Z'),
+ 'user_id' => (int) get_current_user_id(),
+ 'data' => $data,
+ 'plugin_version' => self::VERSION,
+ ];
+ $body = wp_json_encode($envelope);
+ if ($body === false) {
+ return;
+ }
+
+ $signature = hash_hmac('sha256', $body, $secret);
+
+ $args = [
+ 'method' => 'POST',
+ 'timeout' => 2,
+ 'redirection' => 0,
+ 'blocking' => false,
+ 'headers' => [
+ 'Content-Type' => 'application/json',
+ 'X-Airano-MCP-Signature' => 'sha256=' . $signature,
+ 'X-Airano-MCP-Site' => site_url(),
+ 'X-Airano-MCP-Version' => self::VERSION,
+ 'User-Agent' => 'Airano-MCP-Bridge/' . self::VERSION,
+ ],
+ 'body' => $body,
+ ];
+
+ $resp = wp_remote_post($endpoint, $args);
+ // With blocking=false we get a WP_HTTP_Requests_Response-shaped stub
+ // that doesn't carry a real status; record the attempt timestamp
+ // so operators know pushes are happening at all.
+ update_option(self::AUDIT_OPT_LAST_PUSH_GMT, gmdate('Y-m-d\TH:i:s\Z'), false);
+
+ if (is_wp_error($resp)) {
+ $failures = (int) get_option(self::AUDIT_OPT_FAILURE_COUNT, 0) + 1;
+ update_option(self::AUDIT_OPT_FAILURE_COUNT, $failures, false);
+ update_option(self::AUDIT_OPT_LAST_ERROR, $resp->get_error_message(), false);
+ }
+ }
+
+ /**
+ * Display admin notices
+ */
+ public function admin_notices() {
+ // Only show notices on the Plugins page to avoid clutter on every admin page
+ $screen = get_current_screen();
+ if ( ! $screen || $screen->id !== 'plugins' ) {
+ return;
+ }
+
+ $rank_math_active = $this->is_rank_math_active();
+ $yoast_active = $this->is_yoast_active();
+ $woocommerce_active = $this->is_woocommerce_active();
+
+ if (!$rank_math_active && !$yoast_active) {
+ echo '';
+ echo '
Airano MCP Bridge: ' . esc_html__( 'Neither Rank Math SEO nor Yoast SEO is detected. SEO meta routes are idle, but upload helper routes remain active.', 'airano-mcp-bridge' ) . '
';
+ echo '
';
+ } else {
+ $active_plugins = [];
+ if ($rank_math_active) $active_plugins[] = 'Rank Math SEO';
+ if ($yoast_active) $active_plugins[] = 'Yoast SEO';
+
+ $supported_types = implode(', ', $this->supported_post_types);
+
+ echo '';
+ echo '
Airano MCP Bridge v' . esc_html( self::VERSION ) . ': ' . esc_html(
+ sprintf(
+ /* translators: %s: comma-separated list of active SEO plugin names. */
+ __( 'Successfully registered meta fields for %s.', 'airano-mcp-bridge' ),
+ implode( ' and ', $active_plugins )
+ )
+ ) . '
';
+ echo '
' . esc_html__( 'Supported post types:', 'airano-mcp-bridge' ) . ' ' . esc_html( $supported_types ) . '
';
+ echo '
' . esc_html__( 'MCP Bridge upload helper:', 'airano-mcp-bridge' ) . ' ' . esc_html__( '/wp-json/airano-mcp/v1/upload-limits + /upload-chunk (bypasses upload_max_filesize).', 'airano-mcp-bridge' ) . '
';
+
+ if ($woocommerce_active) {
+ echo '
WooCommerce: ' . esc_html__( 'Detected and supported. Product SEO fields are available via REST API.', 'airano-mcp-bridge' ) . '
';
+ }
+ echo '
';
+ }
+ }
+}
+
+// Initialize the plugin
+new SEO_API_Bridge();
diff --git a/wordpress-plugin/airano-mcp-bridge/readme.txt b/wordpress-plugin/airano-mcp-bridge/readme.txt
new file mode 100644
index 0000000..a764b89
--- /dev/null
+++ b/wordpress-plugin/airano-mcp-bridge/readme.txt
@@ -0,0 +1,180 @@
+=== Airano MCP Bridge ===
+Contributors: airano
+Tags: mcp, ai, rest-api, seo, media
+Requires at least: 5.0
+Tested up to: 6.9
+Requires PHP: 7.4
+Stable tag: 2.9.0
+License: GPLv2 or later
+License URI: https://www.gnu.org/licenses/gpl-2.0.html
+
+Companion plugin for MCP Hub. REST API routes for SEO meta and raw-binary media uploads that bypass upload_max_filesize.
+
+== Description ==
+
+**Airano MCP Bridge** is the official companion plugin for [MCP Hub](https://github.com/airano-ir/mcphub) — the AI-native management hub for WordPress, WooCommerce, and self-hosted services. It extends the WordPress REST API with dedicated routes that AI agents (Claude, ChatGPT, Cursor, VS Code Copilot) and the MCP Hub server rely on:
+
+* **SEO meta routes** — read and write Rank Math SEO and Yoast SEO metadata for posts, pages, and WooCommerce products.
+* **Media upload helper routes** — accept raw-binary uploads via `php://input`, which bypasses the `upload_max_filesize` PHP limit (still bounded by `post_max_size`). Unlocks reliable hero-image and large-asset uploads for AI-agent workflows.
+* **Capability probe** — reports the effective PHP + WordPress upload limits so MCP Hub can pick the right upload path automatically.
+
+**Features:**
+
+* **Rank Math SEO Support** — Full access to all Rank Math meta fields
+* **Yoast SEO Support** — Full access to all Yoast SEO meta fields
+* **WooCommerce Compatible** — Works with product post types
+* **MCP Hub media pipeline** — bypasses `upload_max_filesize` for AI-agent uploads
+* **Secure** — Requires WordPress Application Password + capability checks on every route
+* **Auto-Detection** — Automatically detects active SEO plugin and advertises capabilities
+* **Zero Configuration** — Works out of the box after activation
+
+**REST API Endpoints:**
+
+*SEO meta (namespace `airano-mcp-bridge/v1`)*
+
+* `GET/POST /wp-json/airano-mcp-bridge/v1/posts/{id}/seo` — Post SEO data
+* `GET/POST /wp-json/airano-mcp-bridge/v1/pages/{id}/seo` — Page SEO data
+* `GET/POST /wp-json/airano-mcp-bridge/v1/products/{id}/seo` — Product SEO data (WooCommerce)
+* `GET /wp-json/airano-mcp-bridge/v1/status` — Plugin status + active SEO plugins
+
+*MCP Bridge helpers (namespace `airano-mcp/v1`, added in 2.0.0)*
+
+* `GET /wp-json/airano-mcp/v1/upload-limits` — Effective PHP + WP upload limits
+* `POST /wp-json/airano-mcp/v1/upload-chunk` — Raw-body media upload (bypasses `upload_max_filesize`)
+* `GET /wp-json/airano-mcp/v1/capabilities` — Effective capabilities + feature flags + available routes (added in 2.1.0)
+* `POST /wp-json/airano-mcp/v1/bulk-meta` — Batch post/product meta writes in a single HTTP round-trip (added in 2.2.0)
+* `GET /wp-json/airano-mcp/v1/export` — Structured JSON export: posts, pages, products + media + terms + meta, with post_type/status/since/limit/offset paging (added in 2.3.0)
+* `POST /wp-json/airano-mcp/v1/cache-purge` — Auto-detects active cache plugins (LiteSpeed, WP Rocket, W3TC, Super Cache, Fastest Cache, SG Optimizer) and invokes their purge API; always flushes object cache (added in 2.4.0)
+* `POST /wp-json/airano-mcp/v1/transient-flush` — Native transient cleanup with scope=expired/all/pattern (glob); optional site-transient handling on multisite (added in 2.5.0)
+* `GET /wp-json/airano-mcp/v1/site-health` — Unified site-health snapshot: PHP/MySQL/WP versions, extensions, disk free, active plugins + theme, writability checks (added in 2.6.0)
+* `GET|POST|DELETE /wp-json/airano-mcp/v1/audit-hook` — Configure + query a webhook that forwards WordPress action events to MCP Hub (HMAC-SHA256 signed, non-blocking) (added in 2.7.0)
+* `POST /wp-json/airano-mcp/v1/regenerate-thumbnails` — Regenerate attachment sub-sizes via `wp_generate_attachment_metadata` for a list of IDs or in paged batch mode (added in 2.8.0)
+* `POST /wp-json/airano-mcp/v1/upload-and-attach` — Raw-body upload + metadata + attach-to-post + set-featured in a single REST call (added in 2.9.0). Query params: `attach_to_post`, `set_featured`, `title`, `alt_text`, `caption`, `description`. Permission: `upload_files` + `edit_post` on the target post.
+
+**Designed for [MCP Hub](https://github.com/airano-ir/mcphub)** — the AI-native management hub for WordPress and self-hosted services.
+
+== Installation ==
+
+1. Upload the `airano-mcp-bridge` folder to `/wp-content/plugins/`
+2. Activate the plugin through the 'Plugins' menu in WordPress
+3. For SEO meta routes: ensure either Rank Math SEO or Yoast SEO is active
+4. The REST API endpoints are now available
+
+== Frequently Asked Questions ==
+
+= Which SEO plugins are supported? =
+
+Rank Math SEO and Yoast SEO. The plugin auto-detects which one is active.
+
+= Does it work with WooCommerce? =
+
+Yes. Product SEO endpoints are available when WooCommerce is active.
+
+= How is authentication handled? =
+
+All endpoints require WordPress Application Password authentication. SEO routes require `edit_posts`. Upload helper routes require `upload_files` or `manage_options`.
+
+= Do I need MCP Hub to use the upload helper routes? =
+
+No. The `/airano-mcp/v1/upload-chunk` route is a standard authenticated REST endpoint and can be used from any client that can send a raw-binary `POST` with `Content-Disposition` and `Content-Type` headers.
+
+= Is there a size cap on the upload helper? =
+
+The helper reads the request body via `php://input`, which is **not** subject to PHP's `upload_max_filesize` limit — but it is still bounded by `post_max_size` and `memory_limit`. For files larger than `post_max_size`, MCP Hub falls back to its own server-side chunked session pipeline.
+
+= Why keep the folder name "airano-mcp-bridge" even though the plugin now does more than SEO? =
+
+Plugin slugs on wordpress.org are permanent. Existing installs keep working; the display name and feature set are updated in 2.0.0.
+
+= Can I use this without MCP Hub? =
+
+Yes. All REST API endpoints work with any application that can make authenticated HTTP requests.
+
+== Changelog ==
+
+= 2.9.0 =
+* Added `POST /wp-json/airano-mcp/v1/upload-and-attach` — same raw-body semantics as `/upload-chunk`, but accepts query params (`attach_to_post`, `set_featured`, `title`, `alt_text`, `caption`, `description`) and applies them in one PHP round-trip. Saves 2-3 REST calls per upload. Per-target permission enforced via `current_user_can('edit_post', attach_to_post)`.
+* `status` + `capabilities` routes now advertise `upload_and_attach: true` alongside the existing capability flags.
+
+= 2.8.0 =
+* Added `POST /wp-json/airano-mcp/v1/regenerate-thumbnails` — rebuild attachment sub-sizes via `wp_generate_attachment_metadata()` after an upload or format conversion changes source pixels. Supports two modes: `{ "ids": [...] }` for targeted regeneration (up to 50 per call) and `{ "all": true, "offset": N, "limit": M }` for paged batches (max 50 per page, with `has_more` + `next_offset` for pagination). Non-image attachments are skipped; per-item permission check via `current_user_can('edit_post', $attachment_id)`. Permission: `upload_files` or `manage_options`.
+
+= 2.7.0 =
+* Added `GET|POST|DELETE /wp-json/airano-mcp/v1/audit-hook` — configure a webhook that forwards WordPress action events (post transitions, deletions, user events, plugin activations, theme switches) to MCP Hub for unified auditing. Each event is signed with HMAC-SHA256 using a shared secret and posted non-blocking via `wp_remote_post` so the admin UI stays snappy. Secret is stored in `wp_options` and never echoed back in GET responses (only the last 4 characters are returned). Permission: `manage_options`.
+
+= 2.6.0 =
+* Added `GET /wp-json/airano-mcp/v1/site-health` — single-envelope health snapshot: WordPress version/multisite/locale, PHP version + critical extensions, server software + disk free, MySQL/MariaDB version + charset, active plugins list (with versions), active + parent theme, writability checks (wp-content, uploads) and SSL status. Replaces 5+ separate REST calls with a single request. Permission: `manage_options`.
+
+= 2.5.0 =
+* Added `POST /wp-json/airano-mcp/v1/transient-flush` — native PHP transient cleanup. Scopes: `expired` (default, calls `delete_expired_transients()`), `all` (delete every `_transient_%` row), or `pattern` (glob match, e.g. `rank_math_*`). Optional `include_site_transients` for multisite. Response includes `deleted_count` + capped `deleted_sample` for observability. Permission: `manage_options`.
+
+= 2.4.0 =
+* Added `POST /wp-json/airano-mcp/v1/cache-purge` — auto-detects active page-cache plugins (LiteSpeed Cache, WP Rocket, W3 Total Cache, WP Super Cache, WP Fastest Cache, SiteGround Optimizer) and triggers their purge API. Always calls `wp_cache_flush()` for object caches. Permission: `manage_options`. Removes the need for Docker-socket + WP-CLI to flush caches on managed hosts.
+
+= 2.3.0 =
+* Added `GET /wp-json/airano-mcp/v1/export` — structured JSON export of posts, pages, and WooCommerce products (not WXR). Query params: `post_type`, `status`, `since`, `limit` (max 500), `offset`, `include_media`, `include_terms`, `include_meta`. Response includes posts + referenced media + taxonomy terms + post meta in a single envelope, plus `has_more` / `next_offset` for pagination. Permission: `edit_posts`.
+
+= 2.2.0 =
+* Added `POST /wp-json/airano-mcp/v1/bulk-meta` — writes many `post_meta` / `product_meta` entries in a single REST request. Capped at 500 items per call; each item is permission-checked via `current_user_can('edit_post', $post_id)`. Null meta values delete the key. Status endpoint now advertises `capabilities.bulk_meta = true` and the capabilities probe reports `routes.bulk_meta = true`.
+
+= 2.1.0 =
+* Added `GET /wp-json/airano-mcp/v1/capabilities` — reports the effective capabilities of the calling application password, plus feature flags (Rank Math / Yoast / WooCommerce / multisite) and the list of companion routes this version actually ships. Consumed by MCP Hub's capability probe so per-user clients only see tools they're actually authorised to use.
+* Status endpoint now advertises `capabilities.capabilities = true`.
+
+= 2.0.0 =
+* **Rebrand**: "Airano MCP SEO Meta Bridge" → "Airano MCP Bridge". The companion plugin is no longer SEO-only.
+* Added `GET /wp-json/airano-mcp/v1/upload-limits` — returns effective PHP + WP upload limits for MCP Hub probes.
+* Added `POST /wp-json/airano-mcp/v1/upload-chunk` — raw-body upload route that bypasses `upload_max_filesize`.
+* Status endpoint now includes `capabilities: { seo_meta, upload_limits, upload_chunk }`.
+* Plugin folder / slug unchanged: `airano-mcp-bridge` (permanent on wordpress.org).
+
+= 1.3.0 =
+* Added REST API endpoints for posts, pages, and products (GET/POST operations)
+* Added authentication requirement for all endpoints
+* Fixed rank_math_title meta key registration
+
+= 1.2.0 =
+* Enhanced WooCommerce product support
+* Improved MariaDB compatibility
+
+= 1.1.0 =
+* Added status endpoint for plugin detection
+* Improved error handling
+
+= 1.0.0 =
+* Initial release with Rank Math and Yoast SEO support
+
+== Upgrade Notice ==
+
+= 2.9.0 =
+Adds `POST /airano-mcp/v1/upload-and-attach` — upload + metadata + attach + featured in one REST round-trip. No breaking changes.
+
+= 2.8.0 =
+Adds `POST /airano-mcp/v1/regenerate-thumbnails` for rebuilding attachment sub-sizes after uploads or format changes. No breaking changes.
+
+= 2.7.0 =
+Adds `GET|POST|DELETE /airano-mcp/v1/audit-hook` to forward WordPress action events to MCP Hub. Disabled until configured. No breaking changes.
+
+= 2.6.0 =
+Adds `GET /airano-mcp/v1/site-health` for a unified site-health snapshot. No breaking changes.
+
+= 2.5.0 =
+Adds `POST /airano-mcp/v1/transient-flush` for native transient cleanup. No breaking changes.
+
+= 2.4.0 =
+Adds `POST /airano-mcp/v1/cache-purge` for native cache flushing. No breaking changes.
+
+= 2.3.0 =
+Adds `GET /airano-mcp/v1/export` for offline backups and migrations. No breaking changes.
+
+= 2.2.0 =
+Adds `POST /airano-mcp/v1/bulk-meta` so MCP Hub can write many post meta values in one request. No breaking changes.
+
+= 2.1.0 =
+Adds `GET /airano-mcp/v1/capabilities` so MCP Hub can probe which tools the current application password can actually use. No breaking changes; existing routes unchanged.
+
+= 2.0.0 =
+Rebranded to "Airano MCP Bridge". Adds new `airano-mcp/v1` namespace with upload-helper routes. All existing 1.x REST endpoints keep working unchanged.
+
+= 1.3.0 =
+GET endpoints now require authentication (edit_posts capability). Update your API clients if needed.
diff --git a/wordpress-plugin/airano-mcp-seo-bridge.zip b/wordpress-plugin/airano-mcp-seo-bridge.zip
deleted file mode 100644
index 2e25bb2..0000000
Binary files a/wordpress-plugin/airano-mcp-seo-bridge.zip and /dev/null differ
diff --git a/wordpress-plugin/airano-mcp-seo-bridge/README.md b/wordpress-plugin/airano-mcp-seo-bridge/README.md
deleted file mode 100644
index 8f2d92d..0000000
--- a/wordpress-plugin/airano-mcp-seo-bridge/README.md
+++ /dev/null
@@ -1,309 +0,0 @@
-# Airano MCP SEO Bridge - WordPress Plugin
-
-**Version:** 1.3.0
-**Requires:** WordPress 5.0+, PHP 7.4+
-**License:** GPLv2 or later
-
-## Description
-
-SEO API Bridge is a comprehensive WordPress plugin that exposes Rank Math SEO and Yoast SEO meta fields via dedicated REST API endpoints. This enables MCP servers, AI agents, and other applications to read and write SEO metadata programmatically for posts, pages, and WooCommerce products.
-
-## What's New in 1.3.0
-
-- **Full REST API Endpoints** - GET and POST operations for all post types
-- **Product SEO Support** - Complete WooCommerce product SEO management
-- **Simplified Integration** - Direct API calls instead of meta field manipulation
-- **Better Error Handling** - Proper validation and error responses
-
-## Features
-
-- **Rank Math SEO Support** - Full access to all Rank Math meta fields
-- **Yoast SEO Support** - Full access to all Yoast SEO meta fields
-- **WooCommerce Compatible** - Works with product post types
-- **Secure** - Requires proper authentication and edit_posts capability
-- **Auto-Detection** - Automatically detects which SEO plugin is active
-- **Health Check Endpoint** - Dedicated API endpoint for plugin detection
-- **Zero Configuration** - Works out of the box after activation
-
-## Supported SEO Fields
-
-### Rank Math SEO
-
-| Category | Field | Description |
-|----------|-------|-------------|
-| Core | `rank_math_focus_keyword` | Focus keyword |
-| Core | `rank_math_title` | Meta title |
-| Core | `rank_math_description` | Meta description |
-| Core | `rank_math_additional_keywords` | Additional keywords |
-| Advanced | `rank_math_canonical_url` | Canonical URL |
-| Advanced | `rank_math_robots` | Robots meta directives |
-| Advanced | `rank_math_breadcrumb_title` | Breadcrumb title |
-| Open Graph | `rank_math_facebook_title` | OG title |
-| Open Graph | `rank_math_facebook_description` | OG description |
-| Open Graph | `rank_math_facebook_image` | OG image URL |
-| Open Graph | `rank_math_facebook_image_id` | OG image ID |
-| Twitter | `rank_math_twitter_title` | Twitter title |
-| Twitter | `rank_math_twitter_description` | Twitter description |
-| Twitter | `rank_math_twitter_image` | Twitter image URL |
-| Twitter | `rank_math_twitter_image_id` | Twitter image ID |
-| Twitter | `rank_math_twitter_card_type` | Card type (summary, summary_large_image) |
-
-### Yoast SEO
-
-| Category | Field | Description |
-|----------|-------|-------------|
-| Core | `_yoast_wpseo_focuskw` | Focus keyword |
-| Core | `_yoast_wpseo_title` | Meta title |
-| Core | `_yoast_wpseo_metadesc` | Meta description |
-| Advanced | `_yoast_wpseo_canonical` | Canonical URL |
-| Advanced | `_yoast_wpseo_meta-robots-noindex` | Noindex setting |
-| Advanced | `_yoast_wpseo_meta-robots-nofollow` | Nofollow setting |
-| Advanced | `_yoast_wpseo_bctitle` | Breadcrumb title |
-| Open Graph | `_yoast_wpseo_opengraph-title` | OG title |
-| Open Graph | `_yoast_wpseo_opengraph-description` | OG description |
-| Open Graph | `_yoast_wpseo_opengraph-image` | OG image URL |
-| Open Graph | `_yoast_wpseo_opengraph-image-id` | OG image ID |
-| Twitter | `_yoast_wpseo_twitter-title` | Twitter title |
-| Twitter | `_yoast_wpseo_twitter-description` | Twitter description |
-| Twitter | `_yoast_wpseo_twitter-image` | Twitter image URL |
-| Twitter | `_yoast_wpseo_twitter-image-id` | Twitter image ID |
-
-## Installation
-
-### Method 1: Upload via WordPress Admin
-
-1. Download `airano-mcp-seo-bridge.zip` from [Releases](https://github.com/airano-ir/mcphub)
-2. Go to WordPress Admin > Plugins > Add New > Upload Plugin
-3. Upload the ZIP file and click "Install Now"
-4. Click "Activate Plugin"
-
-### Method 2: FTP/SSH Upload
-
-1. Upload the `airano-mcp-seo-bridge` folder to `/wp-content/plugins/`
-2. Go to WordPress Admin > Plugins
-3. Find "Airano MCP SEO Bridge" and click "Activate"
-
-### Method 3: WP-CLI
-
-```bash
-cd /var/www/html/wp-content/plugins/
-# Copy the plugin folder here
-wp plugin activate airano-mcp-seo-bridge
-```
-
-## REST API Endpoints
-
-All endpoints require **WordPress Application Password** authentication.
-
-### Status Endpoint
-
-**GET** `/wp-json/airano-mcp-seo-bridge/v1/status`
-
-```bash
-curl -X GET "https://yoursite.com/wp-json/airano-mcp-seo-bridge/v1/status" \
- -u "username:application_password"
-```
-
-**Response:**
-```json
-{
- "plugin": "SEO API Bridge",
- "version": "1.3.0",
- "active": true,
- "seo_plugins": {
- "rank_math": { "active": true, "version": "1.0.257" },
- "yoast": { "active": false, "version": null }
- },
- "supported_post_types": ["post", "page", "product"],
- "message": "SEO API Bridge is active and working with Rank Math SEO."
-}
-```
-
-### Post SEO Endpoints
-
-**GET** `/wp-json/airano-mcp-seo-bridge/v1/posts/{id}/seo` — Get SEO metadata for a post
-
-**POST** `/wp-json/airano-mcp-seo-bridge/v1/posts/{id}/seo` — Update SEO metadata for a post
-
-```bash
-# Get post SEO
-curl -X GET "https://yoursite.com/wp-json/airano-mcp-seo-bridge/v1/posts/123/seo" \
- -u "username:application_password"
-
-# Update post SEO
-curl -X POST "https://yoursite.com/wp-json/airano-mcp-seo-bridge/v1/posts/123/seo" \
- -u "username:application_password" \
- -H "Content-Type: application/json" \
- -d '{"focus_keyword": "wordpress", "seo_title": "My Title"}'
-```
-
-### Page SEO Endpoints
-
-**GET** `/wp-json/airano-mcp-seo-bridge/v1/pages/{id}/seo`
-
-**POST** `/wp-json/airano-mcp-seo-bridge/v1/pages/{id}/seo`
-
-### Product SEO Endpoints (WooCommerce)
-
-**GET** `/wp-json/airano-mcp-seo-bridge/v1/products/{id}/seo`
-
-**POST** `/wp-json/airano-mcp-seo-bridge/v1/products/{id}/seo`
-
-```bash
-curl -X POST "https://yoursite.com/wp-json/airano-mcp-seo-bridge/v1/products/1217/seo" \
- -u "username:application_password" \
- -H "Content-Type: application/json" \
- -d '{"focus_keyword": "product keyword", "seo_title": "Product Title"}'
-```
-
-### Alternative: Standard WordPress REST API
-
-The plugin also registers all SEO meta fields on standard WordPress REST API endpoints. You can read/write SEO data directly via:
-
-- `GET/POST /wp-json/wp/v2/posts/{id}` — SEO fields in the `meta` object
-- `GET/POST /wp-json/wp/v2/pages/{id}`
-- `GET/POST /wp-json/wp/v2/products/{id}` (WooCommerce)
-
-```bash
-# Read SEO fields via standard endpoint
-curl -X GET "https://yoursite.com/wp-json/wp/v2/posts/123" \
- -u "username:application_password"
-
-# Update SEO via standard endpoint
-curl -X POST "https://yoursite.com/wp-json/wp/v2/posts/123" \
- -u "username:application_password" \
- -H "Content-Type: application/json" \
- -d '{"meta": {"rank_math_focus_keyword": "wordpress optimization"}}'
-```
-
-## Usage with MCP Hub
-
-This plugin is designed to work with [MCP Hub](https://github.com/airano-ir/mcphub).
-
-```javascript
-// Get product SEO
-const seo = await mcp.wordpress_get_product_seo({
- site: "yoursite",
- product_id: 1217
-});
-
-// Update product SEO
-await mcp.wordpress_update_product_seo({
- site: "yoursite",
- product_id: 1217,
- focus_keyword: "keyword",
- seo_title: "Title",
- meta_description: "Description"
-});
-```
-
-```python
-# Get post SEO data
-result = await mcp.call_tool(
- "wordpress_get_post_seo",
- {"site": "yoursite", "post_id": 123}
-)
-
-# Update post SEO
-result = await mcp.call_tool(
- "wordpress_update_post_seo",
- {
- "site": "yoursite",
- "post_id": 123,
- "focus_keyword": "wordpress seo",
- "seo_title": "Complete SEO Guide",
- "meta_description": "Learn WordPress SEO..."
- }
-)
-```
-
-## Verification
-
-1. **Admin Notice:** After activation, visit the Plugins page to see a status notice indicating which SEO plugin was detected.
-
-2. **Status Endpoint:**
-```bash
-curl -X GET "https://your-site.com/wp-json/airano-mcp-seo-bridge/v1/status" \
- -u "username:application_password"
-```
-
-3. **REST API Test:**
-```bash
-curl -X GET "https://your-site.com/wp-json/wp/v2/posts/1" \
- -u "username:application_password"
-```
-
-4. **MCP Health Check:** The MCP server automatically detects the plugin using the status endpoint.
-
-## Security
-
-- All meta fields require authentication via Application Passwords
-- Write access requires `edit_posts` capability
-- Read access follows WordPress post visibility rules
-- No sensitive data exposed without proper permissions
-
-## Troubleshooting
-
-### MCP Server Cannot Detect Plugin
-
-1. Upgrade to v1.1.0+ (has dedicated health check endpoint)
-2. Restart MCP server
-3. Test endpoint: `curl "https://your-site.com/wp-json/airano-mcp-seo-bridge/v1/status" -u "user:pass"`
-
-### SEO Plugin Not Detected
-
-1. Verify Rank Math or Yoast SEO is installed and activated
-2. Deactivate and reactivate SEO API Bridge
-3. Check PHP error logs for warnings
-
-### Meta Fields Not Appearing in REST API
-
-1. Ensure you're authenticated (use Application Password)
-2. Check user has `edit_posts` permission
-3. Verify the post type is supported (post, page, product)
-
-## Compatibility
-
-- WordPress 5.0+
-- PHP 7.4+
-- Rank Math SEO 1.0+
-- Yoast SEO 14.0+
-- WooCommerce 5.0+ (for product support)
-- Classic Editor & Gutenberg
-- Multisite compatible
-
-## Changelog
-
-### 1.3.0 (2025-02-18)
-- Added REST API endpoints for posts, pages, and products (GET/POST operations)
-- Added authentication requirement for all endpoints
-- Fixed `rank_math_title` meta key registration
-- Fixed output escaping for WordPress.org compliance
-
-### 1.2.0 (2025-02-15)
-- Enhanced WooCommerce product support
-- Improved MariaDB compatibility for meta field queries
-
-### 1.1.0 (2025-01-10)
-- Added health check REST API endpoint `/airano-mcp-seo-bridge/v1/status`
-- Direct plugin detection without requiring posts/products
-- Better compatibility with sites that have no content
-- Returns SEO plugin versions in status endpoint
-
-### 1.0.0 (2025-01-10)
-- Initial release
-- Rank Math SEO support (15 meta fields)
-- Yoast SEO support (15 meta fields)
-- WooCommerce product support
-- Auto-detection of active SEO plugins
-
-## Support
-
-- **Plugin issues:** [GitHub Issues](https://github.com/airano-ir/mcphub/issues)
-- **MCP Server:** [MCP Hub Documentation](https://github.com/airano-ir/mcphub)
-- **Rank Math SEO:** [Rank Math Support](https://rankmath.com/support/)
-- **Yoast SEO:** [Yoast Support](https://yoast.com/help/)
-
-## License
-
-GPLv2 or later - See [LICENSE](https://www.gnu.org/licenses/gpl-2.0.html) for details
diff --git a/wordpress-plugin/airano-mcp-seo-bridge/readme.txt b/wordpress-plugin/airano-mcp-seo-bridge/readme.txt
deleted file mode 100644
index 8867602..0000000
--- a/wordpress-plugin/airano-mcp-seo-bridge/readme.txt
+++ /dev/null
@@ -1,82 +0,0 @@
-=== Airano MCP SEO Meta Bridge ===
-Contributors: airano
-Tags: seo, rest-api, rank-math, yoast, mcp
-Requires at least: 5.0
-Tested up to: 6.9
-Requires PHP: 7.4
-Stable tag: 1.3.0
-License: GPLv2 or later
-License URI: https://www.gnu.org/licenses/gpl-2.0.html
-
-Exposes Rank Math SEO and Yoast SEO meta fields via WordPress REST API for use with MCP servers and AI agents.
-
-== Description ==
-
-Airano MCP SEO Bridge is a WordPress plugin that exposes Rank Math SEO and Yoast SEO meta fields via dedicated REST API endpoints. This enables MCP servers, AI agents, and other applications to read and write SEO metadata programmatically for posts, pages, and WooCommerce products.
-
-**Features:**
-
-* **Rank Math SEO Support** — Full access to all Rank Math meta fields
-* **Yoast SEO Support** — Full access to all Yoast SEO meta fields
-* **WooCommerce Compatible** — Works with product post types
-* **Secure** — Requires WordPress Application Password and edit_posts capability
-* **Auto-Detection** — Automatically detects which SEO plugin is active
-* **Status Endpoint** — Dedicated API endpoint for plugin and SEO detection
-* **Zero Configuration** — Works out of the box after activation
-
-**REST API Endpoints:**
-
-* `GET/POST /wp-json/airano-mcp-seo-bridge/v1/posts/{id}/seo` — Post SEO data
-* `GET/POST /wp-json/airano-mcp-seo-bridge/v1/pages/{id}/seo` — Page SEO data
-* `GET/POST /wp-json/airano-mcp-seo-bridge/v1/products/{id}/seo` — Product SEO data (WooCommerce)
-* `GET /wp-json/airano-mcp-seo-bridge/v1/status` — Plugin status and SEO detection
-
-**Designed for [MCP Hub](https://github.com/airano-ir/mcphub)** — the AI-native management hub for WordPress and self-hosted services.
-
-== Installation ==
-
-1. Upload the `airano-mcp-seo-bridge` folder to `/wp-content/plugins/`
-2. Activate the plugin through the 'Plugins' menu in WordPress
-3. Ensure either Rank Math SEO or Yoast SEO is active
-4. The REST API endpoints are now available
-
-== Frequently Asked Questions ==
-
-= Which SEO plugins are supported? =
-
-Rank Math SEO and Yoast SEO. The plugin auto-detects which one is active.
-
-= Does it work with WooCommerce? =
-
-Yes. Product SEO endpoints are available when WooCommerce is active.
-
-= How is authentication handled? =
-
-All endpoints require WordPress Application Password authentication and the `edit_posts` capability. POST requests require the same capability.
-
-= Can I use this without MCP Hub? =
-
-Yes. The REST API endpoints work with any application that can make HTTP requests with proper WordPress authentication.
-
-== Changelog ==
-
-= 1.3.0 =
-* Added REST API endpoints for posts, pages, and products (GET/POST operations)
-* Added authentication requirement for all endpoints
-* Fixed rank_math_title meta key registration
-
-= 1.2.0 =
-* Enhanced WooCommerce product support
-* Improved MariaDB compatibility
-
-= 1.1.0 =
-* Added status endpoint for plugin detection
-* Improved error handling
-
-= 1.0.0 =
-* Initial release with Rank Math and Yoast SEO support
-
-== Upgrade Notice ==
-
-= 1.3.0 =
-GET endpoints now require authentication (edit_posts capability). Update your API clients if needed.
diff --git a/wordpress-plugin/airano-mcp-seo-bridge/seo-api-bridge.php b/wordpress-plugin/airano-mcp-seo-bridge/seo-api-bridge.php
deleted file mode 100644
index b032580..0000000
--- a/wordpress-plugin/airano-mcp-seo-bridge/seo-api-bridge.php
+++ /dev/null
@@ -1,715 +0,0 @@
-is_woocommerce_active()) {
- $this->supported_post_types[] = 'product_variation'; // Also support variations
- }
- }
-
- /**
- * Register REST API routes
- */
- public function register_rest_routes() {
- // Status endpoint
- register_rest_route('airano-mcp-seo-bridge/v1', '/status', [
- 'methods' => 'GET',
- 'callback' => [$this, 'get_status'],
- 'permission_callback' => function() {
- return is_user_logged_in();
- }
- ]);
-
- // Post SEO endpoints
- register_rest_route('airano-mcp-seo-bridge/v1', '/posts/(?P\d+)/seo', [
- [
- 'methods' => 'GET',
- 'callback' => [$this, 'get_post_seo'],
- 'permission_callback' => function() {
- return current_user_can('edit_posts');
- },
- 'args' => [
- 'id' => [
- 'validate_callback' => function($param) {
- return is_numeric($param);
- }
- ]
- ]
- ],
- [
- 'methods' => 'POST',
- 'callback' => [$this, 'update_post_seo'],
- 'permission_callback' => function() {
- return current_user_can('edit_posts');
- },
- 'args' => [
- 'id' => [
- 'validate_callback' => function($param) {
- return is_numeric($param);
- }
- ]
- ]
- ]
- ]);
-
- // Page SEO endpoints
- register_rest_route('airano-mcp-seo-bridge/v1', '/pages/(?P\d+)/seo', [
- [
- 'methods' => 'GET',
- 'callback' => [$this, 'get_page_seo'],
- 'permission_callback' => function() {
- return current_user_can('edit_posts');
- },
- 'args' => [
- 'id' => [
- 'validate_callback' => function($param) {
- return is_numeric($param);
- }
- ]
- ]
- ],
- [
- 'methods' => 'POST',
- 'callback' => [$this, 'update_page_seo'],
- 'permission_callback' => function() {
- return current_user_can('edit_pages');
- },
- 'args' => [
- 'id' => [
- 'validate_callback' => function($param) {
- return is_numeric($param);
- }
- ]
- ]
- ]
- ]);
-
- // Product SEO endpoints (WooCommerce)
- register_rest_route('airano-mcp-seo-bridge/v1', '/products/(?P\d+)/seo', [
- [
- 'methods' => 'GET',
- 'callback' => [$this, 'get_product_seo'],
- 'permission_callback' => function() {
- return current_user_can('edit_posts');
- },
- 'args' => [
- 'id' => [
- 'validate_callback' => function($param) {
- return is_numeric($param);
- }
- ]
- ]
- ],
- [
- 'methods' => 'POST',
- 'callback' => [$this, 'update_product_seo'],
- 'permission_callback' => function() {
- return current_user_can('edit_posts');
- },
- 'args' => [
- 'id' => [
- 'validate_callback' => function($param) {
- return is_numeric($param);
- }
- ]
- ]
- ]
- ]);
- }
-
- /**
- * Get plugin status endpoint
- */
- public function get_status() {
- $rank_math_active = $this->is_rank_math_active();
- $yoast_active = $this->is_yoast_active();
-
- $response = [
- 'plugin' => 'Airano MCP SEO Meta Bridge',
- 'version' => self::VERSION,
- 'active' => true,
- 'seo_plugins' => [
- 'rank_math' => [
- 'active' => $rank_math_active,
- 'version' => $rank_math_active && defined('RANK_MATH_VERSION') ? RANK_MATH_VERSION : null
- ],
- 'yoast' => [
- 'active' => $yoast_active,
- 'version' => $yoast_active && defined('WPSEO_VERSION') ? WPSEO_VERSION : null
- ]
- ],
- 'supported_post_types' => $this->supported_post_types,
- 'message' => $this->get_status_message($rank_math_active, $yoast_active)
- ];
-
- return rest_ensure_response($response);
- }
-
- /**
- * Get status message based on active plugins
- */
- private function get_status_message($rank_math_active, $yoast_active) {
- if (!$rank_math_active && !$yoast_active) {
- return 'No SEO plugin detected. Please install and activate Rank Math SEO or Yoast SEO.';
- }
-
- $active_plugins = [];
- if ($rank_math_active) $active_plugins[] = 'Rank Math SEO';
- if ($yoast_active) $active_plugins[] = 'Yoast SEO';
-
- return 'Airano MCP SEO Meta Bridge is active and working with ' . implode(' and ', $active_plugins) . '.';
- }
-
- /**
- * Register all meta fields for REST API
- */
- public function register_meta_fields() {
- // Register Rank Math fields if plugin is active
- if ($this->is_rank_math_active()) {
- $this->register_rank_math_fields();
- }
-
- // Register Yoast SEO fields if plugin is active
- if ($this->is_yoast_active()) {
- $this->register_yoast_fields();
- }
- }
-
- /**
- * Check if Rank Math is active
- */
- private function is_rank_math_active() {
- return defined('RANK_MATH_VERSION') || class_exists('RankMath');
- }
-
- /**
- * Check if Yoast SEO is active
- */
- private function is_yoast_active() {
- return defined('WPSEO_VERSION') || class_exists('WPSEO_Options');
- }
-
- /**
- * Check if WooCommerce is active
- */
- private function is_woocommerce_active() {
- return class_exists('WooCommerce') || defined('WC_VERSION');
- }
-
- /**
- * Register Rank Math meta fields
- */
- private function register_rank_math_fields() {
- $rank_math_fields = [
- // Core SEO Fields
- 'rank_math_focus_keyword' => [
- 'type' => 'string',
- 'description' => 'Rank Math focus keyword',
- 'single' => true,
- ],
- 'rank_math_title' => [
- 'type' => 'string',
- 'description' => 'Rank Math SEO title (meta title)',
- 'single' => true,
- ],
- 'rank_math_description' => [
- 'type' => 'string',
- 'description' => 'Rank Math meta description',
- 'single' => true,
- ],
-
- // Additional Keywords
- 'rank_math_additional_keywords' => [
- 'type' => 'string',
- 'description' => 'Rank Math additional keywords (comma-separated)',
- 'single' => true,
- ],
-
- // Advanced Fields
- 'rank_math_canonical_url' => [
- 'type' => 'string',
- 'description' => 'Rank Math canonical URL',
- 'single' => true,
- ],
- 'rank_math_robots' => [
- 'type' => 'array',
- 'description' => 'Rank Math robots meta (noindex, nofollow, etc.)',
- 'single' => true,
- ],
- 'rank_math_breadcrumb_title' => [
- 'type' => 'string',
- 'description' => 'Rank Math breadcrumb title',
- 'single' => true,
- ],
-
- // Open Graph Fields
- 'rank_math_facebook_title' => [
- 'type' => 'string',
- 'description' => 'Rank Math Facebook Open Graph title',
- 'single' => true,
- ],
- 'rank_math_facebook_description' => [
- 'type' => 'string',
- 'description' => 'Rank Math Facebook Open Graph description',
- 'single' => true,
- ],
- 'rank_math_facebook_image' => [
- 'type' => 'string',
- 'description' => 'Rank Math Facebook Open Graph image URL',
- 'single' => true,
- ],
- 'rank_math_facebook_image_id' => [
- 'type' => 'integer',
- 'description' => 'Rank Math Facebook Open Graph image ID',
- 'single' => true,
- ],
-
- // Twitter Card Fields
- 'rank_math_twitter_title' => [
- 'type' => 'string',
- 'description' => 'Rank Math Twitter Card title',
- 'single' => true,
- ],
- 'rank_math_twitter_description' => [
- 'type' => 'string',
- 'description' => 'Rank Math Twitter Card description',
- 'single' => true,
- ],
- 'rank_math_twitter_image' => [
- 'type' => 'string',
- 'description' => 'Rank Math Twitter Card image URL',
- 'single' => true,
- ],
- 'rank_math_twitter_image_id' => [
- 'type' => 'integer',
- 'description' => 'Rank Math Twitter Card image ID',
- 'single' => true,
- ],
- 'rank_math_twitter_card_type' => [
- 'type' => 'string',
- 'description' => 'Rank Math Twitter Card type (summary, summary_large_image)',
- 'single' => true,
- ],
- ];
-
- $this->register_fields($rank_math_fields);
- }
-
- /**
- * Register Yoast SEO meta fields
- */
- private function register_yoast_fields() {
- $yoast_fields = [
- // Core SEO Fields
- '_yoast_wpseo_focuskw' => [
- 'type' => 'string',
- 'description' => 'Yoast SEO focus keyword',
- 'single' => true,
- ],
- '_yoast_wpseo_title' => [
- 'type' => 'string',
- 'description' => 'Yoast SEO title (meta title)',
- 'single' => true,
- ],
- '_yoast_wpseo_metadesc' => [
- 'type' => 'string',
- 'description' => 'Yoast SEO meta description',
- 'single' => true,
- ],
-
- // Advanced Fields
- '_yoast_wpseo_canonical' => [
- 'type' => 'string',
- 'description' => 'Yoast SEO canonical URL',
- 'single' => true,
- ],
- '_yoast_wpseo_meta-robots-noindex' => [
- 'type' => 'string',
- 'description' => 'Yoast SEO noindex setting (0 = index, 1 = noindex)',
- 'single' => true,
- ],
- '_yoast_wpseo_meta-robots-nofollow' => [
- 'type' => 'string',
- 'description' => 'Yoast SEO nofollow setting (0 = follow, 1 = nofollow)',
- 'single' => true,
- ],
- '_yoast_wpseo_bctitle' => [
- 'type' => 'string',
- 'description' => 'Yoast SEO breadcrumb title',
- 'single' => true,
- ],
-
- // Open Graph Fields
- '_yoast_wpseo_opengraph-title' => [
- 'type' => 'string',
- 'description' => 'Yoast SEO Open Graph title',
- 'single' => true,
- ],
- '_yoast_wpseo_opengraph-description' => [
- 'type' => 'string',
- 'description' => 'Yoast SEO Open Graph description',
- 'single' => true,
- ],
- '_yoast_wpseo_opengraph-image' => [
- 'type' => 'string',
- 'description' => 'Yoast SEO Open Graph image URL',
- 'single' => true,
- ],
- '_yoast_wpseo_opengraph-image-id' => [
- 'type' => 'integer',
- 'description' => 'Yoast SEO Open Graph image ID',
- 'single' => true,
- ],
-
- // Twitter Card Fields
- '_yoast_wpseo_twitter-title' => [
- 'type' => 'string',
- 'description' => 'Yoast SEO Twitter title',
- 'single' => true,
- ],
- '_yoast_wpseo_twitter-description' => [
- 'type' => 'string',
- 'description' => 'Yoast SEO Twitter description',
- 'single' => true,
- ],
- '_yoast_wpseo_twitter-image' => [
- 'type' => 'string',
- 'description' => 'Yoast SEO Twitter image URL',
- 'single' => true,
- ],
- '_yoast_wpseo_twitter-image-id' => [
- 'type' => 'integer',
- 'description' => 'Yoast SEO Twitter image ID',
- 'single' => true,
- ],
- ];
-
- $this->register_fields($yoast_fields);
- }
-
- /**
- * Register fields for all supported post types
- */
- private function register_fields($fields) {
- foreach ($this->supported_post_types as $post_type) {
- foreach ($fields as $meta_key => $args) {
- $show_in_rest = $args['type'] === 'array'
- ? [ 'schema' => [ 'type' => 'array', 'items' => [ 'type' => 'string' ] ] ]
- : true;
-
- register_post_meta($post_type, $meta_key, [
- 'show_in_rest' => $show_in_rest,
- 'single' => $args['single'],
- 'type' => $args['type'],
- 'description' => $args['description'],
- 'auth_callback' => function() {
- return current_user_can('edit_posts');
- }
- ]);
- }
- }
- }
-
- /**
- * Get SEO data for a post
- */
- public function get_post_seo($request) {
- $post_id = $request['id'];
- return $this->get_seo_data($post_id, 'post');
- }
-
- /**
- * Update SEO data for a post
- */
- public function update_post_seo($request) {
- $post_id = $request['id'];
- return $this->update_seo_data($post_id, $request->get_json_params(), 'post');
- }
-
- /**
- * Get SEO data for a page
- */
- public function get_page_seo($request) {
- $post_id = $request['id'];
- return $this->get_seo_data($post_id, 'page');
- }
-
- /**
- * Update SEO data for a page
- */
- public function update_page_seo($request) {
- $post_id = $request['id'];
- return $this->update_seo_data($post_id, $request->get_json_params(), 'page');
- }
-
- /**
- * Get SEO data for a product
- */
- public function get_product_seo($request) {
- $post_id = $request['id'];
- return $this->get_seo_data($post_id, 'product');
- }
-
- /**
- * Update SEO data for a product
- */
- public function update_product_seo($request) {
- $post_id = $request['id'];
- return $this->update_seo_data($post_id, $request->get_json_params(), 'product');
- }
-
- /**
- * Generic method to get SEO data
- */
- private function get_seo_data($post_id, $post_type) {
- // Verify post exists and is correct type
- $post = get_post($post_id);
- if (!$post || $post->post_type !== $post_type) {
- return new WP_Error(
- 'invalid_post',
- sprintf('%s not found', ucfirst($post_type)),
- ['status' => 404]
- );
- }
-
- $seo_data = [
- 'post_id' => $post_id,
- 'post_type' => $post_type,
- 'post_title' => $post->post_title,
- ];
-
- // Detect which SEO plugin is active and get data
- if ($this->is_rank_math_active()) {
- $seo_data['plugin'] = 'rank_math';
- $seo_data = array_merge($seo_data, $this->get_rank_math_data($post_id));
- } elseif ($this->is_yoast_active()) {
- $seo_data['plugin'] = 'yoast';
- $seo_data = array_merge($seo_data, $this->get_yoast_data($post_id));
- } else {
- return new WP_Error(
- 'no_seo_plugin',
- 'No supported SEO plugin found (Rank Math or Yoast required)',
- ['status' => 500]
- );
- }
-
- return rest_ensure_response($seo_data);
- }
-
- /**
- * Get Rank Math SEO data
- */
- private function get_rank_math_data($post_id) {
- return [
- 'focus_keyword' => get_post_meta($post_id, 'rank_math_focus_keyword', true),
- 'seo_title' => get_post_meta($post_id, 'rank_math_title', true),
- 'meta_description' => get_post_meta($post_id, 'rank_math_description', true),
- 'canonical_url' => get_post_meta($post_id, 'rank_math_canonical_url', true),
- 'robots' => get_post_meta($post_id, 'rank_math_robots', true),
- 'og_title' => get_post_meta($post_id, 'rank_math_facebook_title', true),
- 'og_description' => get_post_meta($post_id, 'rank_math_facebook_description', true),
- 'og_image' => get_post_meta($post_id, 'rank_math_facebook_image', true),
- 'twitter_title' => get_post_meta($post_id, 'rank_math_twitter_title', true),
- 'twitter_description' => get_post_meta($post_id, 'rank_math_twitter_description', true),
- 'twitter_image' => get_post_meta($post_id, 'rank_math_twitter_image', true),
- ];
- }
-
- /**
- * Get Yoast SEO data
- */
- private function get_yoast_data($post_id) {
- return [
- 'focus_keyword' => get_post_meta($post_id, '_yoast_wpseo_focuskw', true),
- 'seo_title' => get_post_meta($post_id, '_yoast_wpseo_title', true),
- 'meta_description' => get_post_meta($post_id, '_yoast_wpseo_metadesc', true),
- 'canonical_url' => get_post_meta($post_id, '_yoast_wpseo_canonical', true),
- 'noindex' => get_post_meta($post_id, '_yoast_wpseo_meta-robots-noindex', true),
- 'nofollow' => get_post_meta($post_id, '_yoast_wpseo_meta-robots-nofollow', true),
- 'og_title' => get_post_meta($post_id, '_yoast_wpseo_opengraph-title', true),
- 'og_description' => get_post_meta($post_id, '_yoast_wpseo_opengraph-description', true),
- 'og_image' => get_post_meta($post_id, '_yoast_wpseo_opengraph-image', true),
- 'twitter_title' => get_post_meta($post_id, '_yoast_wpseo_twitter-title', true),
- 'twitter_description' => get_post_meta($post_id, '_yoast_wpseo_twitter-description', true),
- 'twitter_image' => get_post_meta($post_id, '_yoast_wpseo_twitter-image', true),
- ];
- }
-
- /**
- * Generic method to update SEO data
- */
- private function update_seo_data($post_id, $params, $post_type) {
- // Verify post exists and is correct type
- $post = get_post($post_id);
- if (!$post || $post->post_type !== $post_type) {
- return new WP_Error(
- 'invalid_post',
- sprintf('%s not found', ucfirst($post_type)),
- ['status' => 404]
- );
- }
-
- $updated_fields = [];
-
- // Update based on active SEO plugin
- if ($this->is_rank_math_active()) {
- $updated_fields = $this->update_rank_math_data($post_id, $params);
- } elseif ($this->is_yoast_active()) {
- $updated_fields = $this->update_yoast_data($post_id, $params);
- } else {
- return new WP_Error(
- 'no_seo_plugin',
- 'No supported SEO plugin found',
- ['status' => 500]
- );
- }
-
- return rest_ensure_response([
- 'post_id' => $post_id,
- 'post_type' => $post_type,
- 'updated_fields' => $updated_fields,
- 'message' => 'SEO metadata updated successfully'
- ]);
- }
-
- /**
- * Update Rank Math data
- */
- private function update_rank_math_data($post_id, $params) {
- $updated = [];
- $field_map = [
- 'focus_keyword' => 'rank_math_focus_keyword',
- 'seo_title' => 'rank_math_title',
- 'meta_description' => 'rank_math_description',
- 'canonical_url' => 'rank_math_canonical_url',
- 'robots' => 'rank_math_robots',
- 'og_title' => 'rank_math_facebook_title',
- 'og_description' => 'rank_math_facebook_description',
- 'og_image' => 'rank_math_facebook_image',
- 'twitter_title' => 'rank_math_twitter_title',
- 'twitter_description' => 'rank_math_twitter_description',
- 'twitter_image' => 'rank_math_twitter_image',
- ];
-
- foreach ($field_map as $param_key => $meta_key) {
- if (isset($params[$param_key])) {
- update_post_meta($post_id, $meta_key, $params[$param_key]);
- $updated[] = $meta_key;
- }
- }
-
- return $updated;
- }
-
- /**
- * Update Yoast data
- */
- private function update_yoast_data($post_id, $params) {
- $updated = [];
- $field_map = [
- 'focus_keyword' => '_yoast_wpseo_focuskw',
- 'seo_title' => '_yoast_wpseo_title',
- 'meta_description' => '_yoast_wpseo_metadesc',
- 'canonical_url' => '_yoast_wpseo_canonical',
- 'og_title' => '_yoast_wpseo_opengraph-title',
- 'og_description' => '_yoast_wpseo_opengraph-description',
- 'og_image' => '_yoast_wpseo_opengraph-image',
- 'twitter_title' => '_yoast_wpseo_twitter-title',
- 'twitter_description' => '_yoast_wpseo_twitter-description',
- 'twitter_image' => '_yoast_wpseo_twitter-image',
- ];
-
- foreach ($field_map as $param_key => $meta_key) {
- if (isset($params[$param_key])) {
- update_post_meta($post_id, $meta_key, $params[$param_key]);
- $updated[] = $meta_key;
- }
- }
-
- return $updated;
- }
-
- /**
- * Display admin notices
- */
- public function admin_notices() {
- // Only show notices on the Plugins page to avoid clutter on every admin page
- $screen = get_current_screen();
- if ( ! $screen || $screen->id !== 'plugins' ) {
- return;
- }
-
- $rank_math_active = $this->is_rank_math_active();
- $yoast_active = $this->is_yoast_active();
- $woocommerce_active = $this->is_woocommerce_active();
-
- if (!$rank_math_active && !$yoast_active) {
- echo '';
- echo '
Airano MCP SEO Meta Bridge: ' . esc_html__( 'Neither Rank Math SEO nor Yoast SEO is detected. Please install and activate one of these plugins to enable SEO meta field access via REST API.', 'airano-mcp-seo-bridge' ) . '
';
- echo '
';
- } else {
- $active_plugins = [];
- if ($rank_math_active) $active_plugins[] = 'Rank Math SEO';
- if ($yoast_active) $active_plugins[] = 'Yoast SEO';
-
- $supported_types = implode(', ', $this->supported_post_types);
-
- echo '';
- echo '
Airano MCP SEO Meta Bridge v' . esc_html( self::VERSION ) . ': ' . esc_html( sprintf( 'Successfully registered meta fields for %s.', implode( ' and ', $active_plugins ) ) ) . '
';
- echo '
' . esc_html__( 'Supported post types:', 'airano-mcp-seo-bridge' ) . ' ' . esc_html( $supported_types ) . '
';
-
- if ($woocommerce_active) {
- echo '
WooCommerce: ' . esc_html__( 'Detected and supported. Product SEO fields are available via REST API.', 'airano-mcp-seo-bridge' ) . '
';
- }
- echo '
';
- }
- }
-}
-
-// Initialize the plugin
-new SEO_API_Bridge();