feat(v3.12.0): media pipeline, AI image generation, capability probe, companion v2.9.0
Some checks failed
Release / Test before release (push) Has been cancelled
Release / Publish to PyPI (push) Has been cancelled
Release / Publish to Docker Hub (push) Has been cancelled
Release / Create GitHub Release (push) Has been cancelled

Three-month batch sync from internal repo (~80 commits) covering Tracks F.5a, F.7e, F.8, F.17, F.18, F.X.

WordPress media pipeline
- Pillow-based optimization, AI image generation (OpenAI / Stability / Replicate / Google Nano Banana / OpenRouter), chunked + resumable uploads, bulk delete/reassign, idempotent retries.

Capability discovery (F.7e)
- Per-site credential probe + adapters for WordPress / WooCommerce / Gitea, tier-fit unions granted ∪ roles, capability badge UI with HTMX partial re-check, install hint in every companion-unreachable error.

Companion plugin overhaul
- Renamed wordpress-plugin/airano-mcp-seo-bridge → wordpress-plugin/airano-mcp-bridge.
- Eight new endpoints: /capabilities, /bulk-meta, /export, /cache-purge, /transient-flush, /site-health, /audit-hook, /upload-and-attach.
- wp.org Plugin Check pass: i18n, WP_Filesystem, scheme allowlist on audit-hook URL.

Other
- Gitea ergonomics (F.17): batch files, tree, search, compare, releases, fork.
- Opportunistic bcrypt upgrade for legacy SHA-256 admin keys (F.8).
- n8n refactor: structured errors, capability probe, missing tools backfilled.
- Idempotency-Key dedup for AI media upload retries; WP client fast-fails on unreachable sites.

Docs
- README + CLAUDE.md drop the fixed "633 tools" claim. The total grows with each release; per-plugin approximations + dashboard-surfaced counts replace it.
- Tools/Tests badges removed in favour of "Plugins: 10".

Deployment
- PyPI mirror chain, optional BUILD_HTTP_PROXY, Alpine→Yandex apk mirror, Debian-slim Plan-B Dockerfile, mirror.gcr.io variant.

CI
- Black + Ruff clean on Python 3.12; pytest tests/ green.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-04-25 16:25:58 +02:00
parent 788439e377
commit f203ca88de
140 changed files with 23802 additions and 2253 deletions

View File

@@ -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",

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

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

View File

@@ -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 ``<PROVIDER>_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)

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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(

View File

@@ -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

View File

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

View File

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

View File

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

View File

@@ -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:

View File

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

View File

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

View File

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

View File

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

View File

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