Files
mcphub/core/tool_access.py
airano-ir 788439e377
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(F.7+F.17): v3.11.0 — Coolify plugin (67 tools) + tool access overhaul
Catch-up sync spanning v3.7.0 → v3.11.0 of the internal repo.

Platform
- Total tools: 565 → 633 (+68) across 10 plugins (Coolify added)
- Tests: 481 → 828 passing

New plugin: Coolify (67 tools, Track F.17)
- Applications (17): CRUD, lifecycle, logs, env vars
- Deployments (5): list/get/cancel/deploy, app history
- Servers (8): CRUD, resources, domains, validation
- Projects (8), Databases (16, 6 DB types + backups), Services (13)

Tool access system (Track F.7 → F.7d)
- Scope → category mapping with per-tool `category` + `sensitivity`
- Schema v7: `site_tool_toggles(site_id)` + `sites.tool_scope` column
- Schema v8: per-site API keys (`api_keys.site_id`)
- Plugin-specific access-level presets (WP / WC / Gitea / OpenPanel /
  Coolify 5-tier)
- Credential-requirement notice tailored per plugin and tier
- Admin Tools count card on service page
- Dropped redundant `write` tier on WP / WP Advanced / WooCommerce
  (admin-scope tool count = 0 → identical to admin tier)

Dashboard
- Unified site manage page (Connection / Tool Access / Connect)
- /dashboard/keys unified (was /api-keys and /connect)
- CSRF interceptor via meta-tag; removed conflicting cookie reader
- Tailwind: pre-built CSS (scripts/build-css.sh) replaces CDN

Docs
- README / DOCKER_README / CLAUDE updated to 633 tools / 10 plugins
- CHANGELOG entries for v3.7.0 → v3.11.0
- FastMCP compatibility note updated to 3.x (post-v3.5 upgrade)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-14 21:05:07 +02:00

533 lines
18 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 3-tier scope system (F.7c) ─────────────────────────────
# Maps a scope tier to the set of ``required_scope`` values it may access.
# Works for ALL plugins because every tool has ``required_scope``.
UNIVERSAL_SCOPE_TIERS: dict[str, set[str]] = {
"read": {"read"},
"write": {"read", "write"},
"admin": {"read", "write", "admin"},
}
# ── 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 in {"wordpress", "wordpress_advanced"}:
# 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 == "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)
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.
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 = {}
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
]
# 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