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

@@ -403,6 +403,11 @@ class ToolAccessManager:
tools = self.apply_scope_filter(tools, [site_scope], plugin_type=plugin_type)
tools = await self.apply_site_toggles(tools, site_id)
# F.X.fix-pass3: filter out tools whose central prerequisites
# are unmet (no AI provider key, missing companion route,
# missing SEO plugin) so the live endpoint never advertises a
# tool that would 100% fail at call time.
tools = await self.apply_prerequisites_filter(tools, site_id)
return tools
async def toggle_tool(
@@ -488,6 +493,19 @@ class ToolAccessManager:
Used by the dashboard API to present the per-site management view.
Does not apply scope filters — the UI decides what to show.
F.X.fix #8: each row also carries ``provider_key_required`` and
``provider_key_configured`` so the Tool Access template can
render AI tools greyed + "Configure key" CTA when the site has
no keys for the required provider, instead of letting the user
enable a tool that errors at call time with ``NO_PROVIDER_KEY``.
F.X.fix-pass3: rows also carry ``available`` + ``unavailable_reason``
derived from a central :data:`_TOOL_PREREQUISITES` resolver so
the UI can auto-grey/disable tools whose requirements (companion
route, SEO plugin, AI key) the site doesn't satisfy. This is the
same data the live MCP endpoint uses to silently filter the
catalog (see :meth:`get_visible_tools`).
Args:
site_id: Site UUID.
plugin_type: Plugin type.
@@ -504,20 +522,235 @@ class ToolAccessManager:
except RuntimeError:
toggles = {}
try:
from core.site_api import list_site_providers_set
configured_providers = await list_site_providers_set(site_id)
except Exception: # noqa: BLE001
configured_providers = set()
# F.X.fix-pass3: pull the cached probe payload so we can
# evaluate prerequisites for SEO / companion-route tools.
# ``probe_site_capabilities`` reuses its 10-min cache so the
# cost is one SQLite query in the common path.
probe_payload: dict[str, Any] | None = None
try:
from core.capability_probe import get_probe_cache
cached = get_probe_cache().get(site_id)
if cached is not None:
probe_payload = cached
except Exception: # noqa: BLE001
probe_payload = None
registry = get_tool_registry()
tools = registry.get_by_plugin_type(plugin_type)
return [
{
"name": t.name,
"description": t.description,
"plugin_type": t.plugin_type,
"category": t.category,
"sensitivity": t.sensitivity,
"required_scope": t.required_scope,
"enabled": toggles.get(t.name, True),
}
for t in tools
]
rows: list[dict[str, Any]] = []
for t in tools:
available, reason = check_tool_prerequisites(
t.name,
probe_payload=probe_payload,
configured_providers=configured_providers,
)
rows.append(
{
"name": t.name,
"description": t.description,
"plugin_type": t.plugin_type,
"category": t.category,
"sensitivity": t.sensitivity,
"required_scope": t.required_scope,
"enabled": toggles.get(t.name, True),
"provider_key_required": _tool_requires_provider_key(t.name),
"provider_key_configured": _tool_has_configured_provider(
t.name, configured_providers
),
"available": available,
"unavailable_reason": reason,
}
)
return rows
async def apply_prerequisites_filter(
self,
tools: list[ToolDefinition],
site_id: str,
) -> list[ToolDefinition]:
"""Drop tools whose central prerequisites are not satisfied.
Mirrors :meth:`list_tools_for_site` but returns ``ToolDefinition``
objects so the live MCP endpoint pipeline (``get_visible_tools``)
can call it inline. Reads the same cached probe + provider-key
set so a fresh probe never gets triggered from this hot path.
"""
try:
from core.site_api import list_site_providers_set
configured_providers = await list_site_providers_set(site_id)
except Exception: # noqa: BLE001
configured_providers = set()
probe_payload: dict[str, Any] | None = None
try:
from core.capability_probe import get_probe_cache
cached = get_probe_cache().get(site_id)
if cached is not None:
probe_payload = cached
except Exception: # noqa: BLE001
probe_payload = None
kept: list[ToolDefinition] = []
for tool in tools:
available, _ = check_tool_prerequisites(
tool.name,
probe_payload=probe_payload,
configured_providers=configured_providers,
)
if available:
kept.append(tool)
return kept
# F.X.fix #8 — tools that need a per-site AI provider key to succeed.
# Today only the AI image generator; expand as more AI tools land.
_PROVIDER_KEY_REQUIRED_TOOLS: frozenset[str] = frozenset({"wordpress_generate_and_upload_image"})
def _tool_requires_provider_key(tool_name: str) -> bool:
return tool_name in _PROVIDER_KEY_REQUIRED_TOOLS
def _tool_has_configured_provider(tool_name: str, configured: set[str]) -> bool:
"""``True`` iff the site has at least one provider key for this tool.
The AI image tool is happy with ANY supported provider key (the
caller picks which one at call time via the ``provider`` arg) so
any non-empty configured set counts.
"""
if not _tool_requires_provider_key(tool_name):
return True
return bool(configured)
# ── F.X.fix-pass3: central tool-prerequisites resolver ───────────────
# Single source of truth for "this tool needs X to work". The resolver
# below decides per-call whether a tool is *available* on a given site
# given the cached probe payload + the site's configured provider keys.
# Three predicate kinds:
#
# provider_key — site needs at least one of the listed AI provider
# keys.
# companion_route — the companion plugin must advertise the named
# route in probe.routes.
# feature_any — the WP probe features must include at least one of
# the listed feature names (e.g. rank_math / yoast).
#
# The resolver's output (``available`` + ``unavailable_reason``) is
# attached to every tool row in ``list_tools_for_site`` AND used by
# ``apply_prerequisites_filter`` to drop unavailable tools from the
# live MCP endpoint, so models calling the endpoint never see a tool
# that is guaranteed to fail at call time.
_TOOL_PREREQUISITES: dict[str, list[dict[str, Any]]] = {
# AI image — any provider key is enough; the caller picks at call time.
"wordpress_generate_and_upload_image": [
{
"kind": "provider_key",
"any_of": ["openai", "stability", "replicate", "openrouter"],
}
],
# Companion-route-backed WP tools.
"wordpress_cache_purge": [{"kind": "companion_route", "name": "cache_purge"}],
"wordpress_bulk_update_meta": [{"kind": "companion_route", "name": "bulk_meta"}],
"wordpress_export_content": [{"kind": "companion_route", "name": "export"}],
"wordpress_site_health": [{"kind": "companion_route", "name": "site_health"}],
"wordpress_transient_flush": [{"kind": "companion_route", "name": "transient_flush"}],
"wordpress_audit_hook_status": [{"kind": "companion_route", "name": "audit_hook"}],
"wordpress_audit_hook_configure": [{"kind": "companion_route", "name": "audit_hook"}],
"wordpress_audit_hook_disable": [{"kind": "companion_route", "name": "audit_hook"}],
"wordpress_regenerate_thumbnails": [
{"kind": "companion_route", "name": "regenerate_thumbnails"}
],
"wordpress_bulk_delete_media": [{"kind": "companion_route", "name": "bulk_meta"}],
"wordpress_bulk_reassign_media": [{"kind": "companion_route", "name": "bulk_meta"}],
# SEO — needs Rank Math (or Yoast in the future). Probe surfaces
# both flags; we accept either.
"wordpress_get_post_seo": [{"kind": "feature_any", "names": ["rank_math", "yoast"]}],
"wordpress_update_post_seo": [{"kind": "feature_any", "names": ["rank_math", "yoast"]}],
"wordpress_get_product_seo": [{"kind": "feature_any", "names": ["rank_math", "yoast"]}],
"wordpress_update_product_seo": [{"kind": "feature_any", "names": ["rank_math", "yoast"]}],
"wordpress_get_internal_links": [{"kind": "feature_any", "names": ["rank_math", "yoast"]}],
# F.X.fix-pass5 — WooCommerce media tools hit /wp/v2/media which
# WC consumer_key + secret can't authenticate. Need a WP App
# Password (wp_username + wp_app_password fields, or legacy
# username + app_password single-credential mode).
"woocommerce_attach_media_to_product": [{"kind": "wp_credentials"}],
"woocommerce_upload_and_attach_to_product": [{"kind": "wp_credentials"}],
"woocommerce_set_featured_image": [{"kind": "wp_credentials"}],
# AI image, exposed on both WP and WC plugins. The WC variant
# needs both an AI provider key AND WP credentials (to upload
# /wp/v2/media); the WP variant only needs the provider key
# since its primary client already uses Application Password.
"woocommerce_generate_and_upload_image": [
{"kind": "provider_key", "any_of": ["openai", "stability", "replicate", "openrouter"]},
{"kind": "wp_credentials"},
],
}
def check_tool_prerequisites(
tool_name: str,
*,
probe_payload: dict[str, Any] | None,
configured_providers: set[str],
) -> tuple[bool, str | None]:
"""Decide whether a tool's prerequisites are satisfied.
Returns ``(available, unavailable_reason)``. ``unavailable_reason``
is one of ``provider_key`` | ``companion_route`` | ``feature``
| ``probe_unknown`` when the tool is unavailable, else ``None``.
A tool with no prerequisites declared is always available — most
of the catalog (plain CRUD over WP REST) needs no companion / no
AI key, so the default of "no entry → True" keeps the rule list
short.
"""
rules = _TOOL_PREREQUISITES.get(tool_name)
if not rules:
return True, None
routes = ((probe_payload or {}).get("routes")) or {}
features = ((probe_payload or {}).get("features")) or {}
for rule in rules:
kind = rule.get("kind")
if kind == "provider_key":
allowed = set(rule.get("any_of") or [])
if allowed and not (allowed & configured_providers):
return False, "provider_key"
elif kind == "companion_route":
name = rule.get("name") or ""
# Probe is authoritative when present; absence of probe data
# (e.g. companion not installed → companion_available=False)
# is also treated as "unavailable".
if not routes.get(name):
return False, "companion_route"
elif kind == "feature_any":
names = list(rule.get("names") or [])
# WP companion's features dict carries booleans like
# rank_math: true / yoast: false. WC has no analogous gate.
if not any(features.get(n) for n in names):
return False, "feature"
elif kind == "wp_credentials":
# F.X.fix-pass5 — WC media tools and the WC AI image tool
# need a WP Application Password to authenticate
# /wp/v2/media. The WC plugin probe surfaces a
# ``wp_credentials_present`` flag; absence is a hard fail.
if not (probe_payload or {}).get("wp_credentials_present"):
return False, "wp_credentials"
return True, None
# Singleton