Files
mcphub/core/capability_probe.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

486 lines
17 KiB
Python

"""F.7e — per-site credential capability probe.
Each plugin's ``probe_capabilities()`` (defined on ``BasePlugin``) knows
how to ask its upstream service what the saved credential can actually
do. This module wraps those calls with:
* Per-site in-memory TTL cache (default 10 min) so the probe doesn't
hammer the upstream service on every dashboard page view.
* A thin wrapper that decrypts the site's credentials, instantiates the
plugin, calls the probe, and normalises the result.
* ``/api/sites/{id}/capabilities`` Starlette handler that the dashboard
UI consumes (wired in ``core/dashboard/routes.py``).
The cache is process-local and not persisted. Workers start cold and
populate on demand; invalidation happens on cache expiry or explicit
site-credential update.
"""
from __future__ import annotations
import logging
import os
import time
from typing import Any
from starlette.requests import Request
from starlette.responses import JSONResponse
logger = logging.getLogger("mcphub.capability_probe")
# Cache TTL in seconds (default 10 min). Upstream capability rarely
# changes — most drift happens when the operator rotates an
# app_password or changes a Gitea token's scopes, both of which are
# infrequent.
_CACHE_TTL_SECONDS = int(os.environ.get("CAPABILITY_PROBE_TTL", "600"))
class _ProbeCache:
"""Trivial in-memory ``site_id -> (expires_at, payload)`` cache."""
def __init__(self, ttl_seconds: int = _CACHE_TTL_SECONDS) -> None:
self._ttl = ttl_seconds
self._entries: dict[str, tuple[float, dict[str, Any]]] = {}
def get(self, site_id: str) -> dict[str, Any] | None:
entry = self._entries.get(site_id)
if entry is None:
return None
expires_at, payload = entry
if expires_at < time.time():
self._entries.pop(site_id, None)
return None
return payload
def set(self, site_id: str, payload: dict[str, Any]) -> None:
self._entries[site_id] = (time.time() + self._ttl, payload)
def invalidate(self, site_id: str) -> bool:
return self._entries.pop(site_id, None) is not None
_cache = _ProbeCache()
def get_probe_cache() -> _ProbeCache:
return _cache
# ---------------------------------------------------------------------------
# F.7e — tier-fit evaluation: compare probe.granted with what the site's
# selected ``tool_scope`` actually needs.
# ---------------------------------------------------------------------------
# Capability names required for each (plugin_type, tier) pair. Tiers come
# from ``core/tool_access.py``. For plugins whose probe doesn't map 1:1
# onto these names (e.g. Gitea scopes use ``read:repository`` rather than
# ``read``) the fit evaluator's ``_aliased_granted`` helper below
# normalises both sides before comparing.
TIER_REQUIREMENTS: dict[str, dict[str, set[str]]] = {
"wordpress": {
"read": {"read"},
"write": {"edit_posts", "upload_files"},
# WP-admin-tier tools need the same cap as WP's admin area itself.
"admin": {"manage_options"},
},
"woocommerce": {
"read": {"read_products"},
"write": {"write_products"},
"admin": {"write_products"},
},
"gitea": {
"read": {"read:repository"},
"write": {"write:repository"},
"admin": {"admin:repo_hook"},
},
}
# Aliases that plugins may return from their probe which should be
# treated as equivalent to the canonical cap names in TIER_REQUIREMENTS.
# Keeps the adapter implementations honest about what the upstream
# service actually says while letting the evaluator compare sets.
_CAP_ALIASES: dict[str, set[str]] = {
"manage_options": {"administrator", "manage_options"},
"edit_posts": {"edit_posts", "editor", "administrator"},
"upload_files": {"upload_files", "editor", "administrator"},
"read": {"read", "subscriber", "contributor", "author", "editor", "administrator"},
"read_products": {"read_products", "read", "read_write"},
"write_products": {"write_products", "write", "read_write"},
"read_orders": {"read_orders", "read", "read_write"},
"write_orders": {"write_orders", "write", "read_write"},
}
def _cap_matches(required: str, granted: set[str]) -> bool:
"""Return True if ``required`` is satisfied by any cap in ``granted``.
Uses ``_CAP_ALIASES`` to accept role names (WP) and permission
strings (WC) alongside the canonical capability names.
"""
if required in granted:
return True
aliases = _CAP_ALIASES.get(required, {required})
return bool(aliases & granted)
def evaluate_tier_fit(
plugin_type: str,
tier: str | None,
probe_payload: dict[str, Any],
) -> dict[str, Any]:
"""Decide whether ``probe.granted`` covers what ``tier`` requires.
Args:
plugin_type: e.g. "wordpress", "woocommerce", "gitea".
tier: the site's selected ``tool_scope`` preset (read / write /
admin / custom / None).
probe_payload: the dict returned by ``probe_site_capabilities``
(must contain ``probe_available`` and ``granted``).
Returns:
A dict with:
* ``status``: one of ``ok`` | ``warning`` | ``probe_unavailable``
| ``unknown_tier``.
* ``required``: list[str] of caps the tier needs (empty when
the tier is ``custom`` / not in the table).
* ``missing``: list[str] of required caps not present in
``granted``.
* ``reason``: passthrough from the probe when probe is
unavailable, else ``None``.
``custom`` tier always returns ``status='ok'`` because by definition
the caller picked individual tools — we can't check a tier-level
contract.
"""
tier_norm = (tier or "").strip().lower()
if not probe_payload.get("probe_available", False):
return {
"status": "probe_unavailable",
"required": [],
"missing": [],
"reason": probe_payload.get("reason"),
}
if tier_norm in {"", "custom"}:
return {
"status": "ok",
"required": [],
"missing": [],
"reason": None,
}
requirements = (TIER_REQUIREMENTS.get(plugin_type) or {}).get(tier_norm)
if requirements is None:
return {
"status": "unknown_tier",
"required": [],
"missing": [],
"reason": f"no_tier_table_for:{plugin_type}/{tier_norm}",
}
# F.X.fix #5: the probe places WP role names under ``roles`` and
# individual capability strings under ``granted`` — historically we
# only compared against ``granted``, so the ``read`` tier always
# reported ``warning: Missing read`` even for admin users, because
# ``read`` is implied by every role but not emitted as a bare cap
# in the companion's capability map. Union the two sets so the
# alias resolver in ``_cap_matches`` can see roles too.
granted = set(probe_payload.get("granted") or [])
roles = set(probe_payload.get("roles") or [])
effective = granted | roles
missing = sorted(cap for cap in requirements if not _cap_matches(cap, effective))
return {
"status": "warning" if missing else "ok",
"required": sorted(requirements),
"missing": missing,
"reason": None,
}
async def probe_site_capabilities(
site_id: str,
user_id: str,
*,
force: bool = False,
) -> dict[str, Any]:
"""Return the capability probe payload for a user-owned site.
Uses a 10-minute per-site TTL cache unless ``force=True``.
Response shape:
{
"site_id": str,
"plugin_type": str,
"probe_available": bool,
"granted": list[str],
"source": str,
"reason": str | None, # only when probe_available=False
"cached": bool,
# + any plugin-specific extras (roles, plugin_version, ...)
}
"""
from core.database import get_database
from core.encryption import get_credential_encryption
from plugins import registry as plugin_registry
db = get_database()
site = await db.get_site(site_id, user_id)
if site is None:
return {
"site_id": site_id,
"plugin_type": None,
"probe_available": False,
"granted": [],
"source": "unavailable",
"reason": "site_not_found",
"cached": False,
}
if not force:
cached = _cache.get(site_id)
if cached is not None:
out = dict(cached)
out["cached"] = True
return out
plugin_type = site["plugin_type"]
if not plugin_registry.is_registered(plugin_type):
return {
"site_id": site_id,
"plugin_type": plugin_type,
"probe_available": False,
"granted": [],
"source": "unavailable",
"reason": f"plugin_not_registered:{plugin_type}",
"cached": False,
}
try:
encryptor = get_credential_encryption()
credentials = encryptor.decrypt_credentials(site["credentials"], site_id)
except Exception as exc: # noqa: BLE001
logger.warning("capability_probe: decrypt failed for site %s: %s", site_id, exc)
return {
"site_id": site_id,
"plugin_type": plugin_type,
"probe_available": False,
"granted": [],
"source": "unavailable",
"reason": "credentials_decrypt_failed",
"cached": False,
}
config_dict: dict[str, Any] = {
"site_url": site["url"],
"url": site["url"],
"alias": site["alias"],
"user_id": user_id,
"site_id": site_id,
**credentials,
}
try:
instance = plugin_registry.create_instance(
plugin_type,
project_id=f"probe_{site_id}",
config=config_dict,
)
except Exception as exc: # noqa: BLE001
logger.warning("capability_probe: plugin instantiation failed for %s: %s", site_id, exc)
return {
"site_id": site_id,
"plugin_type": plugin_type,
"probe_available": False,
"granted": [],
"source": "unavailable",
"reason": f"plugin_instantiation_failed: {exc}",
"cached": False,
}
try:
result = await instance.probe_credential_capabilities()
except Exception as exc: # noqa: BLE001
logger.warning("capability_probe: probe call raised for site %s: %s", site_id, exc)
result = {
"probe_available": False,
"granted": [],
"source": "unavailable",
"reason": f"probe_call_failed: {exc}",
}
payload: dict[str, Any] = {
"site_id": site_id,
"plugin_type": plugin_type,
"probe_available": bool(result.get("probe_available", False)),
"granted": list(result.get("granted") or []),
"source": result.get("source") or "unavailable",
}
if not payload["probe_available"]:
payload["reason"] = result.get("reason")
# F.X.fix #3: propagate install_hint so the dashboard can render
# the "site unreachable / install companion" prompt without a
# second probe call.
# F.X.fix-pass3: also propagate routes + features so the
# tool-prerequisites resolver in core/tool_access can compute
# tool availability without a second probe call.
# F.X.fix-pass5: also propagate wp_credentials_present so the
# prerequisites resolver can auto-disable WC media tools when
# the site has no WP Application Password configured.
for extra in (
"roles",
"plugin_version",
"install_hint",
"routes",
"features",
"wp_credentials_present",
):
if extra in result:
payload[extra] = result[extra]
# F.X.fix-pass2 — surface the site's configured AI-provider set so
# the badge can show a distinct "no AI provider key" warning
# independent of tier fit. Even an Administrator WP credential
# can't run the AI image tool without a provider key. Cheap: one
# SQLite query over site_provider_keys.
try:
from core.site_api import list_site_providers_set
payload["ai_providers_configured"] = sorted(await list_site_providers_set(site_id))
except Exception as exc: # noqa: BLE001
logger.debug("capability_probe: provider-set lookup skipped for %s: %s", site_id, exc)
payload["ai_providers_configured"] = []
# Cache everything, including the "probe unavailable" answer — a
# missing companion plugin is a stable fact until the operator
# installs it and re-tests the connection.
_cache.set(site_id, payload)
payload_out = dict(payload)
payload_out["cached"] = False
return payload_out
# ---------------------------------------------------------------------------
# Starlette handler: GET /api/sites/{id}/capabilities
# ---------------------------------------------------------------------------
async def api_site_capabilities(request: Request) -> JSONResponse:
"""Return the capability probe for a user-owned site.
Auth: same OAuth user session guard as the other site endpoints.
Query params:
* ``force=1`` — bypass the 10-minute cache.
"""
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)
site_id = (request.path_params.get("id") or "").strip()
if not site_id:
return JSONResponse({"ok": False, "error": "invalid_request"}, status_code=400)
force = request.query_params.get("force") in {"1", "true", "True"}
payload = await probe_site_capabilities(
site_id=site_id, user_id=user_session["user_id"], force=force
)
if payload.get("reason") == "site_not_found":
return JSONResponse({"ok": False, "error": "site_not_found"}, status_code=404)
# F.7e: also evaluate fit against the site's currently-selected
# tool_scope tier so the UI can render the badge without needing a
# second request. The caller-supplied ?tier=... query param lets the
# dashboard preview a tier before save.
from core.database import get_database
db = get_database()
site = await db.get_site(site_id, user_session["user_id"])
site_tier = (site or {}).get("tool_scope")
# Allow the caller to override the tier (e.g. preview before save).
tier_override = request.query_params.get("tier")
tier = tier_override or site_tier
fit = evaluate_tier_fit(
plugin_type=payload.get("plugin_type") or "",
tier=tier,
probe_payload=payload,
)
return JSONResponse(
{
"ok": True,
**payload,
"tier": tier,
"fit": fit,
}
)
async def api_site_capabilities_badge(request: Request):
"""F.X.fix #9 — render the capability-badge template fragment.
Used by the HTMX Re-check button so the badge swaps in place
instead of forcing a full-page reload. Response is HTML (not JSON)
— the caller sets ``hx-swap="outerHTML"`` on the badge element.
"""
from starlette.responses import HTMLResponse
from core.dashboard.routes import _require_user_session, templates
user_session, redirect = _require_user_session(request)
if redirect or user_session is None:
return HTMLResponse("<div>unauthorized</div>", status_code=401)
site_id = (request.path_params.get("id") or "").strip()
if not site_id:
return HTMLResponse("<div>invalid_request</div>", status_code=400)
force = request.query_params.get("force") in {"1", "true", "True"}
payload = await probe_site_capabilities(
site_id=site_id, user_id=user_session["user_id"], force=force
)
if payload.get("reason") == "site_not_found":
return HTMLResponse("<div>site_not_found</div>", status_code=404)
from core.database import get_database
db = get_database()
site = await db.get_site(site_id, user_session["user_id"])
if site is None:
return HTMLResponse("<div>site_not_found</div>", status_code=404)
tier = request.query_params.get("tier") or site.get("tool_scope")
fit = evaluate_tier_fit(
plugin_type=payload.get("plugin_type") or "",
tier=tier,
probe_payload=payload,
)
capability_probe = {**payload, "tier": tier, "fit": fit}
# Pull up the companion download URL the same way the page does.
try:
from plugins.wordpress.handlers._companion_hint import COMPANION_DOWNLOAD_URL
companion_download_url: str | None = COMPANION_DOWNLOAD_URL
except Exception: # noqa: BLE001
companion_download_url = None
lang = request.query_params.get("lang") or request.cookies.get("lang") or "en"
return templates.TemplateResponse(
request,
"dashboard/sites/_capability_badge.html",
{
"capability_probe": capability_probe,
"site": site,
"lang": lang,
"companion_download_url": companion_download_url,
},
)