feat(v3.12.0): media pipeline, AI image generation, capability probe, companion v2.9.0
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:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user