Files
mcphub/plugins/wordpress/handlers/capabilities.py
airano-ir f203ca88de
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
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>
2026-04-25 16:25:58 +02:00

222 lines
7.0 KiB
Python

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