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

@@ -0,0 +1,33 @@
"""AI image generation provider library (F.5a.4).
Not a registered MCP plugin — this package exposes provider
implementations and a lookup registry consumed by media-upload tools in
other plugins (currently ``wordpress_generate_and_upload_image``).
Typical usage::
from plugins.ai_image.registry import get_provider
provider = get_provider("openai")
result = await provider.generate(
api_key=key, prompt="a red cube", size="1024x1024"
)
image_bytes, mime, meta = result.bytes, result.mime, result.meta
"""
from plugins.ai_image.providers.base import (
BaseImageProvider,
GenerationRequest,
GenerationResult,
ProviderError,
)
from plugins.ai_image.registry import get_provider, list_providers
__all__ = [
"BaseImageProvider",
"GenerationRequest",
"GenerationResult",
"ProviderError",
"get_provider",
"list_providers",
]

View File

@@ -0,0 +1 @@
"""AI image provider implementations (F.5a.4)."""

View File

@@ -0,0 +1,72 @@
"""Base types for AI image providers (F.5a.4).
Each concrete provider subclasses :class:`BaseImageProvider` and implements
``generate()``. Providers return raw image bytes + MIME so the caller (e.g.
the WordPress upload pipeline) can reuse the existing raw-upload path.
"""
from __future__ import annotations
from abc import ABC, abstractmethod
from dataclasses import dataclass, field
from typing import Any
class ProviderError(Exception):
"""Stable-coded error for provider failures.
Codes follow the F.5a error taxonomy (``PROVIDER_QUOTA``,
``PROVIDER_AUTH``, ``PROVIDER_BAD_REQUEST``, ``PROVIDER_UNAVAILABLE``,
``PROVIDER_TIMEOUT``). ``details`` is JSON-serialisable.
"""
def __init__(self, code: str, message: str, details: dict[str, Any] | None = None):
super().__init__(message)
self.code = code
self.message = message
self.details = details or {}
def to_dict(self) -> dict[str, Any]:
return {"error_code": self.code, "message": self.message, "details": self.details}
@dataclass
class GenerationRequest:
"""Normalised generation parameters.
Not every provider uses every field — unknown fields are ignored.
"""
prompt: str
size: str = "1024x1024"
quality: str = "standard"
model: str | None = None
negative_prompt: str | None = None
extras: dict[str, Any] = field(default_factory=dict)
@dataclass
class GenerationResult:
"""Raw bytes + metadata returned by a provider."""
data: bytes
mime: str
filename: str
meta: dict[str, Any] = field(default_factory=dict)
cost_usd: float | None = None
class BaseImageProvider(ABC):
"""Abstract provider: turns ``(api_key, request)`` into image bytes."""
name: str = "base"
@abstractmethod
async def generate(self, api_key: str, request: GenerationRequest) -> GenerationResult:
"""Call the provider API and return image bytes + metadata.
Implementations should raise :class:`ProviderError` with a stable
code for predictable client handling. Network retries for transient
failures (429 / 5xx) should happen inside the implementation.
"""
raise NotImplementedError

View File

@@ -0,0 +1,184 @@
"""OpenAI image-generation provider (DALL-E 3 + gpt-image-1).
DALL-E 3 returns a time-limited URL (~1h TTL). gpt-image-1 can return
``b64_json`` directly. In both cases we return the raw bytes immediately
to the caller so the 1h URL expiry is never a concern downstream.
"""
from __future__ import annotations
import asyncio
import base64
import logging
from typing import Any
import aiohttp
from plugins.ai_image.providers.base import (
BaseImageProvider,
GenerationRequest,
GenerationResult,
ProviderError,
)
_logger = logging.getLogger("mcphub.ai_image.openai")
_API_URL = "https://api.openai.com/v1/images/generations"
_DEFAULT_MODEL = "dall-e-3"
_RETRY_STATUS = {429, 500, 502, 503, 504}
_MAX_RETRIES = 3
_REQUEST_TIMEOUT = 90
# Rough per-image pricing (USD) for audit logging. These are documented
# public list prices at the time of writing and may drift — treat as
# ballpark for cost dashboards, not billing.
_COST_TABLE: dict[tuple[str, str, str], float] = {
("dall-e-3", "1024x1024", "standard"): 0.040,
("dall-e-3", "1024x1024", "hd"): 0.080,
("dall-e-3", "1792x1024", "standard"): 0.080,
("dall-e-3", "1024x1792", "standard"): 0.080,
("dall-e-3", "1792x1024", "hd"): 0.120,
("dall-e-3", "1024x1792", "hd"): 0.120,
("gpt-image-1", "1024x1024", "low"): 0.011,
("gpt-image-1", "1024x1024", "medium"): 0.042,
("gpt-image-1", "1024x1024", "high"): 0.167,
}
def _estimate_cost(model: str, size: str, quality: str) -> float | None:
return _COST_TABLE.get((model, size, quality))
class OpenAIProvider(BaseImageProvider):
name = "openai"
async def generate(self, api_key: str, request: GenerationRequest) -> GenerationResult:
if not api_key:
raise ProviderError("PROVIDER_AUTH", "OpenAI API key is missing.")
model = request.model or _DEFAULT_MODEL
payload: dict[str, Any] = {
"model": model,
"prompt": request.prompt,
"size": request.size,
"n": 1,
}
if model == "dall-e-3":
payload["quality"] = request.quality or "standard"
payload["response_format"] = "url"
else:
payload["response_format"] = "b64_json"
if request.quality:
payload["quality"] = request.quality
data, meta = await self._post_with_retry(api_key, payload)
items = data.get("data") or []
if not items:
raise ProviderError("PROVIDER_BAD_RESPONSE", "OpenAI returned no images.")
first = items[0]
if first.get("b64_json"):
image_bytes = base64.b64decode(first["b64_json"])
elif first.get("url"):
image_bytes = await _fetch_url(first["url"])
else:
raise ProviderError(
"PROVIDER_BAD_RESPONSE",
"OpenAI response contained no b64_json or url.",
)
meta = dict(meta)
if first.get("revised_prompt"):
meta["revised_prompt"] = first["revised_prompt"]
meta["model"] = model
meta["size"] = request.size
return GenerationResult(
data=image_bytes,
mime="image/png",
filename=f"openai-{model}.png",
meta=meta,
cost_usd=_estimate_cost(model, request.size, request.quality or "standard"),
)
async def _post_with_retry(
self, api_key: str, payload: dict[str, Any]
) -> tuple[dict[str, Any], dict[str, Any]]:
headers = {
"Authorization": f"Bearer {api_key}",
"Content-Type": "application/json",
}
last_error: str = ""
delay = 1.0
timeout = aiohttp.ClientTimeout(total=_REQUEST_TIMEOUT)
async with aiohttp.ClientSession(timeout=timeout) as session:
for attempt in range(1, _MAX_RETRIES + 1):
try:
async with session.post(_API_URL, json=payload, headers=headers) as resp:
if resp.status == 200:
body = await resp.json()
return body, {"attempt": attempt, "status": 200}
text = await resp.text()
last_error = text[:500]
if resp.status == 401:
raise ProviderError(
"PROVIDER_AUTH",
"OpenAI rejected the API key (401).",
{"body": last_error},
)
if resp.status == 400:
raise ProviderError(
"PROVIDER_BAD_REQUEST",
f"OpenAI rejected request (400): {last_error}",
{"body": last_error},
)
if resp.status == 429 and attempt < _MAX_RETRIES:
_logger.warning("OpenAI 429, retry %d", attempt)
await asyncio.sleep(delay)
delay *= 2
continue
if resp.status in _RETRY_STATUS and attempt < _MAX_RETRIES:
_logger.warning("OpenAI %d, retry %d", resp.status, attempt)
await asyncio.sleep(delay)
delay *= 2
continue
if resp.status == 429:
raise ProviderError(
"PROVIDER_QUOTA",
"OpenAI quota/rate-limit hit after retries.",
{"status": 429, "body": last_error},
)
raise ProviderError(
"PROVIDER_UNAVAILABLE",
f"OpenAI upstream error HTTP {resp.status}.",
{"status": resp.status, "body": last_error},
)
except TimeoutError as exc:
last_error = f"timeout: {exc}"
if attempt < _MAX_RETRIES:
await asyncio.sleep(delay)
delay *= 2
continue
raise ProviderError(
"PROVIDER_TIMEOUT",
"OpenAI request timed out after retries.",
) from exc
raise ProviderError(
"PROVIDER_UNAVAILABLE",
f"OpenAI call failed after {_MAX_RETRIES} attempts: {last_error}",
)
async def _fetch_url(url: str) -> bytes:
"""Fetch a DALL-E URL immediately. URLs expire in ~1h, so no caching."""
timeout = aiohttp.ClientTimeout(total=_REQUEST_TIMEOUT)
async with aiohttp.ClientSession(timeout=timeout) as session:
async with session.get(url) as resp:
if resp.status != 200:
raise ProviderError(
"PROVIDER_BAD_RESPONSE",
f"Failed to fetch OpenAI image URL (HTTP {resp.status}).",
{"status": resp.status},
)
return await resp.read()

View File

@@ -0,0 +1,474 @@
"""OpenRouter image-generation provider.
OpenRouter is an aggregator that routes requests to many model vendors.
For image generation, the usable path is their chat-completions endpoint
with ``modalities=["image","text"]`` — models like
``google/gemini-2.5-flash-image`` (a.k.a. "Nano Banana") return the
generated image as a ``data:``-prefixed base64 URL inside the message
``images`` array; other models may return ``image_url``-style pointers.
Why this provider is worth adding:
* Unlocks Gemini image models without needing a Google Cloud project.
* Lets users concentrate AI spend on a single OpenRouter key rather
than managing separate OpenAI / Stability / Replicate accounts.
* Supported models (as of F.X.fix 2026-04-18):
- ``google/gemini-2.5-flash-image`` (default, GA)
- ``google/gemini-2.5-flash-image-preview`` (DEPRECATED; 404 on
fresh OpenRouter accounts — kept as a recognised alias so we can
emit ``PROVIDER_MODEL_DEPRECATED`` with a clear hint)
- Any other OpenRouter model that returns image parts in
``message.images[]`` — the parser is tolerant of newer entries.
The default model is picked to cover the most common use case (hero
images for WordPress / WooCommerce posts); callers can override via
``GenerationRequest.model``.
"""
from __future__ import annotations
import asyncio
import base64
import logging
import os
import time
from typing import Any
import aiohttp
from plugins.ai_image.providers.base import (
BaseImageProvider,
GenerationRequest,
GenerationResult,
ProviderError,
)
_logger = logging.getLogger("mcphub.ai_image.openrouter")
_API_URL = "https://openrouter.ai/api/v1/chat/completions"
_MODELS_URL = "https://openrouter.ai/api/v1/models"
# GA endpoint. The preview alias (...-image-preview) was deprecated after
# Google promoted the model to GA — new OpenRouter accounts get 404 on
# the preview ID.
_DEFAULT_MODEL = "google/gemini-2.5-flash-image"
_DEPRECATED_MODELS: dict[str, str] = {
"google/gemini-2.5-flash-image-preview": "google/gemini-2.5-flash-image",
}
_RETRY_STATUS = {429, 500, 502, 503, 504}
_MAX_RETRIES = 3
_REQUEST_TIMEOUT = 120
# USD-per-image price table. Figures are conservative upper bounds from
# public OpenRouter pricing (2026-04); operators who negotiate custom
# rates can override via ``OPENROUTER_PRICING_OVERRIDE`` (JSON map of
# model_id -> price USD).
_MODEL_PRICING: dict[str, float] = {
"google/gemini-2.5-flash-image": 0.00039,
"google/gemini-2.5-flash-image-preview": 0.00039,
"google/imagen-3.0-generate": 0.04,
"google/imagen-3.0-fast": 0.02,
"openai/dall-e-3": 0.04,
"openai/dall-e-3-hd": 0.08,
"openai/gpt-image-1": 0.017,
"black-forest-labs/flux-1.1-pro": 0.04,
"black-forest-labs/flux-pro": 0.055,
"black-forest-labs/flux-schnell": 0.003,
"stability-ai/stable-diffusion-3.5-large": 0.065,
}
def _pricing_table() -> dict[str, float]:
"""Return the effective price table, merging env override if present."""
import json as _json
override = os.environ.get("OPENROUTER_PRICING_OVERRIDE", "").strip()
if not override:
return _MODEL_PRICING
try:
parsed = _json.loads(override)
except Exception as exc: # noqa: BLE001
_logger.warning("OPENROUTER_PRICING_OVERRIDE is not valid JSON: %s", exc)
return _MODEL_PRICING
if not isinstance(parsed, dict):
return _MODEL_PRICING
merged = dict(_MODEL_PRICING)
for k, v in parsed.items():
try:
merged[str(k)] = float(v)
except (TypeError, ValueError):
continue
return merged
def _cost_for(model: str) -> float | None:
"""Return USD cost for a known model, else None (logs at debug)."""
price = _pricing_table().get(model)
if price is None:
_logger.debug("openrouter: no pricing entry for model %r", model)
return price
# Module-level cache for list_image_models() — 1h TTL is plenty; model
# catalog drift is measured in days, not minutes.
_MODELS_CACHE_TTL_SECONDS = 3600
_models_cache: dict[str, Any] = {"fetched_at": 0.0, "payload": None}
class OpenRouterProvider(BaseImageProvider):
name = "openrouter"
async def generate(self, api_key: str, request: GenerationRequest) -> GenerationResult:
if not api_key:
raise ProviderError("PROVIDER_AUTH", "OpenRouter API key is missing.")
requested_model = request.model or _DEFAULT_MODEL
replacement = _DEPRECATED_MODELS.get(requested_model)
if replacement is not None:
raise ProviderError(
"PROVIDER_MODEL_DEPRECATED",
(
f"OpenRouter model '{requested_model}' is deprecated. "
f"Use '{replacement}' instead."
),
{"requested_model": requested_model, "replacement_model": replacement},
)
model = requested_model
# Chat-completions shape with image modality declared. The
# prompt is sent as the single user message; size hints are
# advisory — Gemini picks an output size internally.
user_content: str = request.prompt
if request.negative_prompt:
user_content = f"{user_content}\n\nAvoid: {request.negative_prompt}"
payload: dict[str, Any] = {
"model": model,
"messages": [{"role": "user", "content": user_content}],
"modalities": ["image", "text"],
}
body = await self._post_with_retry(api_key, payload)
image_url = _extract_image_url(body)
if not image_url:
raise ProviderError(
"PROVIDER_BAD_RESPONSE",
"OpenRouter response contained no image data. Is the "
f"model '{model}' configured for image output?",
{"model": model},
)
image_bytes, mime = await _materialise_image(image_url)
if not image_bytes:
raise ProviderError(
"PROVIDER_BAD_RESPONSE",
"OpenRouter image URL could not be fetched / decoded.",
{"model": model},
)
# Derive a useful filename suffix from the mime type.
ext = {"image/png": "png", "image/jpeg": "jpg", "image/webp": "webp"}.get(mime, "png")
meta: dict[str, Any] = {"model": model, "size": request.size}
# Attach OpenRouter usage if present so audit logs can correlate.
usage = body.get("usage") or {}
if isinstance(usage, dict):
for k in ("prompt_tokens", "completion_tokens", "total_tokens"):
if k in usage:
meta[k] = usage[k]
return GenerationResult(
data=image_bytes,
mime=mime,
filename=f"openrouter-{model.replace('/', '-')}.{ext}",
meta=meta,
cost_usd=_cost_for(model),
)
async def _post_with_retry(self, api_key: str, payload: dict[str, Any]) -> dict[str, Any]:
headers = {
"Authorization": f"Bearer {api_key}",
"Content-Type": "application/json",
# OpenRouter recommends identifying the calling app for
# observability. Using a stable string so admins can spot
# traffic from MCPHub in their OpenRouter dashboard.
"HTTP-Referer": "https://github.com/airano-ir/mcphub",
"X-Title": "MCPHub",
}
last_error: str = ""
delay = 1.0
timeout = aiohttp.ClientTimeout(total=_REQUEST_TIMEOUT)
async with aiohttp.ClientSession(timeout=timeout) as session:
for attempt in range(1, _MAX_RETRIES + 1):
try:
async with session.post(_API_URL, json=payload, headers=headers) as resp:
if resp.status == 200:
return await resp.json()
text = await resp.text()
last_error = text[:500]
if resp.status == 401:
raise ProviderError(
"PROVIDER_AUTH",
"OpenRouter rejected the API key (401).",
{"body": last_error},
)
if resp.status == 400:
raise ProviderError(
"PROVIDER_BAD_REQUEST",
f"OpenRouter rejected request (400): {last_error}",
{"body": last_error},
)
if resp.status in _RETRY_STATUS and attempt < _MAX_RETRIES:
_logger.warning(
"OpenRouter %d, retry %d/%d", resp.status, attempt, _MAX_RETRIES
)
await asyncio.sleep(delay)
delay *= 2
continue
if resp.status == 429:
raise ProviderError(
"PROVIDER_QUOTA",
"OpenRouter quota/rate-limit hit after retries.",
{"status": 429, "body": last_error},
)
raise ProviderError(
"PROVIDER_UNAVAILABLE",
f"OpenRouter upstream error HTTP {resp.status}.",
{"status": resp.status, "body": last_error},
)
except TimeoutError as exc:
last_error = f"timeout: {exc}"
if attempt < _MAX_RETRIES:
await asyncio.sleep(delay)
delay *= 2
continue
raise ProviderError(
"PROVIDER_TIMEOUT",
"OpenRouter request timed out after retries.",
) from exc
raise ProviderError(
"PROVIDER_UNAVAILABLE",
f"OpenRouter call failed after {_MAX_RETRIES} attempts: {last_error}",
)
async def list_image_models(
self, api_key: str | None = None, *, force: bool = False
) -> list[dict[str, Any]]:
"""Return OpenRouter catalog entries whose modality includes image.
The catalog drifts slowly (days) so we cache the whole filtered
list at module scope for 1h. ``api_key`` is optional — the
``/v1/models`` endpoint is public, but authenticated callers see
per-account availability flags. On any upstream error the
previous cache (if any) is returned; otherwise an empty list.
"""
now = time.time()
payload = _models_cache.get("payload")
fetched_at = _models_cache.get("fetched_at", 0.0)
if not force and payload is not None and (now - fetched_at) < _MODELS_CACHE_TTL_SECONDS:
return list(payload)
headers = {
"HTTP-Referer": "https://github.com/airano-ir/mcphub",
"X-Title": "MCPHub",
}
if api_key:
headers["Authorization"] = f"Bearer {api_key}"
timeout = aiohttp.ClientTimeout(total=_REQUEST_TIMEOUT)
try:
async with aiohttp.ClientSession(timeout=timeout) as session:
async with session.get(_MODELS_URL, headers=headers) as resp:
if resp.status != 200:
_logger.warning("openrouter /v1/models HTTP %s", resp.status)
return list(payload or [])
body = await resp.json()
except Exception as exc: # noqa: BLE001
_logger.warning("openrouter /v1/models fetch failed: %s", exc)
return list(payload or [])
raw_models = body.get("data") or []
pricing = _pricing_table()
filtered: list[dict[str, Any]] = []
for item in raw_models:
if not isinstance(item, dict):
continue
if not _model_is_image(item):
continue
model_id = str(item.get("id") or "")
if not model_id or model_id in _DEPRECATED_MODELS:
continue
filtered.append(
{
"id": model_id,
"name": item.get("name") or model_id,
"description": (item.get("description") or "")[:400],
"context_length": item.get("context_length"),
"input_modalities": _modalities_of(item, "input"),
"output_modalities": _modalities_of(item, "output"),
"price_per_image_usd": pricing.get(model_id),
}
)
filtered.sort(key=lambda m: m["id"])
_models_cache["fetched_at"] = now
_models_cache["payload"] = filtered
return list(filtered)
def _modalities_of(item: dict[str, Any], side: str) -> list[str]:
"""Extract input/output modality list from an OpenRouter model entry."""
arch = item.get("architecture") or {}
if isinstance(arch, dict):
key = "input_modalities" if side == "input" else "output_modalities"
mods = arch.get(key)
if isinstance(mods, list):
return [str(m) for m in mods if isinstance(m, str)]
modality = arch.get("modality")
if isinstance(modality, str):
parts = modality.split("->")
if len(parts) == 2:
idx = 0 if side == "input" else 1
return [p.strip() for p in parts[idx].split("+") if p.strip()]
return []
def _model_is_image(item: dict[str, Any]) -> bool:
"""True when an OpenRouter model entry emits image output."""
out = _modalities_of(item, "output")
if any("image" in m.lower() for m in out):
return True
# Legacy catalog rows without structured modalities — fall back to a
# name/description heuristic so we don't silently drop valid models.
blob = " ".join(str(item.get(k, "") or "") for k in ("id", "name", "description")).lower()
return "image" in blob and "gen" in blob
def _extract_image_url(body: dict[str, Any]) -> str | None:
"""Pull the first image URL out of an OpenRouter chat response.
The wire format varies by model vendor. We recognise:
* ``choices[0].message.images[i].image_url.url`` — a string that
may be a ``data:image/...;base64,...`` URL or an https URL.
* ``choices[0].message.content`` when it is a list of parts, each
of shape ``{type: "image_url", image_url: {url: ...}}`` —
OpenAI-compatible multimodal reply shape.
Returns None if no usable reference was found.
"""
choices = body.get("choices") or []
if not isinstance(choices, list) or not choices:
return None
message = (choices[0] or {}).get("message") or {}
# Shape 1: message.images (Gemini via OpenRouter).
for entry in message.get("images") or []:
url = _image_url_from_entry(entry)
if url:
return url
# Shape 2: message.content as list of parts.
content = message.get("content")
if isinstance(content, list):
for part in content:
if not isinstance(part, dict):
continue
if part.get("type") == "image_url":
url = _image_url_from_entry(part)
if url:
return url
return None
def _image_url_from_entry(entry: Any) -> str | None:
"""Handle both ``{image_url: {url: ...}}`` and ``{image_url: "..."}``."""
if not isinstance(entry, dict):
return None
iu = entry.get("image_url")
if isinstance(iu, str):
return iu
if isinstance(iu, dict):
u = iu.get("url")
return u if isinstance(u, str) else None
# Some providers emit a bare "url" key.
u = entry.get("url")
return u if isinstance(u, str) else None
async def _materialise_image(url: str) -> tuple[bytes, str]:
"""Turn an image URL (data: or https) into raw bytes + MIME.
Returns ``(b"", "")`` on any failure so the caller can emit a
uniform ``PROVIDER_BAD_RESPONSE`` error.
"""
if url.startswith("data:"):
try:
header, payload = url.split(",", 1)
except ValueError:
return b"", ""
mime = "image/png"
rest = header[len("data:") :] if header.startswith("data:") else header
if ";" in rest:
mime = rest.split(";", 1)[0] or mime
elif rest:
mime = rest
if "base64" in header:
try:
return base64.b64decode(payload), mime
except Exception:
return b"", mime
return payload.encode("latin-1", errors="ignore"), mime
if url.startswith("http://") or url.startswith("https://"):
timeout = aiohttp.ClientTimeout(total=_REQUEST_TIMEOUT)
async with aiohttp.ClientSession(timeout=timeout) as session:
async with session.get(url) as resp:
if resp.status != 200:
return b"", ""
mime = resp.headers.get("Content-Type", "image/png").split(";", 1)[0].strip()
return await resp.read(), mime or "image/png"
return b"", ""
# ---------------------------------------------------------------------------
# Starlette handler — GET /api/providers/openrouter/models
# ---------------------------------------------------------------------------
async def api_openrouter_models(request: Any) -> Any:
"""Return the cached OpenRouter image-model catalog.
Auth: same OAuth user session guard as the other /api/ endpoints.
Query params:
* ``force=1`` — bypass the 1h module cache.
* ``site_id=<uuid>`` — if present, the user's OpenRouter key for
that site is used as bearer so availability flags reflect the
operator's account; otherwise the endpoint is fetched unauthed.
"""
from starlette.responses import JSONResponse
from core.dashboard.routes import _require_user_session
user_session, redirect = _require_user_session(request)
if redirect or user_session is None:
return JSONResponse({"ok": False, "error": "unauthorized"}, status_code=401)
force = request.query_params.get("force") in {"1", "true", "True"}
api_key: str | None = None
site_id = (request.query_params.get("site_id") or "").strip()
if site_id:
try:
from core.database import get_database
from core.site_api import get_site_provider_key
db = get_database()
site = await db.get_site(site_id, user_session["user_id"])
if site is not None:
api_key = await get_site_provider_key(site_id, "openrouter")
except Exception as exc: # noqa: BLE001
_logger.debug("openrouter models: site key lookup skipped: %s", exc)
provider = OpenRouterProvider()
models = await provider.list_image_models(api_key=api_key, force=force)
return JSONResponse({"ok": True, "provider": "openrouter", "models": models})

View File

@@ -0,0 +1,204 @@
"""Replicate provider (Flux family + other image models).
Replicate runs predictions asynchronously: POST to ``/v1/predictions``
returns a job id, and we poll ``/v1/predictions/{id}`` until status is
``succeeded`` or ``failed``. The final output is a URL (or list of URLs)
that must be fetched to get bytes.
"""
from __future__ import annotations
import asyncio
import logging
from typing import Any
import aiohttp
from plugins.ai_image.providers.base import (
BaseImageProvider,
GenerationRequest,
GenerationResult,
ProviderError,
)
_logger = logging.getLogger("mcphub.ai_image.replicate")
_PREDICTIONS_URL = "https://api.replicate.com/v1/predictions"
_DEFAULT_MODEL = "black-forest-labs/flux-schnell"
_MAX_RETRIES = 3
_POLL_INTERVAL = 2.0
_POLL_TIMEOUT = 180
_REQUEST_TIMEOUT = 60
_RETRY_STATUS = {429, 500, 502, 503, 504}
_COST_TABLE: dict[str, float] = {
"black-forest-labs/flux-schnell": 0.003,
"black-forest-labs/flux-dev": 0.025,
"black-forest-labs/flux-pro": 0.055,
}
def _aspect_from_size(size: str) -> str:
"""Replicate's Flux models accept an aspect_ratio enum, not WxH."""
mapping = {
"1024x1024": "1:1",
"1344x768": "16:9",
"768x1344": "9:16",
"1152x896": "3:2",
"896x1152": "2:3",
}
return mapping.get(size, "1:1")
class ReplicateProvider(BaseImageProvider):
name = "replicate"
async def generate(self, api_key: str, request: GenerationRequest) -> GenerationResult:
if not api_key:
raise ProviderError("PROVIDER_AUTH", "Replicate API token is missing.")
model = request.model or _DEFAULT_MODEL
payload: dict[str, Any] = {
"model": model,
"input": {
"prompt": request.prompt,
"aspect_ratio": _aspect_from_size(request.size),
**(request.extras or {}),
},
}
if request.negative_prompt:
payload["input"]["negative_prompt"] = request.negative_prompt
prediction = await self._create_prediction(api_key, payload)
final = await self._poll_until_done(api_key, prediction)
output = final.get("output")
url = _first_url(output)
if not url:
raise ProviderError(
"PROVIDER_BAD_RESPONSE",
"Replicate prediction finished without an image URL.",
{"prediction": final},
)
image_bytes = await _fetch_url(url)
meta = {
"model": model,
"prediction_id": final.get("id"),
"status": final.get("status"),
"metrics": final.get("metrics", {}),
}
return GenerationResult(
data=image_bytes,
mime="image/webp" if url.endswith(".webp") else "image/png",
filename=f"replicate-{model.split('/')[-1]}.png",
meta=meta,
cost_usd=_COST_TABLE.get(model),
)
async def _create_prediction(self, api_key: str, payload: dict[str, Any]) -> dict[str, Any]:
headers = {
"Authorization": f"Bearer {api_key}",
"Content-Type": "application/json",
"Prefer": "wait=0",
}
delay = 1.0
last_error = ""
timeout = aiohttp.ClientTimeout(total=_REQUEST_TIMEOUT)
async with aiohttp.ClientSession(timeout=timeout) as session:
for attempt in range(1, _MAX_RETRIES + 1):
async with session.post(_PREDICTIONS_URL, json=payload, headers=headers) as resp:
if resp.status in (200, 201):
return await resp.json()
text = await resp.text()
last_error = text[:500]
if resp.status in (401, 403):
raise ProviderError(
"PROVIDER_AUTH",
f"Replicate rejected auth ({resp.status}).",
{"body": last_error},
)
if resp.status == 422:
raise ProviderError(
"PROVIDER_BAD_REQUEST",
f"Replicate rejected request: {last_error}",
{"body": last_error},
)
if resp.status in _RETRY_STATUS and attempt < _MAX_RETRIES:
_logger.warning("Replicate %d, retry %d", resp.status, attempt)
await asyncio.sleep(delay)
delay *= 2
continue
if resp.status == 429:
raise ProviderError(
"PROVIDER_QUOTA",
"Replicate rate-limit hit.",
{"status": 429, "body": last_error},
)
raise ProviderError(
"PROVIDER_UNAVAILABLE",
f"Replicate HTTP {resp.status}.",
{"status": resp.status, "body": last_error},
)
raise ProviderError(
"PROVIDER_UNAVAILABLE",
f"Replicate create_prediction failed after retries: {last_error}",
)
async def _poll_until_done(self, api_key: str, prediction: dict[str, Any]) -> dict[str, Any]:
headers = {"Authorization": f"Bearer {api_key}"}
poll_url = (prediction.get("urls") or {}).get("get") or (
f"{_PREDICTIONS_URL}/{prediction.get('id')}"
)
timeout = aiohttp.ClientTimeout(total=_REQUEST_TIMEOUT)
deadline = asyncio.get_event_loop().time() + _POLL_TIMEOUT
async with aiohttp.ClientSession(timeout=timeout) as session:
while True:
if asyncio.get_event_loop().time() > deadline:
raise ProviderError(
"PROVIDER_TIMEOUT",
"Replicate prediction did not finish within timeout.",
{"prediction_id": prediction.get("id")},
)
async with session.get(poll_url, headers=headers) as resp:
if resp.status != 200:
text = await resp.text()
raise ProviderError(
"PROVIDER_UNAVAILABLE",
f"Replicate poll HTTP {resp.status}.",
{"body": text[:500]},
)
body = await resp.json()
status = body.get("status")
if status in ("succeeded",):
return body
if status in ("failed", "canceled"):
raise ProviderError(
"PROVIDER_BAD_RESPONSE",
f"Replicate prediction {status}: {body.get('error')}",
{"prediction": body},
)
await asyncio.sleep(_POLL_INTERVAL)
def _first_url(output: Any) -> str | None:
if isinstance(output, str):
return output
if isinstance(output, list) and output:
first = output[0]
if isinstance(first, str):
return first
return None
async def _fetch_url(url: str) -> bytes:
timeout = aiohttp.ClientTimeout(total=_REQUEST_TIMEOUT)
async with aiohttp.ClientSession(timeout=timeout) as session:
async with session.get(url) as resp:
if resp.status != 200:
raise ProviderError(
"PROVIDER_BAD_RESPONSE",
f"Failed to fetch Replicate output (HTTP {resp.status}).",
{"status": resp.status},
)
return await resp.read()

View File

@@ -0,0 +1,154 @@
"""Stability AI image generation provider (Stable Image Core / Ultra).
Uses the v2beta generate endpoint with ``Accept: image/*`` to get raw
bytes back directly (no JSON indirection).
"""
from __future__ import annotations
import asyncio
import logging
from typing import Any
import aiohttp
from plugins.ai_image.providers.base import (
BaseImageProvider,
GenerationRequest,
GenerationResult,
ProviderError,
)
_logger = logging.getLogger("mcphub.ai_image.stability")
_MODEL_ENDPOINTS = {
"core": "https://api.stability.ai/v2beta/stable-image/generate/core",
"ultra": "https://api.stability.ai/v2beta/stable-image/generate/ultra",
"sd3": "https://api.stability.ai/v2beta/stable-image/generate/sd3",
}
_DEFAULT_MODEL = "core"
_MAX_RETRIES = 3
_REQUEST_TIMEOUT = 120
_RETRY_STATUS = {429, 500, 502, 503, 504}
_COST_TABLE: dict[str, float] = {
"core": 0.03,
"ultra": 0.08,
"sd3": 0.065,
}
def _size_to_aspect(size: str) -> str:
"""Map a WxH size string to the Stability aspect_ratio enum."""
mapping = {
"1024x1024": "1:1",
"1152x896": "21:9",
"1216x832": "3:2",
"1344x768": "16:9",
"768x1344": "9:16",
"832x1216": "2:3",
"1024x576": "16:9",
"512x512": "1:1",
}
return mapping.get(size, "1:1")
class StabilityProvider(BaseImageProvider):
name = "stability"
async def generate(self, api_key: str, request: GenerationRequest) -> GenerationResult:
if not api_key:
raise ProviderError("PROVIDER_AUTH", "Stability API key is missing.")
model = (request.model or _DEFAULT_MODEL).lower()
endpoint = _MODEL_ENDPOINTS.get(model)
if endpoint is None:
raise ProviderError(
"PROVIDER_BAD_REQUEST",
f"Unknown Stability model '{model}'. " f"Allowed: {', '.join(_MODEL_ENDPOINTS)}.",
)
form = aiohttp.FormData()
form.add_field("prompt", request.prompt)
form.add_field("output_format", "png")
form.add_field("aspect_ratio", _size_to_aspect(request.size))
if request.negative_prompt:
form.add_field("negative_prompt", request.negative_prompt)
headers = {
"Authorization": f"Bearer {api_key}",
"Accept": "image/*",
}
data, meta = await self._post_with_retry(endpoint, form, headers)
meta = dict(meta)
meta["model"] = model
meta["aspect_ratio"] = _size_to_aspect(request.size)
return GenerationResult(
data=data,
mime="image/png",
filename=f"stability-{model}.png",
meta=meta,
cost_usd=_COST_TABLE.get(model),
)
async def _post_with_retry(
self,
endpoint: str,
form: aiohttp.FormData,
headers: dict[str, str],
) -> tuple[bytes, dict[str, Any]]:
last_error: str = ""
delay = 1.0
timeout = aiohttp.ClientTimeout(total=_REQUEST_TIMEOUT)
async with aiohttp.ClientSession(timeout=timeout) as session:
for attempt in range(1, _MAX_RETRIES + 1):
try:
async with session.post(endpoint, data=form, headers=headers) as resp:
if resp.status == 200:
body = await resp.read()
return body, {"attempt": attempt, "status": 200}
text = await resp.text()
last_error = text[:500]
if resp.status == 401 or resp.status == 403:
raise ProviderError(
"PROVIDER_AUTH",
f"Stability rejected auth ({resp.status}).",
{"body": last_error},
)
if resp.status == 400:
raise ProviderError(
"PROVIDER_BAD_REQUEST",
f"Stability rejected request: {last_error}",
{"body": last_error},
)
if resp.status in _RETRY_STATUS and attempt < _MAX_RETRIES:
_logger.warning("Stability %d, retry %d", resp.status, attempt)
await asyncio.sleep(delay)
delay *= 2
continue
if resp.status == 429:
raise ProviderError(
"PROVIDER_QUOTA",
"Stability quota/rate-limit hit.",
{"status": 429, "body": last_error},
)
raise ProviderError(
"PROVIDER_UNAVAILABLE",
f"Stability upstream error HTTP {resp.status}.",
{"status": resp.status, "body": last_error},
)
except TimeoutError as exc:
last_error = f"timeout: {exc}"
if attempt < _MAX_RETRIES:
await asyncio.sleep(delay)
delay *= 2
continue
raise ProviderError(
"PROVIDER_TIMEOUT",
"Stability request timed out after retries.",
) from exc
raise ProviderError(
"PROVIDER_UNAVAILABLE",
f"Stability call failed after {_MAX_RETRIES} attempts: {last_error}",
)

View File

@@ -0,0 +1,36 @@
"""Provider registry for AI image generation (F.5a.4).
Lookup by name; raises :class:`ProviderError` with code
``PROVIDER_UNKNOWN`` if the caller supplies a non-existent provider.
"""
from __future__ import annotations
from plugins.ai_image.providers.base import BaseImageProvider, ProviderError
from plugins.ai_image.providers.openai import OpenAIProvider
from plugins.ai_image.providers.openrouter import OpenRouterProvider
from plugins.ai_image.providers.replicate import ReplicateProvider
from plugins.ai_image.providers.stability import StabilityProvider
_PROVIDERS: dict[str, BaseImageProvider] = {
"openai": OpenAIProvider(),
"stability": StabilityProvider(),
"replicate": ReplicateProvider(),
"openrouter": OpenRouterProvider(),
}
def get_provider(name: str) -> BaseImageProvider:
"""Return the registered provider singleton by name."""
try:
return _PROVIDERS[name]
except KeyError as exc:
raise ProviderError(
"PROVIDER_UNKNOWN",
f"Unknown provider '{name}'. Allowed: {', '.join(_PROVIDERS)}.",
) from exc
def list_providers() -> list[str]:
"""Return all registered provider names in registration order."""
return list(_PROVIDERS.keys())

View File

@@ -165,6 +165,40 @@ class BasePlugin(ABC):
"""
return {"healthy": True, "message": "Health check not implemented for this plugin"}
async def probe_credential_capabilities(self) -> dict[str, Any]:
"""F.7e — report what the saved credential can actually do.
Plugins should override this to call the upstream service
(WordPress companion ``/capabilities``, WooCommerce
``system_status``, Gitea ``/user`` header scopes, Coolify
``/api/v1/teams/0``, etc.) and return the subset of capability
names the caller's token actually grants.
The base implementation returns a well-shaped "probe not
available" payload so callers can handle all plugin types
uniformly without special-casing the ones that haven't
implemented a probe yet.
Returns:
Dict with:
probe_available: bool — True if the plugin knows how to
query its upstream for capabilities.
granted: list[str] — the capabilities the credential
actually grants (subset of the universe the plugin
understands). Empty when ``probe_available=False``.
source: str — which endpoint / header the list came from;
``"unavailable"`` when no probe is wired.
reason: str | None — optional human-readable diagnostic
when ``probe_available=False`` or when the call
failed in a way that still produced a usable answer.
"""
return {
"probe_available": False,
"granted": [],
"source": "unavailable",
"reason": "probe_not_implemented",
}
def get_project_info(self) -> dict[str, Any]:
"""
Return basic information about this project instance.

View File

@@ -206,25 +206,10 @@ class GiteaClient:
async def create_file(self, owner: str, repo: str, filepath: str, data: dict) -> dict:
"""Create a file"""
# Encode content to base64 if not already encoded
if "content" in data:
content = data["content"]
# Check if content is already base64 encoded
is_already_base64 = data.get("content_is_base64", False)
if not is_already_base64:
# Plain text content - encode to base64
try:
data["content"] = base64.b64encode(content.encode()).decode()
except (AttributeError, UnicodeDecodeError):
# If content is already bytes or has encoding issues, try direct encoding
if isinstance(content, bytes):
data["content"] = base64.b64encode(content).decode()
else:
raise ValueError("Content must be a string or bytes")
# Remove the flag before sending to API
data["content"] = self._normalise_file_content(
data["content"], data.get("content_is_base64", False)
)
data.pop("content_is_base64", None)
return await self.request(
@@ -233,31 +218,63 @@ class GiteaClient:
async def update_file(self, owner: str, repo: str, filepath: str, data: dict) -> dict:
"""Update a file"""
# Encode content to base64 if not already encoded
if "content" in data:
content = data["content"]
# Check if content is already base64 encoded
is_already_base64 = data.get("content_is_base64", False)
if not is_already_base64:
# Plain text content - encode to base64
try:
data["content"] = base64.b64encode(content.encode()).decode()
except (AttributeError, UnicodeDecodeError):
# If content is already bytes or has encoding issues, try direct encoding
if isinstance(content, bytes):
data["content"] = base64.b64encode(content).decode()
else:
raise ValueError("Content must be a string or bytes")
# Remove the flag before sending to API
data["content"] = self._normalise_file_content(
data["content"], data.get("content_is_base64", False)
)
data.pop("content_is_base64", None)
return await self.request(
"PUT", f"repos/{owner}/{repo}/contents/{filepath}", json_data=data
)
@staticmethod
def _normalise_file_content(content: Any, is_already_base64: bool) -> str:
"""F.17 ergonomics: turn ``content`` into a base64 string suitable
for Gitea's contents endpoints, with actionable error messages.
``is_already_base64=True`` validates that ``content`` decodes
cleanly and re-emits it stripped of whitespace; if the decode
fails, raise a message that tells the caller exactly how to
recover (drop ``data:`` prefix, use ``content_is_base64=False``
for raw text). ``is_already_base64=False`` UTF-8 + base64
encodes the string.
"""
if is_already_base64:
if not isinstance(content, str):
raise ValueError(
"content_is_base64=True but content is not a string. "
"Pass the raw base64 text; do not wrap it in bytes / dicts."
)
stripped = content.strip()
if stripped.lower().startswith("data:"):
raise ValueError(
"content looks like a data: URL (starts with 'data:'). "
"Strip the 'data:<mime>;base64,' prefix before sending — "
"Gitea expects the base64 payload only."
)
try:
# Validate round-trip and normalise whitespace.
raw = base64.b64decode(stripped, validate=True)
return base64.b64encode(raw).decode()
except Exception as exc: # noqa: BLE001
raise ValueError(
"content_is_base64=True but content is not valid base64. "
"If you meant to send raw text, set content_is_base64=False "
"(the client will base64-encode it for you). Original "
f"decoder error: {exc}"
) from exc
# Plain text / bytes → base64-encode for the caller.
if isinstance(content, bytes):
return base64.b64encode(content).decode()
if isinstance(content, str):
return base64.b64encode(content.encode("utf-8")).decode()
raise ValueError(
"content must be a string or bytes when content_is_base64=False. "
f"Got {type(content).__name__}."
)
async def delete_file(
self,
owner: str,
@@ -275,6 +292,186 @@ class GiteaClient:
"DELETE", f"repos/{owner}/{repo}/contents/{filepath}", json_data=data
)
# ------------------------------------------------------------------
# F.17 — ergonomics: batch file write, tree listing, search, compare,
# releases, fork.
# ------------------------------------------------------------------
async def change_files(
self,
owner: str,
repo: str,
data: dict,
) -> dict:
"""POST /repos/{owner}/{repo}/contents — apply a batch of file
create/update/delete operations in a single commit.
Gitea's ``files`` endpoint takes a list of ``{operation, path,
content, sha}`` entries. Operations: ``create`` | ``update`` |
``delete``. ``content`` must be base64 for create/update.
"""
return await self.request("POST", f"repos/{owner}/{repo}/contents", json_data=data)
async def get_tree(
self,
owner: str,
repo: str,
sha: str = "HEAD",
*,
recursive: bool = False,
page: int = 1,
per_page: int = 100,
) -> dict:
"""GET /repos/{owner}/{repo}/git/trees/{sha}
Returns ``{sha, url, tree: [{path, mode, type, size, sha, url}],
truncated}``. Set ``recursive=True`` to get the entire tree.
"""
params: dict[str, Any] = {"page": page, "per_page": per_page}
if recursive:
params["recursive"] = "true"
return await self.request("GET", f"repos/{owner}/{repo}/git/trees/{sha}", params=params)
async def search_code(
self,
*,
keyword: str,
owner: str | None = None,
repo: str | None = None,
page: int = 1,
per_page: int = 30,
) -> dict:
"""Search code either across all repos (``/repos/search/code``)
or within a specific repo (``/repos/{owner}/{repo}/search/code``).
Returns the raw Gitea payload: ``{ok, data: [...]}``.
"""
params: dict[str, Any] = {"q": keyword, "page": page, "per_page": per_page}
if owner and repo:
path = f"repos/{owner}/{repo}/search/code"
else:
path = "repos/search/code"
return await self.request("GET", path, params=params)
async def compare(
self,
owner: str,
repo: str,
base: str,
head: str,
) -> dict:
"""GET /repos/{owner}/{repo}/compare/{base}...{head}"""
# Gitea's compare endpoint uses ``...`` as the separator. The HTTP
# client URL-encodes path params, so we pre-join instead of
# passing them as separate path parameters.
spec = f"{base}...{head}"
return await self.request("GET", f"repos/{owner}/{repo}/compare/{spec}")
async def list_releases(
self,
owner: str,
repo: str,
*,
page: int = 1,
per_page: int = 30,
) -> list[dict]:
"""GET /repos/{owner}/{repo}/releases"""
return await self.request(
"GET",
f"repos/{owner}/{repo}/releases",
params={"page": page, "per_page": per_page},
)
async def create_release(
self,
owner: str,
repo: str,
data: dict,
) -> dict:
"""POST /repos/{owner}/{repo}/releases"""
return await self.request("POST", f"repos/{owner}/{repo}/releases", json_data=data)
async def get_release(self, owner: str, repo: str, release_id: int) -> dict:
return await self.request("GET", f"repos/{owner}/{repo}/releases/{release_id}")
async def delete_release(self, owner: str, repo: str, release_id: int) -> dict:
return await self.request("DELETE", f"repos/{owner}/{repo}/releases/{release_id}")
async def upload_release_asset(
self,
owner: str,
repo: str,
release_id: int,
*,
filename: str,
content_b64: str,
) -> dict:
"""Upload a release asset.
Gitea's API accepts multipart/form-data on
``POST /repos/{owner}/{repo}/releases/{id}/assets?name=FILE``.
We accept base64 content so the tool schema stays JSON-friendly;
callers supply ``content_b64``. Decoded bytes go into the
multipart body. This bypasses ``self.request`` because that
helper only understands JSON bodies.
"""
import aiohttp
try:
raw = base64.b64decode(content_b64, validate=True)
except Exception as exc: # noqa: BLE001
raise ValueError(
"content_b64 must be a valid base64 string. "
"For small text files, base64-encode the UTF-8 bytes; "
"do not include a 'data:' URL prefix."
) from exc
url = f"{self.api_base}/repos/{owner}/{repo}/releases/{release_id}/assets"
headers = self._get_headers()
# Requests library-style: multipart/form-data with attachment field.
form = aiohttp.FormData()
form.add_field(
"attachment",
raw,
filename=filename,
content_type="application/octet-stream",
)
async with aiohttp.ClientSession() as session:
async with session.post(
url, params={"name": filename}, data=form, headers=headers
) as resp:
text = await resp.text()
if resp.status >= 400:
raise Exception(
f"Gitea upload_release_asset failed ({resp.status}): {text[:500]}"
)
try:
import json as _json
return _json.loads(text) if text else {"success": True}
except Exception:
return {"success": True, "raw": text[:500]}
async def fork_repository(
self,
owner: str,
repo: str,
*,
organization: str | None = None,
name: str | None = None,
) -> dict:
"""POST /repos/{owner}/{repo}/forks
``organization``: target org (omit to fork under the caller).
``name``: custom name for the fork.
"""
payload: dict[str, Any] = {}
if organization:
payload["organization"] = organization
if name:
payload["name"] = name
return await self.request("POST", f"repos/{owner}/{repo}/forks", json_data=payload)
# Issue endpoints
async def list_issues(self, owner: str, repo: str, params: dict) -> list[dict]:
"""List repository issues"""

View File

@@ -482,6 +482,202 @@ def get_tool_specifications() -> list[dict[str, Any]]:
},
"scope": "write",
},
# === F.17 ergonomics: batch files, tree, search, compare, releases, fork ===
{
"name": "create_files",
"method_name": "create_files",
"description": (
"Create or update multiple files in a single commit. Uses Gitea's "
"``/repos/{owner}/{repo}/contents`` batch endpoint. Per-file operations: "
"``create`` | ``update`` | ``delete``. For create/update, pass raw UTF-8 "
"``content`` (default) or a base64 string with ``content_is_base64=true`` "
"per file. Delete requires each file's current ``sha``."
),
"schema": {
"type": "object",
"properties": {
"owner": {"type": "string", "minLength": 1},
"repo": {"type": "string", "minLength": 1},
"message": {
"type": "string",
"description": "Single commit message for the whole batch.",
"minLength": 1,
},
"branch": {"anyOf": [{"type": "string"}, {"type": "null"}]},
"new_branch": {
"anyOf": [{"type": "string"}, {"type": "null"}],
"description": "If set, commit on a new branch created from ``branch``.",
},
"files": {
"type": "array",
"minItems": 1,
"maxItems": 100,
"items": {
"type": "object",
"properties": {
"operation": {
"type": "string",
"enum": ["create", "update", "delete"],
},
"path": {"type": "string", "minLength": 1},
"content": {
"anyOf": [{"type": "string"}, {"type": "null"}],
"description": "Required for create/update.",
},
"sha": {
"anyOf": [{"type": "string"}, {"type": "null"}],
"description": "Required for update/delete.",
},
"content_is_base64": {"type": "boolean", "default": False},
},
"required": ["operation", "path"],
},
},
},
"required": ["owner", "repo", "files", "message"],
},
"scope": "write",
},
{
"name": "get_tree",
"method_name": "get_tree",
"description": (
"List the file tree of a Gitea repository. ``sha`` may be a branch "
"name, tag, or commit (default: HEAD). ``recursive=true`` returns "
"the entire tree in one payload. Use this instead of issuing many "
"``get_file`` calls when you don't know the paths ahead of time."
),
"schema": {
"type": "object",
"properties": {
"owner": {"type": "string", "minLength": 1},
"repo": {"type": "string", "minLength": 1},
"sha": {"type": "string", "default": "HEAD"},
"recursive": {"type": "boolean", "default": False},
"page": {"type": "integer", "minimum": 1, "default": 1},
"per_page": {
"type": "integer",
"minimum": 1,
"maximum": 100,
"default": 100,
},
},
"required": ["owner", "repo"],
},
"scope": "read",
},
{
"name": "search_code",
"method_name": "search_code",
"description": (
"Search code inside Gitea. When ``owner`` and ``repo`` are set, the "
"search scopes to that repository; otherwise it's an instance-wide "
"code search. Returns Gitea's raw ``{ok, data: [...]}`` shape."
),
"schema": {
"type": "object",
"properties": {
"keyword": {"type": "string", "minLength": 1},
"owner": {"anyOf": [{"type": "string"}, {"type": "null"}]},
"repo": {"anyOf": [{"type": "string"}, {"type": "null"}]},
"page": {"type": "integer", "minimum": 1, "default": 1},
"per_page": {
"type": "integer",
"minimum": 1,
"maximum": 50,
"default": 30,
},
},
"required": ["keyword"],
},
"scope": "read",
},
{
"name": "compare",
"method_name": "compare",
"description": (
"Compare two commits / branches / tags in a Gitea repository. Returns "
"commits + file diffs between ``base`` and ``head`` (the Gitea "
"``compare`` endpoint)."
),
"schema": {
"type": "object",
"properties": {
"owner": {"type": "string", "minLength": 1},
"repo": {"type": "string", "minLength": 1},
"base": {"type": "string", "minLength": 1},
"head": {"type": "string", "minLength": 1},
},
"required": ["owner", "repo", "base", "head"],
},
"scope": "read",
},
{
"name": "list_releases",
"method_name": "list_releases",
"description": "List releases of a Gitea repository (paginated).",
"schema": {
"type": "object",
"properties": {
"owner": {"type": "string", "minLength": 1},
"repo": {"type": "string", "minLength": 1},
"page": {"type": "integer", "minimum": 1, "default": 1},
"per_page": {
"type": "integer",
"minimum": 1,
"maximum": 50,
"default": 30,
},
},
"required": ["owner", "repo"],
},
"scope": "read",
},
{
"name": "create_release",
"method_name": "create_release",
"description": (
"Create a release (tag + release metadata) on a Gitea repository. "
"Set ``draft`` to hide it from users until published."
),
"schema": {
"type": "object",
"properties": {
"owner": {"type": "string", "minLength": 1},
"repo": {"type": "string", "minLength": 1},
"tag_name": {"type": "string", "minLength": 1},
"name": {"anyOf": [{"type": "string"}, {"type": "null"}]},
"body": {"anyOf": [{"type": "string"}, {"type": "null"}]},
"target_commitish": {
"anyOf": [{"type": "string"}, {"type": "null"}],
"description": "Branch or commit to tag (default: default branch).",
},
"draft": {"type": "boolean", "default": False},
"prerelease": {"type": "boolean", "default": False},
},
"required": ["owner", "repo", "tag_name"],
},
"scope": "write",
},
{
"name": "fork_repository",
"method_name": "fork_repository",
"description": (
"Fork a Gitea repository. Without ``organization``, forks under the "
"calling user. ``name`` optionally renames the fork."
),
"schema": {
"type": "object",
"properties": {
"owner": {"type": "string", "minLength": 1},
"repo": {"type": "string", "minLength": 1},
"organization": {"anyOf": [{"type": "string"}, {"type": "null"}]},
"name": {"anyOf": [{"type": "string"}, {"type": "null"}]},
},
"required": ["owner", "repo"],
},
"scope": "write",
},
]
@@ -724,3 +920,180 @@ async def delete_file(
await client.delete_file(owner, repo, path, sha, message, branch)
result = {"success": True, "message": f"File '{path}' deleted successfully"}
return json.dumps(result, indent=2)
# ---------------------------------------------------------------------------
# F.17 ergonomics: batch files, tree, search, compare, releases, fork
# ---------------------------------------------------------------------------
async def create_files(
client: GiteaClient,
owner: str,
repo: str,
files: list[dict[str, Any]],
message: str,
branch: str | None = None,
new_branch: str | None = None,
) -> str:
"""Apply a batch of create / update / delete file operations in a single commit.
Each entry in ``files`` is normalised so Gitea's batch endpoint
receives base64-encoded content with the ``content_is_base64`` flag
stripped (the server expects base64). Validation:
* ``operation`` is ``create`` / ``update`` / ``delete``.
* ``path`` required on every entry.
* ``content`` required on create / update.
* ``sha`` required on update / delete (current file SHA).
"""
prepared: list[dict[str, Any]] = []
errors: list[dict[str, Any]] = []
for idx, f in enumerate(files or []):
op = (f.get("operation") or "").strip().lower()
path = f.get("path")
if op not in {"create", "update", "delete"}:
errors.append({"index": idx, "path": path, "error": f"invalid_operation:{op!r}"})
continue
if not path:
errors.append({"index": idx, "error": "missing_path"})
continue
if op in {"update", "delete"} and not f.get("sha"):
errors.append({"index": idx, "path": path, "error": f"missing_sha_for_{op}"})
continue
entry: dict[str, Any] = {"operation": op, "path": path}
if op in {"create", "update"}:
content = f.get("content")
if content is None:
errors.append({"index": idx, "path": path, "error": "missing_content"})
continue
try:
entry["content"] = client._normalise_file_content(
content, bool(f.get("content_is_base64", False))
)
except ValueError as exc:
errors.append({"index": idx, "path": path, "error": str(exc)})
continue
if f.get("sha"):
entry["sha"] = f["sha"]
if f.get("from_path"): # Gitea also supports rename via from_path
entry["from_path"] = f["from_path"]
prepared.append(entry)
if errors:
return json.dumps(
{
"success": False,
"error": "validation_failed",
"errors": errors,
"total": len(files or []),
"prepared": len(prepared),
},
indent=2,
)
payload: dict[str, Any] = {"message": message, "files": prepared}
if branch:
payload["branch"] = branch
if new_branch:
payload["new_branch"] = new_branch
result = await client.change_files(owner, repo, payload)
return json.dumps(
{
"success": True,
"message": f"Batched {len(prepared)} file operation(s) into one commit",
"result": result,
},
indent=2,
)
async def get_tree(
client: GiteaClient,
owner: str,
repo: str,
sha: str = "HEAD",
recursive: bool = False,
page: int = 1,
per_page: int = 100,
) -> str:
tree = await client.get_tree(
owner, repo, sha, recursive=recursive, page=page, per_page=per_page
)
return json.dumps({"success": True, "tree": tree}, indent=2)
async def search_code(
client: GiteaClient,
keyword: str,
owner: str | None = None,
repo: str | None = None,
page: int = 1,
per_page: int = 30,
) -> str:
result = await client.search_code(
keyword=keyword, owner=owner, repo=repo, page=page, per_page=per_page
)
return json.dumps({"success": True, "result": result}, indent=2)
async def compare(
client: GiteaClient,
owner: str,
repo: str,
base: str,
head: str,
) -> str:
diff = await client.compare(owner, repo, base, head)
return json.dumps({"success": True, "compare": diff}, indent=2)
async def list_releases(
client: GiteaClient,
owner: str,
repo: str,
page: int = 1,
per_page: int = 30,
) -> str:
releases = await client.list_releases(owner, repo, page=page, per_page=per_page)
return json.dumps({"success": True, "releases": releases}, indent=2)
async def create_release(
client: GiteaClient,
owner: str,
repo: str,
tag_name: str,
name: str | None = None,
body: str | None = None,
target_commitish: str | None = None,
draft: bool = False,
prerelease: bool = False,
) -> str:
data: dict[str, Any] = {
"tag_name": tag_name,
"draft": draft,
"prerelease": prerelease,
}
if name is not None:
data["name"] = name
if body is not None:
data["body"] = body
if target_commitish is not None:
data["target_commitish"] = target_commitish
release = await client.create_release(owner, repo, data)
return json.dumps({"success": True, "release": release}, indent=2)
async def fork_repository(
client: GiteaClient,
owner: str,
repo: str,
organization: str | None = None,
name: str | None = None,
) -> str:
fork = await client.fork_repository(owner, repo, organization=organization, name=name)
return json.dumps({"success": True, "fork": fork}, indent=2)

View File

@@ -7,6 +7,8 @@ Modular handlers for better organization and maintainability.
from typing import Any
import aiohttp
from plugins.base import BasePlugin
from plugins.gitea import handlers
from plugins.gitea.client import GiteaClient
@@ -160,3 +162,61 @@ class GiteaPlugin(BasePlugin):
return {"healthy": True, "message": "Gitea instance is accessible"}
except Exception as e:
return {"healthy": False, "message": f"Gitea health check failed: {str(e)}"}
async def probe_credential_capabilities(self) -> dict[str, Any]:
"""F.7e — report what the Gitea access token grants.
Gitea tokens carry OAuth-style scope labels. Scopes are surfaced
two ways (different Gitea versions disagree) and we accept both:
* ``X-OAuth-Scopes`` header on the response of any authenticated
endpoint — the format used by GitHub-compatible clients.
* ``capabilities`` key in ``meta`` (rare; ignored here — the
header is the universal path).
We hit ``GET /api/v1/user`` which every token that can do
anything useful on Gitea is allowed to call. Failures return
``probe_available=False`` with a reason rather than raising,
so the badge falls back to "probe unavailable" cleanly.
"""
url = f"{self.client.api_base}/user"
headers = self.client._get_headers()
try:
timeout = aiohttp.ClientTimeout(total=15)
async with aiohttp.ClientSession(timeout=timeout) as session:
async with session.get(url, headers=headers) as resp:
if resp.status >= 400:
body = (await resp.text())[:200]
return {
"probe_available": False,
"granted": [],
"source": "gitea_oauth_scopes",
"reason": f"user_endpoint_http_{resp.status}: {body}",
}
scopes_header = resp.headers.get("X-OAuth-Scopes", "")
except Exception as exc: # noqa: BLE001
return {
"probe_available": False,
"granted": [],
"source": "gitea_oauth_scopes",
"reason": f"probe_failed: {exc}",
}
granted = [s.strip() for s in scopes_header.split(",") if s.strip()]
if not granted:
# Gitea instances without per-token scopes (unset header)
# grant full access to whatever the user account itself
# can do. Report that honestly rather than claiming nothing.
return {
"probe_available": False,
"granted": [],
"source": "gitea_oauth_scopes",
"reason": "scopes_header_absent_or_empty",
}
return {
"probe_available": True,
"granted": sorted(granted),
"source": "gitea_oauth_scopes",
}

View File

@@ -1,5 +1,4 @@
"""
n8n REST API Client
"""n8n REST API Client.
Handles all HTTP communication with n8n REST API.
Separates API communication from business logic.
@@ -11,6 +10,64 @@ from typing import Any
import aiohttp
class N8nApiError(Exception):
"""Base exception for n8n API errors with structured error info."""
def __init__(
self,
message: str,
*,
error_code: str = "API_ERROR",
status_code: int = 0,
hint: str = "",
) -> None:
super().__init__(message)
self.error_code = error_code
self.status_code = status_code
self.hint = hint
def to_dict(self) -> dict[str, Any]:
d: dict[str, Any] = {"error_code": self.error_code, "message": str(self)}
if self.status_code:
d["status_code"] = self.status_code
if self.hint:
d["hint"] = self.hint
return d
class N8nAuthError(N8nApiError):
"""Raised on 401/403 — invalid or under-scoped API key."""
def __init__(self, message: str, *, status_code: int = 401, hint: str = "") -> None:
super().__init__(
message,
error_code="AUTH_FAILED",
status_code=status_code,
hint=hint or "Check the API key in n8n → Settings → API → API Keys.",
)
class N8nNotFoundError(N8nApiError):
"""Raised on 404 — resource doesn't exist."""
def __init__(self, message: str) -> None:
super().__init__(message, error_code="NOT_FOUND", status_code=404)
class N8nValidationError(N8nApiError):
"""Raised on 400/422 — bad payload."""
def __init__(self, message: str, *, status_code: int = 400) -> None:
super().__init__(message, error_code="VALIDATION_ERROR", status_code=status_code)
class N8nConnectionError(N8nApiError):
"""Raised on network-level failures."""
def __init__(self, message: str) -> None:
super().__init__(message, error_code="CONNECTION_ERROR")
class N8nClient:
"""
n8n REST API client for HTTP communication.
@@ -66,69 +123,63 @@ class N8nClient:
json_data: dict | None = None,
headers_override: dict | None = None,
) -> Any:
"""
Make authenticated request to n8n REST API.
Args:
method: HTTP method (GET, POST, PUT, DELETE, PATCH)
endpoint: API endpoint (without base URL)
params: Query parameters
json_data: JSON body data
headers_override: Override default headers
Returns:
API response (dict, list, or None)
"""Make authenticated request to n8n REST API.
Raises:
Exception: On API errors with status code and message
N8nAuthError: On 401/403.
N8nNotFoundError: On 404.
N8nValidationError: On 400/422.
N8nApiError: On other 4xx/5xx.
N8nConnectionError: On network-level failure.
"""
# Build full URL
url = f"{self.api_base}/{endpoint.lstrip('/')}"
# Setup headers
headers = self._get_headers(headers_override)
# Filter out None values from params
if params:
params = {k: v for k, v in params.items() if v is not None}
# Filter None values from JSON data
if json_data:
json_data = {k: v for k, v in json_data.items() if v is not None}
# Make request
self.logger.debug(f"{method} {url}")
self.logger.debug(f"Params: {params}")
self.logger.debug(f"Data: {json_data}")
self.logger.debug("%s %s", method, url)
async with (
aiohttp.ClientSession() as session,
session.request(
method=method, url=url, params=params, json=json_data, headers=headers
) as response,
):
# Log response
self.logger.debug(f"Response status: {response.status}")
try:
async with (
aiohttp.ClientSession() as session,
session.request(
method=method, url=url, params=params, json=json_data, headers=headers
) as response,
):
self.logger.debug("Response status: %d", response.status)
# Handle empty responses (e.g., 204 No Content)
if response.status == 204:
return {"success": True, "message": "Operation completed successfully"}
if response.status == 204:
return {"success": True, "message": "Operation completed successfully"}
try:
response_data = await response.json()
except Exception:
response_text = await response.text()
if response.status >= 400:
self._raise_for_status(response.status, response_text)
return {"success": True, "message": response_text}
# Try to parse JSON response
try:
response_data = await response.json()
except Exception:
response_text = await response.text()
if response.status >= 400:
raise Exception(f"n8n API error (status {response.status}): {response_text}")
return {"success": True, "message": response_text}
error_msg = response_data.get("message", str(response_data))
self._raise_for_status(response.status, error_msg)
# Check for errors
if response.status >= 400:
error_msg = response_data.get("message", str(response_data))
raise Exception(f"n8n API error (status {response.status}): {error_msg}")
return response_data
return response_data
except (aiohttp.ClientError, OSError) as exc:
raise N8nConnectionError(f"Cannot reach n8n at {self.site_url}: {exc}") from exc
@staticmethod
def _raise_for_status(status: int, message: str) -> None:
if status in (401, 403):
raise N8nAuthError(message, status_code=status)
if status == 404:
raise N8nNotFoundError(message)
if status in (400, 422):
raise N8nValidationError(message, status_code=status)
raise N8nApiError(message, error_code="API_ERROR", status_code=status)
# =====================
# WORKFLOW ENDPOINTS
@@ -176,8 +227,18 @@ class N8nClient:
"""Execute a workflow manually"""
return await self.request("POST", f"workflows/{workflow_id}/run", json_data=data or {})
async def transfer_workflow(
self, workflow_id: str, destination_project_id: str
) -> dict[str, Any]:
"""Transfer workflow to another project (Enterprise)."""
return await self.request(
"PUT",
f"workflows/{workflow_id}/transfer",
json_data={"destinationProjectId": destination_project_id},
)
async def get_workflow_tags(self, workflow_id: str) -> list[dict[str, Any]]:
"""Get tags assigned to a workflow"""
"""Get tags assigned to a workflow."""
workflow = await self.get_workflow(workflow_id)
return workflow.get("tags", [])
@@ -189,14 +250,16 @@ class N8nClient:
self,
workflow_id: str | None = None,
status: str | None = None,
project_id: str | None = None,
include_data: bool = False,
limit: int = 20,
cursor: str | None = None,
) -> dict[str, Any]:
"""List workflow executions with filters"""
"""List workflow executions with filters."""
params = {
"workflowId": workflow_id,
"status": status,
"projectId": project_id,
"includeData": str(include_data).lower(),
"limit": limit,
"cursor": cursor,
@@ -391,12 +454,17 @@ class N8nClient:
data["variables"] = variables
return await self.request("POST", "source-control/pull", json_data=data)
async def get_current_user(self) -> dict[str, Any]:
"""Get current authenticated user info (for capability probe)."""
return await self.request("GET", "user")
async def health_check(self) -> dict[str, Any]:
"""Check n8n instance health"""
# Use direct URL, not API base
"""Check n8n instance health."""
url = f"{self.site_url}/healthz"
async with aiohttp.ClientSession() as session, session.get(url) as response:
if response.status == 200:
return {"healthy": True, "status": "ok"}
else:
try:
async with aiohttp.ClientSession() as session, session.get(url) as response:
if response.status == 200:
return {"healthy": True, "status": "ok"}
return {"healthy": False, "status": f"unhealthy (status {response.status})"}
except (aiohttp.ClientError, OSError) as exc:
raise N8nConnectionError(f"Cannot reach n8n health endpoint at {url}: {exc}") from exc

View File

@@ -1,9 +1,15 @@
"""Credentials Handler - manages n8n credentials"""
"""Credentials Handler - manages n8n credentials."""
import json
from typing import Any
from plugins.n8n.client import N8nClient
from plugins.n8n.client import N8nApiError, N8nClient
def _error_json(exc: Exception) -> str:
if isinstance(exc, N8nApiError):
return json.dumps({"success": False, **exc.to_dict()}, indent=2)
return json.dumps({"success": False, "error": str(exc)}, indent=2)
def get_tool_specifications() -> list[dict[str, Any]]:
@@ -107,7 +113,7 @@ async def get_credential(client: N8nClient, credential_id: str) -> str:
}
return json.dumps(result, indent=2)
except Exception as e:
return json.dumps({"success": False, "error": str(e)}, indent=2)
return _error_json(e)
async def create_credential(client: N8nClient, name: str, type: str, data: dict[str, Any]) -> str:
@@ -126,7 +132,7 @@ async def create_credential(client: N8nClient, name: str, type: str, data: dict[
}
return json.dumps(result, indent=2)
except Exception as e:
return json.dumps({"success": False, "error": str(e)}, indent=2)
return _error_json(e)
async def delete_credential(client: N8nClient, credential_id: str) -> str:
@@ -137,7 +143,7 @@ async def delete_credential(client: N8nClient, credential_id: str) -> str:
{"success": True, "message": f"Credential {credential_id} deleted"}, indent=2
)
except Exception as e:
return json.dumps({"success": False, "error": str(e)}, indent=2)
return _error_json(e)
async def get_credential_schema(client: N8nClient, credential_type: str) -> str:
@@ -148,7 +154,7 @@ async def get_credential_schema(client: N8nClient, credential_type: str) -> str:
{"success": True, "credential_type": credential_type, "schema": schema}, indent=2
)
except Exception as e:
return json.dumps({"success": False, "error": str(e)}, indent=2)
return _error_json(e)
async def transfer_credential(
@@ -165,4 +171,4 @@ async def transfer_credential(
indent=2,
)
except Exception as e:
return json.dumps({"success": False, "error": str(e)}, indent=2)
return _error_json(e)

View File

@@ -1,10 +1,16 @@
"""Execution Handler - manages n8n workflow executions"""
"""Execution Handler - manages n8n workflow executions."""
import asyncio
import json
from typing import Any
from plugins.n8n.client import N8nClient
from plugins.n8n.client import N8nApiError, N8nClient
def _error_json(exc: Exception) -> str:
if isinstance(exc, N8nApiError):
return json.dumps({"success": False, **exc.to_dict()}, indent=2)
return json.dumps({"success": False, "error": str(exc)}, indent=2)
def get_tool_specifications() -> list[dict[str, Any]]:
@@ -27,6 +33,10 @@ def get_tool_specifications() -> list[dict[str, Any]]:
"enum": ["success", "error", "waiting", "running", "new"],
"description": "OPTIONAL: Filter by execution status. Omit for all statuses.",
},
"project_id": {
"type": "string",
"description": "OPTIONAL: Filter by project ID (Enterprise). Omit for all projects.",
},
"include_data": {
"type": "boolean",
"description": "Include full execution data",
@@ -200,15 +210,17 @@ async def list_executions(
client: N8nClient,
workflow_id: str | None = None,
status: str | None = None,
project_id: str | None = None,
include_data: bool = False,
limit: int = 20,
cursor: str | None = None,
) -> str:
"""List workflow executions"""
"""List workflow executions."""
try:
response = await client.list_executions(
workflow_id=workflow_id,
status=status,
project_id=project_id,
include_data=include_data,
limit=limit,
cursor=cursor,
@@ -239,7 +251,7 @@ async def list_executions(
return json.dumps(result, indent=2)
except Exception as e:
return json.dumps({"success": False, "error": str(e)}, indent=2)
return _error_json(e)
async def get_execution(client: N8nClient, execution_id: str, include_data: bool = True) -> str:
@@ -270,7 +282,7 @@ async def get_execution(client: N8nClient, execution_id: str, include_data: bool
return json.dumps(result, indent=2)
except Exception as e:
return json.dumps({"success": False, "error": str(e)}, indent=2)
return _error_json(e)
async def delete_execution(client: N8nClient, execution_id: str) -> str:
@@ -283,7 +295,7 @@ async def delete_execution(client: N8nClient, execution_id: str) -> str:
return json.dumps(result, indent=2)
except Exception as e:
return json.dumps({"success": False, "error": str(e)}, indent=2)
return _error_json(e)
async def delete_executions(client: N8nClient, execution_ids: list[str]) -> str:
@@ -309,7 +321,7 @@ async def delete_executions(client: N8nClient, execution_ids: list[str]) -> str:
return json.dumps(result, indent=2)
except Exception as e:
return json.dumps({"success": False, "error": str(e)}, indent=2)
return _error_json(e)
async def stop_execution(client: N8nClient, execution_id: str) -> str:
@@ -322,7 +334,7 @@ async def stop_execution(client: N8nClient, execution_id: str) -> str:
return json.dumps(result, indent=2)
except Exception as e:
return json.dumps({"success": False, "error": str(e)}, indent=2)
return _error_json(e)
async def retry_execution(client: N8nClient, execution_id: str) -> str:
@@ -366,7 +378,7 @@ async def retry_execution(client: N8nClient, execution_id: str) -> str:
return json.dumps(result, indent=2)
except Exception as e:
return json.dumps({"success": False, "error": str(e)}, indent=2)
return _error_json(e)
async def get_execution_data(client: N8nClient, execution_id: str) -> str:
@@ -390,7 +402,7 @@ async def get_execution_data(client: N8nClient, execution_id: str) -> str:
return json.dumps(result, indent=2)
except Exception as e:
return json.dumps({"success": False, "error": str(e)}, indent=2)
return _error_json(e)
async def wait_for_execution(
@@ -444,4 +456,4 @@ async def wait_for_execution(
return json.dumps(result, indent=2)
except Exception as e:
return json.dumps({"success": False, "error": str(e)}, indent=2)
return _error_json(e)

View File

@@ -1,9 +1,15 @@
"""Projects Handler - manages n8n projects (Enterprise/Pro)"""
"""Projects Handler - manages n8n projects (Enterprise/Pro)."""
import json
from typing import Any
from plugins.n8n.client import N8nClient
from plugins.n8n.client import N8nApiError, N8nClient
def _error_json(exc: Exception) -> str:
if isinstance(exc, N8nApiError):
return json.dumps({"success": False, **exc.to_dict()}, indent=2)
return json.dumps({"success": False, "error": str(exc)}, indent=2)
def get_tool_specifications() -> list[dict[str, Any]]:
@@ -146,7 +152,7 @@ async def list_projects(client: N8nClient, limit: int = 100, cursor: str | None
}
return json.dumps(result, indent=2)
except Exception as e:
return json.dumps({"success": False, "error": str(e)}, indent=2)
return _error_json(e)
async def get_project(client: N8nClient, project_id: str) -> str:
@@ -157,7 +163,7 @@ async def get_project(client: N8nClient, project_id: str) -> str:
indent=2,
)
except Exception as e:
return json.dumps({"success": False, "error": str(e)}, indent=2)
return _error_json(e)
async def create_project(client: N8nClient, name: str) -> str:
@@ -172,7 +178,7 @@ async def create_project(client: N8nClient, name: str) -> str:
indent=2,
)
except Exception as e:
return json.dumps({"success": False, "error": str(e)}, indent=2)
return _error_json(e)
async def update_project(client: N8nClient, project_id: str, name: str) -> str:
@@ -187,7 +193,7 @@ async def update_project(client: N8nClient, project_id: str, name: str) -> str:
indent=2,
)
except Exception as e:
return json.dumps({"success": False, "error": str(e)}, indent=2)
return _error_json(e)
async def delete_project(client: N8nClient, project_id: str) -> str:
@@ -195,7 +201,7 @@ async def delete_project(client: N8nClient, project_id: str) -> str:
await client.delete_project(project_id)
return json.dumps({"success": True, "message": f"Project {project_id} deleted"}, indent=2)
except Exception as e:
return json.dumps({"success": False, "error": str(e)}, indent=2)
return _error_json(e)
async def add_project_users(client: N8nClient, project_id: str, users: list[dict[str, str]]) -> str:
@@ -207,7 +213,7 @@ async def add_project_users(client: N8nClient, project_id: str, users: list[dict
indent=2,
)
except Exception as e:
return json.dumps({"success": False, "error": str(e)}, indent=2)
return _error_json(e)
async def change_project_user_role(
@@ -223,7 +229,7 @@ async def change_project_user_role(
indent=2,
)
except Exception as e:
return json.dumps({"success": False, "error": str(e)}, indent=2)
return _error_json(e)
async def remove_project_user(client: N8nClient, project_id: str, user_id: str) -> str:
@@ -234,4 +240,4 @@ async def remove_project_user(client: N8nClient, project_id: str, user_id: str)
indent=2,
)
except Exception as e:
return json.dumps({"success": False, "error": str(e)}, indent=2)
return _error_json(e)

View File

@@ -1,9 +1,15 @@
"""System Handler - manages n8n system operations (audit, source control, health)"""
"""System Handler - manages n8n system operations (audit, source control, health)."""
import json
from typing import Any
from plugins.n8n.client import N8nClient
from plugins.n8n.client import N8nApiError, N8nClient
def _error_json(exc: Exception) -> str:
if isinstance(exc, N8nApiError):
return json.dumps({"success": False, **exc.to_dict()}, indent=2)
return json.dumps({"success": False, "error": str(exc)}, indent=2)
def get_tool_specifications() -> list[dict[str, Any]]:
@@ -84,7 +90,7 @@ async def run_security_audit(client: N8nClient, categories: list[str] | None = N
return json.dumps(audit_data, indent=2)
except Exception as e:
return json.dumps({"success": False, "error": str(e)}, indent=2)
return _error_json(e)
async def source_control_pull(
@@ -99,7 +105,7 @@ async def source_control_pull(
indent=2,
)
except Exception as e:
return json.dumps({"success": False, "error": str(e)}, indent=2)
return _error_json(e)
async def get_instance_info(client: N8nClient) -> str:
@@ -115,13 +121,12 @@ async def get_instance_info(client: N8nClient) -> str:
except Exception:
pass
# Get current user to verify connectivity
try:
user = await client.request("GET", "user")
user = await client.get_current_user()
info["current_user"] = {
"id": user.get("id"),
"email": user.get("email"),
"role": user.get("role"),
"role": user.get("role") or user.get("globalRole"),
}
except Exception:
pass
@@ -131,7 +136,7 @@ async def get_instance_info(client: N8nClient) -> str:
return json.dumps({"success": True, "instance_info": info}, indent=2)
except Exception as e:
return json.dumps({"success": False, "error": str(e)}, indent=2)
return _error_json(e)
async def health_check(client: N8nClient) -> str:

View File

@@ -1,9 +1,15 @@
"""Tags Handler - manages n8n tags"""
"""Tags Handler - manages n8n tags."""
import json
from typing import Any
from plugins.n8n.client import N8nClient
from plugins.n8n.client import N8nApiError, N8nClient
def _error_json(exc: Exception) -> str:
if isinstance(exc, N8nApiError):
return json.dumps({"success": False, **exc.to_dict()}, indent=2)
return json.dumps({"success": False, "error": str(exc)}, indent=2)
def get_tool_specifications() -> list[dict[str, Any]]:
@@ -99,7 +105,7 @@ async def list_tags(client: N8nClient, limit: int = 100, cursor: str | None = No
}
return json.dumps(result, indent=2)
except Exception as e:
return json.dumps({"success": False, "error": str(e)}, indent=2)
return _error_json(e)
async def get_tag(client: N8nClient, tag_id: str) -> str:
@@ -109,7 +115,7 @@ async def get_tag(client: N8nClient, tag_id: str) -> str:
{"success": True, "tag": {"id": tag.get("id"), "name": tag.get("name")}}, indent=2
)
except Exception as e:
return json.dumps({"success": False, "error": str(e)}, indent=2)
return _error_json(e)
async def create_tag(client: N8nClient, name: str) -> str:
@@ -124,7 +130,7 @@ async def create_tag(client: N8nClient, name: str) -> str:
indent=2,
)
except Exception as e:
return json.dumps({"success": False, "error": str(e)}, indent=2)
return _error_json(e)
async def update_tag(client: N8nClient, tag_id: str, name: str) -> str:
@@ -139,7 +145,7 @@ async def update_tag(client: N8nClient, tag_id: str, name: str) -> str:
indent=2,
)
except Exception as e:
return json.dumps({"success": False, "error": str(e)}, indent=2)
return _error_json(e)
async def delete_tag(client: N8nClient, tag_id: str) -> str:
@@ -147,7 +153,7 @@ async def delete_tag(client: N8nClient, tag_id: str) -> str:
await client.delete_tag(tag_id)
return json.dumps({"success": True, "message": f"Tag {tag_id} deleted"}, indent=2)
except Exception as e:
return json.dumps({"success": False, "error": str(e)}, indent=2)
return _error_json(e)
async def delete_tags(client: N8nClient, tag_ids: list[str]) -> str:
@@ -164,4 +170,4 @@ async def delete_tags(client: N8nClient, tag_ids: list[str]) -> str:
indent=2,
)
except Exception as e:
return json.dumps({"success": False, "error": str(e)}, indent=2)
return _error_json(e)

View File

@@ -1,9 +1,15 @@
"""Users Handler - manages n8n users"""
"""Users Handler - manages n8n users."""
import json
from typing import Any
from plugins.n8n.client import N8nClient
from plugins.n8n.client import N8nApiError, N8nClient
def _error_json(exc: Exception) -> str:
if isinstance(exc, N8nApiError):
return json.dumps({"success": False, **exc.to_dict()}, indent=2)
return json.dumps({"success": False, "error": str(exc)}, indent=2)
def get_tool_specifications() -> list[dict[str, Any]]:
@@ -115,7 +121,7 @@ async def list_users(
}
return json.dumps(result, indent=2)
except Exception as e:
return json.dumps({"success": False, "error": str(e)}, indent=2)
return _error_json(e)
async def get_user(client: N8nClient, user_id: str, include_role: bool = True) -> str:
@@ -136,7 +142,7 @@ async def get_user(client: N8nClient, user_id: str, include_role: bool = True) -
indent=2,
)
except Exception as e:
return json.dumps({"success": False, "error": str(e)}, indent=2)
return _error_json(e)
async def create_user(client: N8nClient, email: str, role: str = "global:member") -> str:
@@ -152,7 +158,7 @@ async def create_user(client: N8nClient, email: str, role: str = "global:member"
indent=2,
)
except Exception as e:
return json.dumps({"success": False, "error": str(e)}, indent=2)
return _error_json(e)
async def delete_user(client: N8nClient, user_id: str) -> str:
@@ -160,7 +166,7 @@ async def delete_user(client: N8nClient, user_id: str) -> str:
await client.delete_user(user_id)
return json.dumps({"success": True, "message": f"User {user_id} deleted"}, indent=2)
except Exception as e:
return json.dumps({"success": False, "error": str(e)}, indent=2)
return _error_json(e)
async def change_user_role(client: N8nClient, user_id: str, new_role: str) -> str:
@@ -170,4 +176,4 @@ async def change_user_role(client: N8nClient, user_id: str, new_role: str) -> st
{"success": True, "message": f"User {user_id} role changed to {new_role}"}, indent=2
)
except Exception as e:
return json.dumps({"success": False, "error": str(e)}, indent=2)
return _error_json(e)

View File

@@ -1,9 +1,15 @@
"""Variables Handler - manages n8n environment variables"""
"""Variables Handler - manages n8n environment variables."""
import json
from typing import Any
from plugins.n8n.client import N8nClient
from plugins.n8n.client import N8nApiError, N8nClient
def _error_json(exc: Exception) -> str:
if isinstance(exc, N8nApiError):
return json.dumps({"success": False, **exc.to_dict()}, indent=2)
return json.dumps({"success": False, "error": str(exc)}, indent=2)
def get_tool_specifications() -> list[dict[str, Any]]:
@@ -106,7 +112,7 @@ async def list_variables(client: N8nClient, limit: int = 100, cursor: str | None
}
return json.dumps(result, indent=2)
except Exception as e:
return json.dumps({"success": False, "error": str(e)}, indent=2)
return _error_json(e)
async def get_variable(client: N8nClient, key: str) -> str:
@@ -120,7 +126,7 @@ async def get_variable(client: N8nClient, key: str) -> str:
indent=2,
)
except Exception as e:
return json.dumps({"success": False, "error": str(e)}, indent=2)
return _error_json(e)
async def create_variable(client: N8nClient, key: str, value: str) -> str:
@@ -135,7 +141,7 @@ async def create_variable(client: N8nClient, key: str, value: str) -> str:
indent=2,
)
except Exception as e:
return json.dumps({"success": False, "error": str(e)}, indent=2)
return _error_json(e)
async def update_variable(client: N8nClient, key: str, value: str) -> str:
@@ -150,7 +156,7 @@ async def update_variable(client: N8nClient, key: str, value: str) -> str:
indent=2,
)
except Exception as e:
return json.dumps({"success": False, "error": str(e)}, indent=2)
return _error_json(e)
async def delete_variable(client: N8nClient, key: str) -> str:
@@ -158,7 +164,7 @@ async def delete_variable(client: N8nClient, key: str) -> str:
await client.delete_variable(key)
return json.dumps({"success": True, "message": f"Variable '{key}' deleted"}, indent=2)
except Exception as e:
return json.dumps({"success": False, "error": str(e)}, indent=2)
return _error_json(e)
async def set_variables(client: N8nClient, variables: dict[str, str]) -> str:
@@ -190,4 +196,4 @@ async def set_variables(client: N8nClient, variables: dict[str, str]) -> str:
indent=2,
)
except Exception as e:
return json.dumps({"success": False, "error": str(e)}, indent=2)
return _error_json(e)

View File

@@ -1,9 +1,9 @@
"""Workflow Handler - manages n8n workflows"""
"""Workflow Handler - manages n8n workflows."""
import json
from typing import Any
from plugins.n8n.client import N8nClient
from plugins.n8n.client import N8nApiError, N8nClient
def get_tool_specifications() -> list[dict[str, Any]]:
@@ -318,12 +318,45 @@ def get_tool_specifications() -> list[dict[str, Any]]:
},
"scope": "write",
},
# === TRANSFER WORKFLOW TO PROJECT (Enterprise) ===
{
"name": "transfer_workflow_to_project",
"method_name": "transfer_workflow_to_project",
"description": (
"Transfer a workflow to a different project (Enterprise/Pro). "
"The destination project must exist and the API key must have "
"access to both the source and destination projects."
),
"schema": {
"type": "object",
"properties": {
"workflow_id": {
"type": "string",
"description": "Workflow ID to transfer",
"minLength": 1,
},
"destination_project_id": {
"type": "string",
"description": "Target project ID",
"minLength": 1,
},
},
"required": ["workflow_id", "destination_project_id"],
},
"scope": "write",
},
]
# === HANDLER FUNCTIONS ===
def _error_json(exc: Exception) -> str:
if isinstance(exc, N8nApiError):
return json.dumps({"success": False, **exc.to_dict()}, indent=2)
return json.dumps({"success": False, "error": str(exc)}, indent=2)
async def list_workflows(
client: N8nClient,
active: bool | None = None,
@@ -361,7 +394,7 @@ async def list_workflows(
return json.dumps(result, indent=2)
except Exception as e:
return json.dumps({"success": False, "error": str(e)}, indent=2)
return _error_json(e)
async def get_workflow(client: N8nClient, workflow_id: str) -> str:
@@ -388,7 +421,7 @@ async def get_workflow(client: N8nClient, workflow_id: str) -> str:
return json.dumps(result, indent=2)
except Exception as e:
return json.dumps({"success": False, "error": str(e)}, indent=2)
return _error_json(e)
async def create_workflow(
@@ -429,7 +462,7 @@ async def create_workflow(
return json.dumps(result, indent=2)
except Exception as e:
return json.dumps({"success": False, "error": str(e)}, indent=2)
return _error_json(e)
async def update_workflow(
@@ -470,7 +503,7 @@ async def update_workflow(
return json.dumps(result, indent=2)
except Exception as e:
return json.dumps({"success": False, "error": str(e)}, indent=2)
return _error_json(e)
async def delete_workflow(client: N8nClient, workflow_id: str) -> str:
@@ -483,7 +516,7 @@ async def delete_workflow(client: N8nClient, workflow_id: str) -> str:
return json.dumps(result, indent=2)
except Exception as e:
return json.dumps({"success": False, "error": str(e)}, indent=2)
return _error_json(e)
async def activate_workflow(client: N8nClient, workflow_id: str) -> str:
@@ -504,7 +537,7 @@ async def activate_workflow(client: N8nClient, workflow_id: str) -> str:
return json.dumps(result, indent=2)
except Exception as e:
return json.dumps({"success": False, "error": str(e)}, indent=2)
return _error_json(e)
async def deactivate_workflow(client: N8nClient, workflow_id: str) -> str:
@@ -525,7 +558,7 @@ async def deactivate_workflow(client: N8nClient, workflow_id: str) -> str:
return json.dumps(result, indent=2)
except Exception as e:
return json.dumps({"success": False, "error": str(e)}, indent=2)
return _error_json(e)
async def execute_workflow(client: N8nClient, workflow_id: str) -> str:
@@ -546,7 +579,7 @@ async def execute_workflow(client: N8nClient, workflow_id: str) -> str:
return json.dumps(result, indent=2)
except Exception as e:
return json.dumps({"success": False, "error": str(e)}, indent=2)
return _error_json(e)
async def execute_workflow_with_data(
@@ -570,7 +603,7 @@ async def execute_workflow_with_data(
return json.dumps(result, indent=2)
except Exception as e:
return json.dumps({"success": False, "error": str(e)}, indent=2)
return _error_json(e)
async def duplicate_workflow(client: N8nClient, workflow_id: str, new_name: str) -> str:
@@ -599,7 +632,7 @@ async def duplicate_workflow(client: N8nClient, workflow_id: str, new_name: str)
return json.dumps(result, indent=2)
except Exception as e:
return json.dumps({"success": False, "error": str(e)}, indent=2)
return _error_json(e)
async def export_workflow(client: N8nClient, workflow_id: str) -> str:
@@ -613,7 +646,7 @@ async def export_workflow(client: N8nClient, workflow_id: str) -> str:
return json.dumps(result, indent=2)
except Exception as e:
return json.dumps({"success": False, "error": str(e)}, indent=2)
return _error_json(e)
async def import_workflow(
@@ -646,7 +679,7 @@ async def import_workflow(
return json.dumps(result, indent=2)
except Exception as e:
return json.dumps({"success": False, "error": str(e)}, indent=2)
return _error_json(e)
async def get_workflow_tags(client: N8nClient, workflow_id: str) -> str:
@@ -659,7 +692,7 @@ async def get_workflow_tags(client: N8nClient, workflow_id: str) -> str:
return json.dumps(result, indent=2)
except Exception as e:
return json.dumps({"success": False, "error": str(e)}, indent=2)
return _error_json(e)
async def set_workflow_tags(
@@ -693,4 +726,23 @@ async def set_workflow_tags(
return json.dumps(result, indent=2)
except Exception as e:
return json.dumps({"success": False, "error": str(e)}, indent=2)
return _error_json(e)
async def transfer_workflow_to_project(
client: N8nClient, workflow_id: str, destination_project_id: str
) -> str:
"""Transfer a workflow to another project (Enterprise)."""
try:
await client.transfer_workflow(workflow_id, destination_project_id)
return json.dumps(
{
"success": True,
"message": f"Workflow {workflow_id} transferred to project {destination_project_id}",
"workflow_id": workflow_id,
"destination_project_id": destination_project_id,
},
indent=2,
)
except Exception as e:
return _error_json(e)

View File

@@ -18,8 +18,8 @@ class N8nPlugin(BasePlugin):
n8n Automation Plugin - Comprehensive workflow management.
Provides complete n8n management capabilities including:
- Workflow management (CRUD, activate, deactivate, execute)
- Execution monitoring (list, get, delete, retry, wait)
- Workflow management (CRUD, activate, deactivate, execute, transfer)
- Execution monitoring (list, get, delete, retry, wait, project filter)
- Credential management (get, create, delete, schema, transfer)
- Tag management (CRUD, bulk delete)
- User management (CRUD, roles)
@@ -27,7 +27,7 @@ class N8nPlugin(BasePlugin):
- Variable management (CRUD, bulk set) - Enterprise/Pro
- System operations (audit, source control, health)
Total: 56 tools
Total: 57 tools
"""
@staticmethod
@@ -69,7 +69,7 @@ class N8nPlugin(BasePlugin):
specs = []
# Collect specifications from all handlers
specs.extend(handlers.workflows.get_tool_specifications()) # 14 tools
specs.extend(handlers.workflows.get_tool_specifications()) # 15 tools
specs.extend(handlers.executions.get_tool_specifications()) # 8 tools
specs.extend(handlers.credentials.get_tool_specifications()) # 5 tools
specs.extend(handlers.tags.get_tool_specifications()) # 6 tools
@@ -118,24 +118,62 @@ class N8nPlugin(BasePlugin):
# Method not found in any handler
raise AttributeError(f"'{self.__class__.__name__}' object has no attribute '{name}'")
async def probe_credential_capabilities(self) -> dict[str, Any]:
"""F.7e — probe the n8n API key's effective permissions.
Calls ``GET /api/v1/user`` which returns the current user's
``globalRole`` (and ``globalScopes`` on enterprise). If the key
is invalid or the endpoint is unreachable we return a graceful
fallback so the dashboard can still render a status badge.
"""
from plugins.n8n.client import N8nAuthError, N8nConnectionError
try:
user = await self.client.get_current_user()
except N8nAuthError as exc:
return {
"probe_available": False,
"granted": [],
"source": "n8n_api",
"reason": f"auth_failed: {exc}",
}
except (N8nConnectionError, Exception) as exc: # noqa: BLE001
return {
"probe_available": False,
"granted": [],
"source": "n8n_api",
"reason": f"probe_failed: {exc}",
}
role_name = ""
role_obj = user.get("role") or user.get("globalRole") or ""
if isinstance(role_obj, dict):
role_name = role_obj.get("name", "")
elif isinstance(role_obj, str):
role_name = role_obj
scopes = user.get("globalScopes") or []
return {
"probe_available": True,
"granted": sorted(scopes) if scopes else [role_name],
"source": "n8n_api",
"role": role_name,
"email": user.get("email", ""),
}
async def check_health(self) -> dict[str, Any]:
"""
Check if n8n instance is accessible (internal use).
Note: This is named check_health to avoid shadowing the handler's
health_check function which is exposed as an MCP tool.
Returns:
Dict containing health check result
"""
"""Check if n8n instance is accessible (internal use)."""
try:
result = await self.client.health_check()
return {
"healthy": result.get("healthy", False),
"message": f"n8n instance at {self.client.site_url} is {'accessible' if result.get('healthy') else 'not accessible'}",
"message": (
f"n8n instance at {self.client.site_url} is "
f"{'accessible' if result.get('healthy') else 'not accessible'}"
),
}
except Exception as e:
return {"healthy": False, "message": f"n8n health check failed: {str(e)}"}
return {"healthy": False, "message": f"n8n health check failed: {e}"}
async def health_check(self, **kwargs) -> str:
"""

View File

@@ -7,23 +7,30 @@ Provides 28 tools for WooCommerce store management.
Uses shared WordPress handlers for implementation.
"""
import logging
from typing import Any
from plugins.base import BasePlugin
from plugins.wordpress.client import WordPressClient
from plugins.wordpress.handlers import (
AIMediaHandler,
CouponsHandler,
CustomersHandler,
MediaAttachHandler,
OrdersHandler,
ProductsHandler,
ReportsHandler,
get_ai_media_specs,
get_coupons_specs,
get_customers_specs,
get_media_attach_specs,
get_orders_specs,
get_products_specs,
get_reports_specs,
)
logger = logging.getLogger(__name__)
class WooCommercePlugin(BasePlugin):
"""
@@ -58,6 +65,8 @@ class WooCommercePlugin(BasePlugin):
- url: WordPress/WooCommerce site URL
- consumer_key/consumer_secret: WooCommerce REST API keys (preferred)
- username/app_password: WordPress application password (fallback)
- wp_username/wp_app_password: optional WP Application Password
for /wp/v2/media calls (used by media tools — F.X.fix-pass4).
project_id: Optional project ID (auto-generated if not provided)
"""
super().__init__(config, project_id=project_id)
@@ -76,17 +85,68 @@ class WooCommercePlugin(BasePlugin):
"Please set either CONSUMER_KEY/CONSUMER_SECRET or USERNAME/APP_PASSWORD."
)
# Create WordPress API client
# Create WordPress API client (used for /wc/v3/* WooCommerce REST).
self.client = WordPressClient(
site_url=config["url"], username=username, app_password=password
)
# F.X.fix-pass4 — derive a SECONDARY client for /wp/v2/* media
# uploads. WC's Consumer Key + Secret pair does NOT authenticate
# WP core REST endpoints; uploads to /wp/v2/media require an
# Application Password from the WP admin user. Three resolution
# paths, first that wins:
# 1. explicit wp_username + wp_app_password fields (recommended)
# 2. legacy username + app_password (single-credential mode)
# 3. None — media tools will surface a clear error at call time
wp_user = (config.get("wp_username") or "").strip()
wp_pw = (config.get("wp_app_password") or "").strip()
legacy_user = (config.get("username") or "").strip()
legacy_pw = (config.get("app_password") or "").strip()
# If consumer_key/consumer_secret are present, we're in WC-keys
# mode and the primary client cannot hit /wp/v2/*. We need
# explicit WP creds OR legacy app_password to enable media tools.
if wp_user and wp_pw:
self.wp_media_client: WordPressClient | None = WordPressClient(
site_url=config["url"], username=wp_user, app_password=wp_pw
)
elif (
legacy_user
and legacy_pw
and not (config.get("consumer_key") or config.get("consumer_secret"))
):
# Legacy app_password mode (no ck/cs at all) — same client
# works for both /wp/v2/* and /wc/v3/* because it uses an
# Application Password.
self.wp_media_client = self.client
else:
self.wp_media_client = None
# Initialize WooCommerce handlers
self.products = ProductsHandler(self.client)
self.orders = OrdersHandler(self.client)
self.customers = CustomersHandler(self.client)
self.coupons = CouponsHandler(self.client)
self.reports = ReportsHandler(self.client)
self.media_attach = MediaAttachHandler(self.client, wp_media_client=self.wp_media_client)
# F.X.fix-pass5 — expose generate_and_upload_image on WC sites
# too (was WP-only). The handler reads the per-site provider
# key resolver and uploads via wp_media_client (so WC sites
# with consumer_key/consumer_secret as primary still work as
# long as wp_username + wp_app_password are configured).
# When wp_media_client is None we still register the tool so
# the user sees a clear NO_PROVIDER_KEY / WP_CREDENTIALS_MISSING
# error rather than a missing-tool 404.
self.ai_media = AIMediaHandler(
self.wp_media_client or self.client,
user_id=config.get("user_id"),
site_id=config.get("site_id"),
# F.X.fix-pass6 — pass the primary WC client so
# _apply_metadata_and_attach can detect "attach_to_post
# is a WC product" and route featured-image set through
# /wc/v3/products/{id} instead of the WP /posts endpoint
# which 404s for products.
wc_client=self.client,
)
@staticmethod
def get_tool_specifications() -> list[dict[str, Any]]:
@@ -116,6 +176,16 @@ class WooCommercePlugin(BasePlugin):
# Reports (3 tools)
specs.extend(get_reports_specs())
# F.5a.3: Media attachment (3 tools — attach_media_to_product,
# upload_and_attach_to_product, set_featured_image)
specs.extend(get_media_attach_specs())
# F.X.fix-pass5: AI image generation (1 tool — also surfaced on
# the WP plugin; same handler, same per-site provider key
# resolver). On a WC site this lets the operator chain
# generate → attach without two endpoints.
specs.extend(get_ai_media_specs())
return specs
async def health_check(self) -> dict[str, Any]:
@@ -147,6 +217,118 @@ class WooCommercePlugin(BasePlugin):
"plugin_type": "woocommerce",
}
async def probe_credential_capabilities(self) -> dict[str, Any]:
"""F.7e — report what the WooCommerce consumer key grants.
Uses ``GET /wp-json/wc/v3/system_status`` which is readable by any
key with at least ``read`` permission and includes a
``security.rest_api_keys[]`` array where each entry carries a
``permissions`` field (``read`` / ``write`` / ``read_write``). We
match the caller's consumer_key against the ``truncated_key`` the
endpoint reports (last 7 chars) and derive the granted cap list.
Falls back gracefully on any network / shape failure — callers
still get the tools, they just don't get the early-warning badge.
"""
try:
status = await self.client.get("system_status", use_woocommerce=True)
except Exception as exc: # noqa: BLE001
return {
"probe_available": False,
"granted": [],
"source": "woocommerce_system_status",
"reason": f"system_status_unreachable: {exc}",
}
if not isinstance(status, dict):
return {
"probe_available": False,
"granted": [],
"source": "woocommerce_system_status",
"reason": "non_dict_response",
}
keys = ((status.get("security") or {}).get("rest_api_keys")) or []
consumer_key = self.config.get("consumer_key") or self.config.get("username") or ""
# WC's system_status truncates to last 7 chars by default.
my_tail = consumer_key[-7:] if consumer_key else ""
match = None
for entry in keys:
if not isinstance(entry, dict):
continue
tail = str(entry.get("truncated_key") or "")
if my_tail and tail == my_tail:
match = entry
break
if match is None:
# Fallback: if there's exactly one key listed OR the endpoint
# doesn't expose truncated_key on this WC version, fall back
# to the top entry so we still show *something* useful.
if len(keys) == 1 and isinstance(keys[0], dict):
match = keys[0]
# F.X.fix-pass5 — flag whether the site has WP App Password
# credentials configured. Media tools (and AI image with
# attach) need these on a WC site whose primary credential is
# consumer_key + secret. The prerequisites resolver in
# core/tool_access reads this to gate the WC media tool list.
wp_credentials_present = bool(
(self.config.get("wp_username") and self.config.get("wp_app_password"))
or (
self.config.get("username")
and self.config.get("app_password")
and not (self.config.get("consumer_key") or self.config.get("consumer_secret"))
)
)
if match is not None:
permission = str(match.get("permissions") or "").lower()
granted: list[str] = []
if permission in {"read", "read_write", "write"}:
granted.append("read_products")
granted.append("read_orders")
if permission in {"write", "read_write"}:
granted.append("write_products")
granted.append("write_orders")
return {
"probe_available": True,
"granted": sorted(granted),
"source": "woocommerce_system_status",
"permissions": permission,
"wp_credentials_present": wp_credentials_present,
}
# F.X.fix-pass3 — match is None (consumer key not listed). This
# happens when:
# * system_status omits truncated_key on the running WC build
# * the key was created via WP-CLI / a custom path
# * the key is shadowed by another key with the same tail
# Don't surface a false-negative "probe unavailable" (which the
# badge labelled with a misleading "install companion plugin"
# hint, since WC has no companion).
#
# F.X.fix-pass5 — STAY CONSERVATIVE. The previous pass probed
# ``GET /wc/v3/settings`` and upgraded to write+admin on 200,
# but that signal mixes two unrelated facts (WP user has
# ``manage_woocommerce`` capability AND key has any read perm)
# and was over-granting on read-only keys whose backing user
# was an admin. Result: tier-fit "Read + Write" stayed green
# for read-only keys. Now we report read-only and let the
# tier-fit warning fire correctly. Operators with read+write
# keys still see their tools work; the badge just says
# "Currently granted: read_products, read_orders" — a soft
# signal, not a block.
return {
"probe_available": True,
"granted": sorted({"read_products", "read_orders"}),
"source": "woocommerce_system_status_inferred",
"permissions": "inferred",
"probe_inferred": True,
"wp_credentials_present": wp_credentials_present,
}
# ========================================
# Method Delegation to Handlers
# ========================================
@@ -239,3 +421,17 @@ class WooCommercePlugin(BasePlugin):
async def get_customer_report(self, **kwargs):
return await self.reports.get_customer_report(**kwargs)
# === F.5a.3: Media attach ===
async def attach_media_to_product(self, **kwargs):
return await self.media_attach.attach_media_to_product(**kwargs)
async def upload_and_attach_to_product(self, **kwargs):
return await self.media_attach.upload_and_attach_to_product(**kwargs)
async def set_featured_image(self, **kwargs):
return await self.media_attach.set_featured_image(**kwargs)
# === F.X.fix-pass5: AI image (re-exported from WP handler) ===
async def generate_and_upload_image(self, **kwargs):
return await self.ai_media.generate_and_upload_image(**kwargs)

View File

@@ -33,11 +33,42 @@ class ConnectionError(Exception):
pass
class SiteUnreachableError(ConnectionError):
"""Raised when the WordPress site is not reachable at the TCP/DNS layer.
Subclass of :class:`ConnectionError` so existing ``except ConnectionError``
sites keep working. Carries a structured ``error_code='SITE_UNREACHABLE'``
plus optional ``install_hint`` so companion-backed handlers and the
capability probe can emit a uniform error payload that the dashboard
can render as a "check your URL / install companion" prompt in <10s
instead of the previous 35-85s hang.
"""
error_code = "SITE_UNREACHABLE"
def __init__(
self,
message: str,
*,
install_hint: dict[str, Any] | None = None,
reason: str = "site_unreachable",
) -> None:
super().__init__(message)
self.install_hint = install_hint
self.reason = reason
# Transient HTTP status codes that are worth retrying
_RETRYABLE_STATUS_CODES = {502, 503, 504, 429}
# Default request timeout in seconds
# Default request timeout in seconds (wall clock).
_REQUEST_TIMEOUT = 30
# Connect timeout: how long to wait for the TCP handshake before giving
# up. Five seconds is enough to rule out DNS/TCP failure on any real
# site; beyond that the site is down. Short connect + long total lets us
# fail fast on unreachable hosts while still allowing slow responses
# from reachable ones to complete.
_CONNECT_TIMEOUT = 5
# Retry configuration
_MAX_RETRIES = 2
@@ -126,7 +157,7 @@ class WordPressClient:
"""
# Build URL based on endpoint type
if use_custom_namespace:
# For custom namespaces like airano-mcp-seo-bridge/v1
# For custom namespaces like airano-mcp-bridge/v1
url = f"{self.site_url}/wp-json/{endpoint}"
elif use_woocommerce:
# For WooCommerce endpoints
@@ -158,8 +189,10 @@ class WordPressClient:
if json_data:
json_data = {k: v for k, v in json_data.items() if should_include(v)}
# Make request with retry for transient errors
timeout = aiohttp.ClientTimeout(total=_REQUEST_TIMEOUT)
# Make request with retry for transient errors. connect=5
# short-circuits TCP/DNS failures in <10s (2 retries × 5s each
# worst case) instead of the previous 35-85s hang.
timeout = aiohttp.ClientTimeout(total=_REQUEST_TIMEOUT, connect=_CONNECT_TIMEOUT)
last_exception = None
for attempt in range(_MAX_RETRIES + 1):
@@ -209,11 +242,15 @@ class WordPressClient:
raise # Never retry auth/config errors
except TimeoutError:
last_exception = ConnectionError(
f"Request timed out after {_REQUEST_TIMEOUT}s. "
f"The site at {self.site_url} is not responding. "
"Possible causes: site is overloaded, network is slow, "
"or the server is down."
last_exception = SiteUnreachableError(
(
f"Request timed out after {_REQUEST_TIMEOUT}s. "
f"The site at {self.site_url} is not responding. "
"Possible causes: site is overloaded, network is "
"slow, or the server is down."
),
install_hint=self._site_unreachable_install_hint(),
reason="site_timeout",
)
if attempt < _MAX_RETRIES:
wait = _RETRY_BACKOFF_BASE * (2**attempt)
@@ -225,40 +262,61 @@ class WordPressClient:
continue
except aiohttp.ClientConnectorCertificateError as e:
raise ConnectionError(
f"SSL certificate error for {self.site_url}. "
"The site's SSL certificate is invalid or expired. "
f"Details: {e}"
raise SiteUnreachableError(
(
f"SSL certificate error for {self.site_url}. "
"The site's SSL certificate is invalid or expired. "
f"Details: {e}"
),
install_hint=self._site_unreachable_install_hint(),
reason="site_ssl_error",
) from e
except aiohttp.ClientConnectorDNSError as e:
host = self.site_url.split("://")[-1].split("/")[0]
raise ConnectionError(
f"DNS resolution failed for '{host}'. "
"The domain name could not be found. "
"Please check that the URL is correct."
raise SiteUnreachableError(
(
f"DNS resolution failed for '{host}'. "
"The domain name could not be found. "
"Please check that the URL is correct."
),
install_hint=self._site_unreachable_install_hint(),
reason="site_dns_error",
) from e
except aiohttp.ClientConnectorError as e:
os_error = getattr(e, "os_error", None)
if isinstance(os_error, socket.gaierror):
host = self.site_url.split("://")[-1].split("/")[0]
raise ConnectionError(
f"DNS resolution failed for '{host}'. "
"The domain name could not be found. "
"Please check that the URL is correct."
raise SiteUnreachableError(
(
f"DNS resolution failed for '{host}'. "
"The domain name could not be found. "
"Please check that the URL is correct."
),
install_hint=self._site_unreachable_install_hint(),
reason="site_dns_error",
) from e
raise ConnectionError(
f"Cannot connect to {self.site_url}. "
"The server is unreachable. Possible causes: "
"wrong URL, server is down, firewall blocking, or wrong port."
raise SiteUnreachableError(
(
f"Cannot connect to {self.site_url}. "
"The server is unreachable. Possible causes: "
"wrong URL, server is down, firewall blocking, "
"or wrong port."
),
install_hint=self._site_unreachable_install_hint(),
reason="site_connection_refused",
) from e
except aiohttp.InvalidURL:
raise ConnectionError(
f"Invalid URL: {self.site_url}. "
"Please provide a valid URL starting with https:// or http://."
raise SiteUnreachableError(
(
f"Invalid URL: {self.site_url}. "
"Please provide a valid URL starting with "
"https:// or http://."
),
reason="site_invalid_url",
)
except (aiohttp.ClientError, OSError) as e:
@@ -277,6 +335,25 @@ class WordPressClient:
# All retries exhausted
raise last_exception # type: ignore[misc]
@staticmethod
def _site_unreachable_install_hint() -> dict[str, Any]:
"""Structured install hint for SITE_UNREACHABLE errors.
Mirrors the shape produced by
``plugins.wordpress.handlers._companion_hint.companion_install_hint``
so dashboard code can render one uniform "fix your connection"
prompt regardless of whether the error came from the low-level
client or a companion-backed handler.
"""
from plugins.wordpress.handlers._companion_hint import (
companion_install_hint,
)
return companion_install_hint(
min_version="2.9.0",
required_capability="manage_options",
)
def _parse_error_response(
self, status_code: int, error_text: str, use_woocommerce: bool = False
) -> dict[str, Any]:

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)

View File

@@ -65,7 +65,40 @@ class WordPressPlugin(BasePlugin):
# Initialize core WordPress handlers
self.posts = handlers.PostsHandler(self.client)
self.media = handlers.MediaHandler(self.client)
self.media = handlers.MediaHandler(self.client, user_id=config.get("user_id"))
# F.5a.4 / F.5a.9.x: AI image handler receives user_id AND site_id.
# site_id is the primary input for resolving per-site provider API
# keys; user_id is retained for audit / rate-limit context and for
# the admin/env-fallback path when no site is known.
self.ai_media = handlers.AIMediaHandler(
self.client,
user_id=config.get("user_id"),
site_id=config.get("site_id"),
)
# F.5a.5: chunked media uploads (session store + disk spill)
self.media_chunked = handlers.MediaChunkedHandler(
self.client, user_id=config.get("user_id")
)
# F.5a.6.3: probe WP upload limits (24h cache)
self.media_probe = handlers.ProbeHandler(self.client)
# F.18.1: probe companion-plugin capabilities (24h cache)
self.capabilities = handlers.CapabilitiesHandler(self.client)
# F.18.2: batch post_meta writes via companion plugin
self.bulk_meta = handlers.BulkMetaHandler(self.client)
# F.18.3: structured JSON export via companion plugin
self.export = handlers.ExportHandler(self.client)
# F.18.4: cache purge via companion plugin
self.cache_purge_handler = handlers.CachePurgeHandler(self.client)
# F.18.5: native transient flush via companion plugin
self.transient_flush_handler = handlers.TransientFlushHandler(self.client)
# F.18.6: unified site-health snapshot via companion plugin
self.site_health_handler = handlers.SiteHealthHandler(self.client)
# F.18.7: audit-hook configuration + query
self.audit_hook_handler = handlers.AuditHookHandler(self.client)
# F.5a.8.2: regenerate attachment sub-sizes via companion plugin
self.regenerate_thumbnails_handler = handlers.RegenerateThumbnailsHandler(self.client)
# F.5a.8.3: bulk delete / reassign attachments via stock WP REST
self.media_bulk = handlers.MediaBulkHandler(self.client)
self.taxonomy = handlers.TaxonomyHandler(self.client)
self.comments = handlers.CommentsHandler(self.client)
self.users = handlers.UsersHandler(self.client)
@@ -103,7 +136,19 @@ class WordPressPlugin(BasePlugin):
# Core WordPress handlers
specs.extend(handlers.get_posts_specs()) # 13 tools
specs.extend(handlers.get_media_specs()) # 5 tools
specs.extend(handlers.get_media_specs()) # 6 tools (F.5a.1: added base64 upload)
specs.extend(handlers.get_ai_media_specs()) # 1 tool (F.5a.4: generate+upload)
specs.extend(handlers.get_media_chunked_specs()) # 5 tools (F.5a.5 + F.5a.8.4 status)
specs.extend(handlers.get_media_probe_specs()) # 1 tool (F.5a.6.3: probe limits)
specs.extend(handlers.get_capabilities_specs()) # 1 tool (F.18.1: probe capabilities)
specs.extend(handlers.get_bulk_meta_specs()) # 1 tool (F.18.2: bulk meta write)
specs.extend(handlers.get_export_specs()) # 1 tool (F.18.3: structured export)
specs.extend(handlers.get_cache_purge_specs()) # 1 tool (F.18.4: cache purge)
specs.extend(handlers.get_transient_flush_specs()) # 1 tool (F.18.5: transient flush)
specs.extend(handlers.get_site_health_specs()) # 1 tool (F.18.6: site health)
specs.extend(handlers.get_audit_hook_specs()) # 3 tools (F.18.7: audit hook)
specs.extend(handlers.get_regenerate_thumbnails_specs()) # 1 tool (F.5a.8.2)
specs.extend(handlers.get_media_bulk_specs()) # 2 tools (F.5a.8.3: bulk delete+reassign)
specs.extend(handlers.get_taxonomy_specs()) # 11 tools
specs.extend(handlers.get_comments_specs()) # 5 tools
specs.extend(handlers.get_users_specs()) # 2 tools
@@ -128,6 +173,68 @@ class WordPressPlugin(BasePlugin):
"""
return await self.site.health_check()
async def probe_credential_capabilities(self) -> dict[str, Any]:
"""F.7e — return the capabilities the saved app_password grants.
Delegates to the companion-plugin-backed ``CapabilitiesHandler``
(F.18.1), which reads ``GET /airano-mcp/v1/capabilities`` — the
WordPress user's effective capability map. When the companion
isn't installed we return an empty-granted payload marked as
unavailable so the caller can show a "companion plugin needed"
hint rather than falsely claim the key is under-privileged.
F.X.fix #3 — fast-fail on unreachable sites. When the low-level
client raises :class:`SiteUnreachableError` (DNS/TCP/timeout),
we short-circuit to a structured ``probe_available=False``
payload carrying the ``install_hint`` so the dashboard renders
the "check your URL / install companion" prompt in <10s instead
of hanging on the 30s total timeout.
"""
from plugins.wordpress.client import SiteUnreachableError
from plugins.wordpress.handlers.capabilities import _empty_capabilities_payload
try:
payload = await self.capabilities._fetch_capabilities()
except SiteUnreachableError as exc:
out: dict[str, Any] = {
"probe_available": False,
"granted": [],
"source": "wordpress_companion",
"reason": f"site_unreachable: {exc.reason}",
}
if exc.install_hint:
out["install_hint"] = exc.install_hint
return out
except Exception as exc: # noqa: BLE001
payload = _empty_capabilities_payload(
self.client.site_url, reason=f"probe_failed: {exc}"
)
if not payload.get("companion_available"):
return {
"probe_available": False,
"granted": [],
"source": "wordpress_companion",
"reason": (payload.get("reason") or "companion_not_installed"),
}
user_caps = (payload.get("user") or {}).get("capabilities") or {}
granted = sorted(k for k, v in user_caps.items() if v)
roles = list((payload.get("user") or {}).get("roles") or [])
# F.X.fix-pass3 — surface routes + features so the central
# tool-prerequisites resolver in core/tool_access can decide
# which tools to auto-disable (SEO needs Rank Math/Yoast,
# cache_purge/etc. need the matching companion route, …).
return {
"probe_available": True,
"granted": granted,
"source": "wordpress_companion",
"roles": roles,
"plugin_version": payload.get("plugin_version"),
"routes": dict(payload.get("routes") or {}),
"features": dict(payload.get("features") or {}),
}
# ========================================
# Method Delegation to Handlers
# ========================================
@@ -187,12 +294,84 @@ class WordPressPlugin(BasePlugin):
async def upload_media_from_url(self, **kwargs):
return await self.media.upload_media_from_url(**kwargs)
async def upload_media_from_base64(self, **kwargs):
return await self.media.upload_media_from_base64(**kwargs)
async def update_media(self, **kwargs):
return await self.media.update_media(**kwargs)
async def delete_media(self, **kwargs):
return await self.media.delete_media(**kwargs)
# === AI Image Generation (F.5a.4) ===
async def generate_and_upload_image(self, **kwargs):
return await self.ai_media.generate_and_upload_image(**kwargs)
# === Chunked Media Upload (F.5a.5) ===
async def upload_media_chunked_start(self, **kwargs):
return await self.media_chunked.upload_media_chunked_start(**kwargs)
async def upload_media_chunked_chunk(self, **kwargs):
return await self.media_chunked.upload_media_chunked_chunk(**kwargs)
async def upload_media_chunked_finish(self, **kwargs):
return await self.media_chunked.upload_media_chunked_finish(**kwargs)
async def upload_media_chunked_abort(self, **kwargs):
return await self.media_chunked.upload_media_chunked_abort(**kwargs)
async def upload_media_chunked_status(self, **kwargs):
return await self.media_chunked.upload_media_chunked_status(**kwargs)
# === Probe Upload Limits (F.5a.6.3) ===
async def probe_upload_limits(self, **kwargs):
return await self.media_probe.probe_upload_limits(**kwargs)
# === Probe Companion Capabilities (F.18.1) ===
async def probe_capabilities(self, **kwargs):
return await self.capabilities.probe_capabilities(**kwargs)
# === Bulk Meta Write (F.18.2) ===
async def bulk_update_meta(self, **kwargs):
return await self.bulk_meta.bulk_update_meta(**kwargs)
# === Structured Export (F.18.3) ===
async def export_content(self, **kwargs):
return await self.export.export_content(**kwargs)
# === Cache Purge (F.18.4) ===
async def cache_purge(self, **kwargs):
return await self.cache_purge_handler.cache_purge(**kwargs)
# === Regenerate Thumbnails (F.5a.8.2) ===
async def regenerate_thumbnails(self, **kwargs):
return await self.regenerate_thumbnails_handler.regenerate_thumbnails(**kwargs)
# === Bulk Media Operations (F.5a.8.3) ===
async def bulk_delete_media(self, **kwargs):
return await self.media_bulk.bulk_delete_media(**kwargs)
async def bulk_reassign_media(self, **kwargs):
return await self.media_bulk.bulk_reassign_media(**kwargs)
# === Transient Flush (F.18.5) ===
async def transient_flush(self, **kwargs):
return await self.transient_flush_handler.transient_flush(**kwargs)
# === Unified Site Health (F.18.6) ===
async def site_health(self, **kwargs):
return await self.site_health_handler.site_health(**kwargs)
# === Audit Hook (F.18.7) ===
async def audit_hook_status(self, **kwargs):
return await self.audit_hook_handler.audit_hook_status(**kwargs)
async def audit_hook_configure(self, **kwargs):
return await self.audit_hook_handler.audit_hook_configure(**kwargs)
async def audit_hook_disable(self, **kwargs):
return await self.audit_hook_handler.audit_hook_disable(**kwargs)
# === Taxonomy (Categories & Tags) ===
async def list_categories(self, **kwargs):
return await self.taxonomy.list_categories(**kwargs)