Files
mcphub/core/tool_access.py
airano-ir 43fd2201a0 feat(v3.13.0): settings live DB reads, remove Directus/Appwrite, admin stats
Settings fixes:
- MAX_SITES_PER_USER, USER_RATE_LIMIT_PER_MIN/HR now read from DB settings
  table (DB > ENV > default), so dashboard/settings changes apply without
  restart. Sync cache refreshed on every save or delete.
- /api/me reports the live DB value for max_sites_per_user.

Admin improvements:
- Admin users bypass per-user rate limiting entirely (role=admin or ADMIN_EMAILS).
- Admin Overview now shows platform stats: registered users, new users (7d),
  total user sites, available tools.

Plugin cleanup:
- Appwrite and Directus plugins removed from the active registry (8 plugins
  now: WordPress, WooCommerce, WordPress Specialist, Gitea, n8n, Supabase,
  OpenPanel, Coolify). Plugin code is retained for future re-enabling.
- Settings page plugin visibility list updated to match.

Mobile onboarding:
- Stepper steps on narrow viewports stack vertically with correct full border
  and rounded corners on each step.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-20 23:33:20 +02:00

884 lines
34 KiB
Python

"""Tool access manager — site-scoped visibility and per-site toggles (F.7c).
Provides a central pipeline that filters the set of MCP tools presented for
a user endpoint based on:
1. **Universal scope tiers.** A 3-level system (``read`` / ``write`` /
``admin``) that works across ALL plugins using the tool's
``required_scope`` field. For Coolify (which has fine-grained
``category`` annotations) the legacy category mapping is kept as an
overlay for the ``custom`` preset.
2. **Per-site tool toggles.** Site owners may explicitly disable specific
tools via the ``site_tool_toggles`` table. Only overrides are stored —
tools without an entry are enabled by default.
The ``tool_scope`` value ``"custom"`` is a sentinel meaning "do not apply a
site-level preset filter" — in that case only the per-tool toggles and the
key scope are considered.
Usage::
from core.tool_access import get_tool_access_manager
mgr = get_tool_access_manager()
visible = await mgr.get_visible_tools(
site_id=site["id"],
key_scopes=["read"],
plugin_type="coolify",
)
"""
from __future__ import annotations
import logging
from typing import Any
from core.tool_registry import ToolDefinition
logger = logging.getLogger(__name__)
# ── Universal scope tier system (F.7c → F.19.2.0) ───────────────────
# Maps a scope tier to the set of ``required_scope`` values it may access.
# Works for ALL plugins because every tool has ``required_scope``.
#
# Ladder (each tier is a strict superset of the one above):
#
# read │ inventory + diagnostics only
# editor │ + content / theme-file edits (F.19.5 + F.19.7)
# settings │ + options / cron / maintenance / site-identity / permalinks (F.19.6)
# install │ + plugin & theme install / activate / update from wp.org (F.19.2)
# write │ catch-all "non-destructive write" tier (legacy WP/WC/Gitea)
# admin │ destructive ops (URL/zip install, delete user/plugin/theme,
# │ user CRUD, db_query, anything that can lose data)
#
# Why ``write`` is between ``install`` and ``admin``: keeps existing
# WordPress/WooCommerce/Gitea/etc. keys (which use plain ``write``)
# working. They retain the same effective access they had pre-F.19.2,
# minus the destructive ops which now require ``admin``.
UNIVERSAL_SCOPE_TIERS: dict[str, set[str]] = {
"read": {"read"},
# F.19.5 — ``editor``: page-content editing without unlocking
# destructive ops. wordpress_specialist uses it; other plugins fall
# through to the existing read/write/admin ladder.
"editor": {"read", "editor"},
# F.19.6 — ``settings``: options / cron / maintenance toggle /
# site identity / reading + permalinks. Fits between editor and
# install — settings changes are recoverable, plugin installs can
# reshape the site.
"settings": {"read", "editor", "settings"},
# F.19.2 — ``install``: plugin/theme install + activate + update
# from wp.org slugs. URL/zip install routes belong to ``admin``
# (sees more attack surface). delete_plugin / delete_theme also
# belong to ``admin`` because they can drop data with no undo.
"install": {"read", "editor", "settings", "install"},
# ``write`` is the historical catch-all for non-destructive writes
# (WordPress create_post, WooCommerce update_order, etc.).
"write": {"read", "editor", "settings", "install", "write"},
"admin": {"read", "editor", "settings", "install", "write", "admin"},
}
# Per-tier descriptions for dashboard tooltips + tier toggles. EN + FA
# in lockstep with the rest of the dashboard's i18n.
TIER_DESCRIPTIONS: dict[str, dict[str, str]] = {
"read": {
"label_en": "Read Only",
"label_fa": "فقط خواندن",
"hint_en": "Inventory and diagnostics — no changes.",
"hint_fa": "مشاهده و عیب‌یابی — بدون تغییر.",
},
"editor": {
"label_en": "Editor",
"label_fa": "ویرایشگر",
"hint_en": "Read + page / theme content edits.",
"hint_fa": "خواندن + ویرایش محتوا و فایل قالب.",
},
"settings": {
"label_en": "Settings",
"label_fa": "تنظیمات",
"hint_en": "Editor + site options, cron, identity, permalinks.",
"hint_fa": "ویرایشگر + تنظیمات سایت، کرون، هویت، پرمالینک.",
},
"install": {
"label_en": "Installer",
"label_fa": "نصب‌کننده",
"hint_en": "Settings + install / activate plugins & themes from wp.org.",
"hint_fa": "تنظیمات + نصب و فعال‌سازی پلاگین/قالب از wp.org.",
},
"write": {
"label_en": "Write",
"label_fa": "نوشتن",
"hint_en": "Non-destructive write tier (post/product CRUD, etc.).",
"hint_fa": "تیر نوشتن غیرتخریبی (ایجاد/ویرایش پست، محصول و …).",
},
"admin": {
"label_en": "Admin",
"label_fa": "مدیر",
"hint_en": "Includes destructive ops (delete, URL/zip install, user CRUD).",
"hint_fa": "شامل عملیات تخریبی (حذف، نصب از URL/zip، CRUD کاربر).",
},
}
# ── Legacy Coolify category mapping (kept for ``custom`` overlay) ─────
# Mapping from scope → set of tool categories that scope may see.
SCOPE_TO_CATEGORIES: dict[str, set[str]] = {
"read": {"read"},
"read:sensitive": {"read", "read_sensitive", "backup"},
"deploy": {"read", "lifecycle"},
"write": {"read", "lifecycle", "crud", "env"},
"admin": {
"read",
"read_sensitive",
"lifecycle",
"crud",
"env",
"backup",
"system",
},
}
# All known Coolify categories.
KNOWN_CATEGORIES: set[str] = {
"read",
"read_sensitive",
"lifecycle",
"crud",
"env",
"backup",
"system",
}
# Sentinel meaning "no site-level preset filter — use per-tool toggles only".
SCOPE_CUSTOM = "custom"
# Plugins that have fine-grained category annotations.
_CATEGORY_PLUGINS: set[str] = {"coolify"}
def scopes_to_categories(scopes: list[str]) -> set[str]:
"""Return the union of categories allowed by the given scope list.
Args:
scopes: List of scope strings as presented on the API key / token.
Returns:
Set of category names the scopes collectively allow.
"""
allowed: set[str] = set()
for scope in scopes:
allowed |= SCOPE_TO_CATEGORIES.get(scope.strip(), set())
return allowed
def _scopes_to_required(scopes: list[str]) -> set[str]:
"""Return the union of ``required_scope`` values allowed by universal tiers."""
allowed: set[str] = set()
for scope in scopes:
allowed |= UNIVERSAL_SCOPE_TIERS.get(scope.strip(), set())
return allowed
def get_scope_presets_for_plugin(plugin_type: str) -> list[dict[str, str]]:
"""Return the appropriate scope presets for a plugin type (F.7d).
Each preset is a dict with ``value`` (the canonical scope key persisted
to ``sites.tool_scope``), ``label`` / ``label_fa`` (button title), and
``hint`` / ``hint_fa`` (one-line description shown under the title).
The valid scope values are constrained by ``_VALID_TOOL_SCOPES`` in
``core.dashboard.routes`` — currently:
``{"read", "read:sensitive", "deploy", "write", "admin", "custom"}``.
"""
custom = {
"value": "custom",
"label": "Custom",
"label_fa": "سفارشی",
"hint": "Per-tool toggles",
"hint_fa": "هر ابزار جداگانه",
}
if plugin_type == "coolify":
# 5 fine-grained Coolify tiers + custom (matches SCOPE_TO_CATEGORIES).
return [
{
"value": "read",
"label": "Read",
"label_fa": "خواندن",
"hint": "List/inspect resources",
"hint_fa": "مشاهده و فهرست منابع",
},
{
"value": "read:sensitive",
"label": "Read + Secrets",
"label_fa": "خواندن + اسرار",
"hint": "Includes env vars and backups",
"hint_fa": "شامل متغیرهای محیطی و بکاپ",
},
{
"value": "deploy",
"label": "Deploy",
"label_fa": "استقرار",
"hint": "Read + lifecycle (start/stop/restart)",
"hint_fa": "مشاهده + راه‌اندازی/توقف/ریستارت",
},
{
"value": "write",
"label": "Write",
"label_fa": "نوشتن",
"hint": "Read + lifecycle + CRUD + env",
"hint_fa": "مشاهده + لایفسایکل + CRUD + env",
},
{
"value": "admin",
"label": "Root",
"label_fa": "روت",
"hint": "Everything including system commands",
"hint_fa": "همه چیز شامل دستورات سیستم",
},
custom,
]
if plugin_type == "openpanel":
return [
{
"value": "read",
"label": "Read",
"label_fa": "خواندن",
"hint": "Export current project only",
"hint_fa": "خروجی پروژه فعلی",
},
{
"value": "write",
"label": "Write",
"label_fa": "نوشتن",
"hint": "Default ingestion (track events)",
"hint_fa": "ارسال رویداد (ingestion)",
},
{
"value": "admin",
"label": "Root",
"label_fa": "روت",
"hint": "Export any project",
"hint_fa": "خروجی هر پروژه",
},
custom,
]
if plugin_type == "woocommerce":
# WooCommerce has no admin-scope tools (read=14, write=14, admin=0),
# so Write and "Read + Write" tiers are identical. Present a single
# full-access tier instead of two duplicates.
return [
{
"value": "read",
"label": "Read Only",
"label_fa": "فقط خواندن",
"hint": "Browse products, orders, customers",
"hint_fa": "مشاهده محصولات، سفارش‌ها و مشتریان",
},
{
"value": "admin",
"label": "Read + Write",
"label_fa": "خواندن + نوشتن",
"hint": "Full store management (all 28 tools)",
"hint_fa": "مدیریت کامل فروشگاه (همه ۲۸ ابزار)",
},
custom,
]
if plugin_type == "wordpress":
# WordPress has no admin-scope tools (read=27, write=40, admin=0).
# SEO + plugin/theme tools require the Airano MCP SEO Bridge plugin
# to be installed on the WP site itself. Present 2 tiers + custom.
return [
{
"value": "read",
"label": "Read Only",
"label_fa": "فقط خواندن",
"hint": "View posts, pages, media",
"hint_fa": "مشاهده نوشته‌ها، صفحات و رسانه",
},
{
"value": "admin",
"label": "Full Access",
"label_fa": "دسترسی کامل",
"hint": "All tools (CRUD + SEO via add-on)",
"hint_fa": "همه ابزارها (CRUD و SEO با افزونه)",
},
custom,
]
if plugin_type == "wordpress_specialist":
# F.19.1 ships READ-only tools (inventory + diagnostics).
# F.19.5 adds the EDITOR tier — page editing (Gutenberg + Elementor
# + Classic) on top of read.
# F.19.7 adds the THEME DEV surface on the editor tier (file CRUD)
# and the install/admin tiers for theme install/activate/delete.
# F.19.2.1 adds plugin install/activate/deactivate/update (install
# tier) and plugin install-from-zip + delete (admin tier).
# F.19.6 will surface a SETTINGS tier between editor and install.
return [
{
"value": "read",
"label": "Read Only",
"label_fa": "فقط خواندن",
"hint": "Inventory plugins/themes/users/options/cron — no changes",
"hint_fa": "مشاهده پلاگین‌ها/قالب‌ها/کاربران/تنظیمات/کرون — بدون تغییر",
},
{
"value": "editor",
"label": "Editor",
"label_fa": "ویرایشگر",
"hint": "Read + page editing (Gutenberg blocks, Elementor, Classic) + theme file CRUD",
"hint_fa": "خواندن + ویرایش صفحه (بلوک‌ها، المنتور، کلاسیک) + ویرایش فایل قالب",
},
{
"value": "install",
"label": "Installer",
"label_fa": "نصب‌کننده",
"hint": "Editor + install/activate plugins & themes from wp.org",
"hint_fa": "ویرایشگر + نصب و فعال‌سازی پلاگین/قالب از wp.org",
},
{
"value": "admin",
"label": "Admin",
"label_fa": "مدیر",
"hint": "Installer + URL/zip install, plugin/theme delete (destructive)",
"hint_fa": "نصب‌کننده + نصب از URL/zip، حذف پلاگین/قالب (تخریبی)",
},
custom,
]
if plugin_type == "gitea":
return [
{
"value": "read",
"label": "Read",
"label_fa": "خواندن",
"hint": "Browse repos, issues, users",
"hint_fa": "مشاهده مخازن، ایشوها، کاربران",
},
{
"value": "write",
"label": "Read + Write",
"label_fa": "خواندن + نوشتن",
"hint": "Create issues, PRs, branches",
"hint_fa": "ایجاد ایشو، PR و شاخه",
},
{
"value": "admin",
"label": "Admin",
"label_fa": "مدیر",
"hint": "Repo + org + user admin",
"hint_fa": "مدیریت مخزن، سازمان و کاربر",
},
custom,
]
# Universal default for all other plugins.
return [
{
"value": "read",
"label": "Read",
"label_fa": "فقط خواندن",
"hint": "View only",
"hint_fa": "فقط مشاهده",
},
{
"value": "write",
"label": "Read + Write",
"label_fa": "خواندن + نوشتن",
"hint": "CRUD ops",
"hint_fa": "عملیات CRUD",
},
{
"value": "admin",
"label": "Full Access",
"label_fa": "دسترسی کامل",
"hint": "All tools",
"hint_fa": "همه ابزارها",
},
custom,
]
class ToolAccessManager:
"""Central manager for scope-based visibility and per-site tool toggles."""
def apply_scope_filter(
self,
tools: list[ToolDefinition],
scopes: list[str],
plugin_type: str | None = None,
) -> list[ToolDefinition]:
"""Drop tools not allowed by the presented scopes.
For plugins with category annotations (Coolify) the legacy
category-based filter is used. For all other plugins the universal
3-tier filter based on ``required_scope`` is applied.
Args:
tools: Candidate tool list.
scopes: Scopes presented on the API key (or a single-element list
containing a site's ``tool_scope`` preset).
plugin_type: Plugin type hint. When provided and the plugin is
NOT in ``_CATEGORY_PLUGINS``, the universal tier filter is
used.
Returns:
Filtered tool list.
"""
# Try universal tiers first (works for all plugins)
allowed_scopes = _scopes_to_required(scopes)
if allowed_scopes and (plugin_type is None or plugin_type not in _CATEGORY_PLUGINS):
# Universal filter: match tool.required_scope against allowed tiers
return [t for t in tools if t.required_scope in allowed_scopes]
# Fallback: legacy category-based filter for Coolify / custom scopes
allowed = scopes_to_categories(scopes)
if not allowed:
return [t for t in tools if t.category not in KNOWN_CATEGORIES]
result: list[ToolDefinition] = []
for tool in tools:
if tool.category not in KNOWN_CATEGORIES:
result.append(tool)
continue
if tool.category in allowed:
result.append(tool)
return result
async def apply_site_toggles(
self,
tools: list[ToolDefinition],
site_id: str,
) -> list[ToolDefinition]:
"""Drop tools the site owner has explicitly disabled.
Args:
tools: Candidate tool list.
site_id: Site UUID.
Returns:
Filtered tool list.
"""
from core.database import get_database
try:
db = get_database()
except RuntimeError:
return tools
toggles = await db.get_site_tool_toggles(site_id)
if not toggles:
return tools
return [t for t in tools if toggles.get(t.name, True)]
async def get_visible_tools(
self,
site_id: str,
key_scopes: list[str],
plugin_type: str,
) -> list[ToolDefinition]:
"""Return the visible tool list for a site on a given plugin.
Pipeline:
1. ``ToolRegistry.get_by_plugin_type``
2. Key-scope filter (API key's declared scopes)
3. Site-scope filter (site's stored ``tool_scope`` preset,
skipped when it is ``custom``)
4. Per-site toggle filter (``site_tool_toggles``)
Args:
site_id: Site UUID (the MCP endpoint alias resolves to this).
key_scopes: Scopes presented on the API key / token.
plugin_type: Plugin type (e.g. ``coolify``).
Returns:
List of visible ``ToolDefinition`` objects.
"""
from core.database import get_database
from core.tool_registry import get_tool_registry
registry = get_tool_registry()
tools = registry.get_by_plugin_type(plugin_type)
tools = self.apply_scope_filter(tools, key_scopes, plugin_type=plugin_type)
try:
db = get_database()
site_scope = await db.get_site_tool_scope(site_id)
except RuntimeError:
site_scope = "admin"
if site_scope and site_scope != SCOPE_CUSTOM:
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(
self,
site_id: str,
tool_name: str,
enabled: bool,
reason: str | None = None,
) -> None:
"""Enable or disable a single tool for a site.
Args:
site_id: Site UUID.
tool_name: Fully-qualified tool name.
enabled: True to enable, False to disable.
reason: Optional note.
"""
from core.database import get_database
db = get_database()
await db.set_site_tool_toggle(site_id, tool_name, enabled, reason)
logger.info(
"site %s toggled %s%s",
site_id,
tool_name,
"enabled" if enabled else "disabled",
)
async def bulk_toggle_by_scope(
self,
site_id: str,
scope_name: str,
enabled: bool,
plugin_type: str | None = None,
) -> int:
"""Toggle every tool whose category belongs to the given scope.
Only the *exclusive* category set of the scope is affected — i.e.
the categories explicitly listed under ``SCOPE_TO_CATEGORIES[scope_name]``.
Tools outside those categories are left unchanged.
Args:
site_id: Site UUID.
scope_name: Scope key (``"read"``, ``"deploy"``, ...).
enabled: True to enable, False to disable.
plugin_type: Optional filter — only affect tools from this plugin.
When ``None`` every plugin's tools in that category are touched.
Returns:
Number of tools affected.
"""
from core.database import get_database
from core.tool_registry import get_tool_registry
categories = SCOPE_TO_CATEGORIES.get(scope_name)
if categories is None:
raise ValueError(f"Unknown scope '{scope_name}'")
registry = get_tool_registry()
candidates = registry.get_all()
if plugin_type is not None:
candidates = [t for t in candidates if t.plugin_type == plugin_type]
affected = [t.name for t in candidates if t.category in categories]
if not affected:
return 0
db = get_database()
await db.bulk_set_site_tool_toggles(
site_id,
[(name, enabled) for name in affected],
reason=f"bulk:{scope_name}",
)
return len(affected)
async def list_tools_for_site(
self,
site_id: str,
plugin_type: str,
) -> list[dict[str, Any]]:
"""Return every tool for a plugin, annotated with per-site toggle state.
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.
Returns:
List of dicts with tool metadata + ``enabled`` flag.
"""
from core.database import get_database
from core.tool_registry import get_tool_registry
try:
db = get_database()
toggles = await db.get_site_tool_toggles(site_id)
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)
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",
"woocommerce_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
_manager: ToolAccessManager | None = None
def get_tool_access_manager() -> ToolAccessManager:
"""Return the singleton :class:`ToolAccessManager`."""
global _manager
if _manager is None:
_manager = ToolAccessManager()
return _manager