diff --git a/CLAUDE.md b/CLAUDE.md
index 201e9ef..be9cedc 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -4,7 +4,7 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
## Project Overview
-**MCP Hub** — a Python MCP (Model Context Protocol) server that manages multiple self-hosted services through a unified plugin architecture. Supports 10 plugin types (WordPress, WooCommerce, WordPress Advanced, Gitea, n8n, Supabase, OpenPanel, Appwrite, Directus, Coolify) with 633 tools total. The tool count stays constant regardless of how many sites are configured.
+**MCP Hub** — a Python MCP (Model Context Protocol) server that manages multiple self-hosted services through a unified plugin architecture. Supports 10 plugin types (WordPress, WooCommerce, WordPress Advanced, Gitea, n8n, Supabase, OpenPanel, Appwrite, Directus, Coolify) with 597 tools total. The tool count stays constant regardless of how many sites are configured.
## Quick Setup
diff --git a/README.md b/README.md
index a2c2cbc..f9de079 100644
--- a/README.md
+++ b/README.md
@@ -13,8 +13,8 @@ Connect your sites, stores, repos, and databases — manage them all through Cla
[](https://www.python.org/)
[](https://pypi.org/project/mcphub-server/)
[](https://hub.docker.com/r/airano/mcphub)
-[]()
-[]()
+[]()
+[]()
[](https://github.com/airano-ir/mcphub/actions/workflows/ci.yml)
@@ -265,7 +265,7 @@ MCP Hub supports **Open Dynamic Client Registration** (RFC 7591). ChatGPT can au
## Architecture
```
-/mcp → Admin endpoint (all 633 tools)
+/mcp → Admin endpoint (all 565 tools)
/system/mcp → System tools only (24 tools)
/wordpress/mcp → WordPress tools (67 tools)
/woocommerce/mcp → WooCommerce tools (28 tools)
@@ -275,13 +275,12 @@ MCP Hub supports **Open Dynamic Client Registration** (RFC 7591). ChatGPT can au
/supabase/mcp → Supabase tools (70 tools)
/openpanel/mcp → OpenPanel tools (42 tools)
/appwrite/mcp → Appwrite tools (100 tools)
-/coolify/mcp → Coolify tools (67 tools)
/directus/mcp → Directus tools (100 tools)
/project/{alias}/mcp → Per-project endpoint (auto-injects site)
/u/{user_id}/{alias}/mcp → Per-user endpoint (hosted/OAuth users)
```
-**Recommendation**: Use plugin-specific endpoints instead of `/mcp` (633 tools) to minimize token usage.
+**Recommendation**: Use plugin-specific endpoints instead of `/mcp` (565 tools) to minimize token usage.
| Endpoint | Use Case | Tools |
|----------|----------|------:|
diff --git a/core/dashboard/routes.py b/core/dashboard/routes.py
index af5f82e..7ad1879 100644
--- a/core/dashboard/routes.py
+++ b/core/dashboard/routes.py
@@ -160,6 +160,7 @@ DASHBOARD_TRANSLATIONS = {
"admin_login": "Admin Login with API Key",
"profile": "Profile",
"admin_badge": "Admin",
+ "keys": "API Keys",
},
"fa": {
# Navigation
@@ -265,6 +266,7 @@ DASHBOARD_TRANSLATIONS = {
"admin_login": "ورود مدیر با کلید API",
"profile": "پروفایل",
"admin_badge": "مدیر",
+ "keys": "کلیدهای API",
},
}
@@ -2905,6 +2907,134 @@ async def dashboard_connect_page(request: Request) -> Response:
)
+# ======================================================================
+# Site View Route (F.7b session 2)
+# ======================================================================
+
+
+async def dashboard_sites_view(request: Request) -> Response:
+ """GET /dashboard/sites/{id} — Show site connect page with config snippets."""
+ user_session, redirect = _require_user_session(request)
+ if redirect:
+ return redirect
+
+ site_id = request.path_params.get("id", "")
+
+ from core.config_snippets import get_supported_clients
+ from core.site_api import PLUGIN_DISPLAY_NAMES as SITE_PLUGIN_NAMES
+ from core.site_api import get_user_site
+
+ site = await get_user_site(site_id, user_session["user_id"])
+ if site is None:
+ return RedirectResponse("/dashboard/sites?error=site_not_found", status_code=302)
+
+ accept_language = request.headers.get("accept-language")
+ query_lang = request.query_params.get("lang")
+ lang = detect_language(accept_language, query_lang)
+ t = get_translations(lang)
+
+ public_url = os.environ.get("PUBLIC_URL", "http://localhost:8000").rstrip("/")
+ mcp_url = f"{public_url}/u/{user_session['user_id']}/{site['alias']}/mcp"
+
+ return templates.TemplateResponse(
+ request,
+ "dashboard/sites/view.html",
+ {
+ "lang": lang,
+ "t": t,
+ "session": user_session,
+ "site": site,
+ "plugin_names": SITE_PLUGIN_NAMES,
+ "mcp_url": mcp_url,
+ "clients": get_supported_clients(),
+ "current_page": "my_sites",
+ },
+ )
+
+
+# ======================================================================
+# Unified Keys Route (F.7b session 2)
+# ======================================================================
+
+
+async def dashboard_keys_unified(request: Request) -> Response:
+ """GET /dashboard/keys — Unified API keys page (user or admin view)."""
+ accept_language = request.headers.get("accept-language")
+ query_lang = request.query_params.get("lang")
+ lang = detect_language(accept_language, query_lang)
+ t = get_translations(lang)
+
+ auth = get_dashboard_auth()
+ admin_session = auth.get_session_from_request(request)
+ user_session = auth.get_user_session_from_request(request)
+
+ if admin_session and is_admin_session(admin_session):
+ # Admin view — reuse existing admin keys logic
+ project_filter = request.query_params.get("project", "")
+ status_filter = request.query_params.get("status", "active")
+ search = request.query_params.get("search", "")
+ page = int(request.query_params.get("page", 1))
+
+ keys_data = await get_all_api_keys(
+ project_id=project_filter if project_filter else None,
+ status=status_filter,
+ search=search if search else None,
+ page=page,
+ )
+
+ from core.site_manager import get_site_manager
+
+ site_manager = get_site_manager()
+ available_projects = site_manager.list_all_sites()
+
+ return templates.TemplateResponse(
+ request,
+ "dashboard/keys/list.html",
+ {
+ "lang": lang,
+ "t": t,
+ "session": admin_session,
+ "is_admin": True,
+ "api_keys": keys_data["api_keys"],
+ "total_count": keys_data["total_count"],
+ "total_pages": keys_data["total_pages"],
+ "page_number": keys_data["current_page"],
+ "per_page": keys_data["per_page"],
+ "available_projects": available_projects,
+ "selected_project": project_filter,
+ "selected_status": status_filter,
+ "search_query": search,
+ "current_page": "keys",
+ },
+ )
+
+ if user_session:
+ # User view — personal keys
+ from core.user_keys import get_user_key_manager
+
+ user_keys = []
+ try:
+ key_mgr = get_user_key_manager()
+ user_keys = await key_mgr.list_keys(user_session["user_id"])
+ except RuntimeError:
+ pass
+
+ return templates.TemplateResponse(
+ request,
+ "dashboard/keys/list.html",
+ {
+ "lang": lang,
+ "t": t,
+ "session": user_session,
+ "is_admin": False,
+ "user_keys": user_keys,
+ "current_page": "keys",
+ },
+ )
+
+ return RedirectResponse(url="/auth/login", status_code=303)
+
+
# ======================================================================
# Site Management API Routes (E.3)
# ======================================================================
@@ -3162,6 +3292,181 @@ async def api_delete_key(request: Request) -> Response:
return JSONResponse({"error": str(e)}, status_code=503)
+# ----------------------------------------------------------------------
+# F.7b: per-site tool visibility management
+# ----------------------------------------------------------------------
+
+
+_VALID_TOOL_SCOPES = {"read", "read:sensitive", "deploy", "write", "admin", "custom"}
+
+
+async def _require_owned_site(request: Request) -> tuple[dict | None, Response | None]:
+ """Resolve ``{site_id}`` path param → site row owned by the current user.
+
+ Returns ``(site, None)`` on success, or ``(None, error_response)``.
+ """
+ user_session, redirect = _require_user_session(request)
+ if redirect:
+ return None, JSONResponse({"error": "Unauthorized"}, status_code=401)
+
+ site_id = request.path_params.get("site_id", "")
+ if not site_id:
+ return None, JSONResponse({"error": "Missing site_id"}, status_code=400)
+
+ from core.database import get_database
+
+ try:
+ db = get_database()
+ except RuntimeError:
+ return None, JSONResponse({"error": "Database unavailable"}, status_code=503)
+
+ site = await db.get_site(site_id, user_session["user_id"])
+ if site is None:
+ return None, JSONResponse({"error": "Site not found"}, status_code=404)
+ return site, None
+
+
+async def api_list_site_tools(request: Request) -> Response:
+ """GET /api/sites/{site_id}/tools — list tools for a site with toggle state."""
+ site, err = await _require_owned_site(request)
+ if err:
+ return err
+ assert site is not None
+
+ from core.tool_access import get_tool_access_manager
+
+ access = get_tool_access_manager()
+ tools = await access.list_tools_for_site(site["id"], site["plugin_type"])
+ return JSONResponse(
+ {
+ "site_id": site["id"],
+ "plugin_type": site["plugin_type"],
+ "tool_scope": site.get("tool_scope", "admin"),
+ "tools": tools,
+ }
+ )
+
+
+async def api_patch_site_tool(request: Request) -> Response:
+ """PATCH /api/sites/{site_id}/tools/{tool_name} — toggle a single tool."""
+ site, err = await _require_owned_site(request)
+ if err:
+ return err
+ assert site is not None
+
+ tool_name = request.path_params.get("tool_name", "")
+ try:
+ body = await request.json()
+ except Exception:
+ return JSONResponse({"error": "Invalid JSON body"}, status_code=400)
+
+ if "enabled" not in body or not isinstance(body["enabled"], bool):
+ return JSONResponse({"error": "Missing boolean 'enabled' field"}, status_code=400)
+
+ from core.tool_access import get_tool_access_manager
+ from core.tool_registry import get_tool_registry
+
+ tool_def = get_tool_registry().get_by_name(tool_name)
+ if tool_def is None:
+ return JSONResponse({"error": f"Unknown tool '{tool_name}'"}, status_code=404)
+ if tool_def.plugin_type != site["plugin_type"]:
+ return JSONResponse(
+ {"error": f"Tool '{tool_name}' does not belong to this site's plugin"},
+ status_code=400,
+ )
+
+ access = get_tool_access_manager()
+ try:
+ await access.toggle_tool(
+ site["id"],
+ tool_name,
+ bool(body["enabled"]),
+ body.get("reason"),
+ )
+ except RuntimeError as exc:
+ return JSONResponse({"error": str(exc)}, status_code=503)
+
+ return JSONResponse({"ok": True, "tool_name": tool_name, "enabled": body["enabled"]})
+
+
+async def api_bulk_toggle_site_tools(request: Request) -> Response:
+ """POST /api/sites/{site_id}/tools/bulk-toggle — toggle a category set."""
+ site, err = await _require_owned_site(request)
+ if err:
+ return err
+ assert site is not None
+
+ try:
+ body = await request.json()
+ except Exception:
+ return JSONResponse({"error": "Invalid JSON body"}, status_code=400)
+
+ scope = body.get("scope")
+ enabled = body.get("enabled")
+ if not isinstance(scope, str) or not isinstance(enabled, bool):
+ return JSONResponse(
+ {"error": "Body must contain string 'scope' and bool 'enabled'"},
+ status_code=400,
+ )
+
+ from core.tool_access import get_tool_access_manager
+
+ access = get_tool_access_manager()
+ try:
+ affected = await access.bulk_toggle_by_scope(
+ site["id"], scope, enabled, plugin_type=site["plugin_type"]
+ )
+ except ValueError as exc:
+ return JSONResponse({"error": str(exc)}, status_code=400)
+ except RuntimeError as exc:
+ return JSONResponse({"error": str(exc)}, status_code=503)
+
+ return JSONResponse({"ok": True, "affected": affected})
+
+
+async def api_set_site_tool_scope(request: Request) -> Response:
+ """PATCH /api/sites/{site_id}/tool-scope — update the site's scope preset."""
+ site, err = await _require_owned_site(request)
+ if err:
+ return err
+ assert site is not None
+
+ try:
+ body = await request.json()
+ except Exception:
+ return JSONResponse({"error": "Invalid JSON body"}, status_code=400)
+
+ scope = body.get("scope")
+ if not isinstance(scope, str) or scope not in _VALID_TOOL_SCOPES:
+ return JSONResponse(
+ {
+ "error": (
+ "Body must contain 'scope' with one of: "
+ + ", ".join(sorted(_VALID_TOOL_SCOPES))
+ )
+ },
+ status_code=400,
+ )
+
+ from core.database import get_database
+
+ db = get_database()
+ await db.set_site_tool_scope(site["id"], scope)
+ return JSONResponse({"ok": True, "site_id": site["id"], "tool_scope": scope})
+
+
+async def api_scope_presets(request: Request) -> Response:
+ """GET /api/scope-presets — static scope → categories mapping."""
+ user_session, redirect = _require_user_session(request)
+ if redirect:
+ return JSONResponse({"error": "Unauthorized"}, status_code=401)
+ del user_session
+
+ from core.tool_access import SCOPE_TO_CATEGORIES
+
+ return JSONResponse({"presets": {k: sorted(v) for k, v in SCOPE_TO_CATEGORIES.items()}})
+
+
async def api_get_config(request: Request) -> Response:
"""GET /api/config/{alias} — Get config snippets for a site."""
user_session, redirect = _require_user_session(request)
@@ -3471,8 +3776,11 @@ def register_dashboard_routes(mcp):
dashboard_project_detail
)
- # API Keys routes
- mcp.custom_route("/dashboard/api-keys", methods=["GET"])(dashboard_api_keys_list)
+ # API Keys routes (unified — /dashboard/keys replaces /dashboard/api-keys and /dashboard/connect)
+ mcp.custom_route("/dashboard/keys", methods=["GET"])(dashboard_keys_unified)
+ mcp.custom_route("/dashboard/api-keys", methods=["GET"])(
+ lambda r: RedirectResponse("/dashboard/keys", status_code=301)
+ )
# OAuth Clients routes
mcp.custom_route("/dashboard/oauth-clients", methods=["GET"])(dashboard_oauth_clients_list)
@@ -3519,7 +3827,11 @@ def register_dashboard_routes(mcp):
mcp.custom_route("/dashboard/sites", methods=["GET"])(dashboard_sites_list)
mcp.custom_route("/dashboard/sites/add", methods=["GET"])(dashboard_sites_add)
mcp.custom_route("/dashboard/sites/{id}/edit", methods=["GET"])(dashboard_sites_edit)
- mcp.custom_route("/dashboard/connect", methods=["GET"])(dashboard_connect_page)
+ mcp.custom_route("/dashboard/sites/{id}", methods=["GET"])(dashboard_sites_view)
+ # /dashboard/connect → /dashboard/keys (301)
+ mcp.custom_route("/dashboard/connect", methods=["GET"])(
+ lambda r: RedirectResponse("/dashboard/keys", status_code=301)
+ )
# Service pages (F.3)
mcp.custom_route("/dashboard/services", methods=["GET"])(dashboard_services_list)
diff --git a/core/database.py b/core/database.py
index 789f098..69573c0 100644
--- a/core/database.py
+++ b/core/database.py
@@ -37,7 +37,7 @@ logger = logging.getLogger(__name__)
_DEFAULT_DATA_DIR = "/app/data" if Path("/app").exists() else "./data"
# Schema version — increment when adding migrations
-SCHEMA_VERSION = 5
+SCHEMA_VERSION = 7
# Initial schema DDL
_SCHEMA_SQL = """\
@@ -67,6 +67,7 @@ CREATE TABLE IF NOT EXISTS sites (
status_msg TEXT,
last_health TEXT,
last_tested_at TEXT,
+ tool_scope TEXT NOT NULL DEFAULT 'admin',
created_at TEXT NOT NULL,
UNIQUE(user_id, alias)
);
@@ -100,6 +101,18 @@ CREATE TABLE IF NOT EXISTS schema_version (
version INTEGER PRIMARY KEY,
applied_at TEXT NOT NULL
);
+
+-- F.7b: per-site tool toggles (scope-based visibility overrides)
+CREATE TABLE IF NOT EXISTS site_tool_toggles (
+ id TEXT PRIMARY KEY,
+ site_id TEXT NOT NULL REFERENCES sites(id) ON DELETE CASCADE,
+ tool_name TEXT NOT NULL,
+ enabled INTEGER NOT NULL DEFAULT 1,
+ reason TEXT,
+ updated_at TEXT NOT NULL,
+ UNIQUE(site_id, tool_name)
+);
+CREATE INDEX IF NOT EXISTS idx_site_tool_toggles_site ON site_tool_toggles(site_id);
"""
# Migration registry: version -> SQL string
@@ -120,6 +133,38 @@ _MIGRATIONS: dict[int, str] = {
" updated_at TEXT NOT NULL DEFAULT (datetime('now'))\n"
");\n"
),
+ 6: (
+ # F.7: per-user tool toggles for scope-based visibility & per-tool disable
+ "CREATE TABLE IF NOT EXISTS user_tool_toggles (\n"
+ " id TEXT PRIMARY KEY,\n"
+ " user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,\n"
+ " tool_name TEXT NOT NULL,\n"
+ " enabled INTEGER NOT NULL DEFAULT 1,\n"
+ " reason TEXT,\n"
+ " updated_at TEXT NOT NULL,\n"
+ " UNIQUE(user_id, tool_name)\n"
+ ");\n"
+ "CREATE INDEX IF NOT EXISTS idx_user_tool_toggles_user "
+ "ON user_tool_toggles(user_id);\n"
+ ),
+ 7: (
+ # F.7b: move tool toggles from per-user to per-site and add a
+ # per-site preset column. user_tool_toggles was merged on Phase-1
+ # but never populated with real data — safe to drop.
+ "DROP TABLE IF EXISTS user_tool_toggles;\n"
+ "CREATE TABLE IF NOT EXISTS site_tool_toggles (\n"
+ " id TEXT PRIMARY KEY,\n"
+ " site_id TEXT NOT NULL REFERENCES sites(id) ON DELETE CASCADE,\n"
+ " tool_name TEXT NOT NULL,\n"
+ " enabled INTEGER NOT NULL DEFAULT 1,\n"
+ " reason TEXT,\n"
+ " updated_at TEXT NOT NULL,\n"
+ " UNIQUE(site_id, tool_name)\n"
+ ");\n"
+ "CREATE INDEX IF NOT EXISTS idx_site_tool_toggles_site "
+ "ON site_tool_toggles(site_id);\n"
+ "ALTER TABLE sites ADD COLUMN tool_scope TEXT NOT NULL DEFAULT 'admin';\n"
+ ),
}
@@ -726,6 +771,121 @@ class Database:
(_utc_now(), key_id),
)
+ # ------------------------------------------------------------------
+ # Site tool toggles & tool_scope (F.7b)
+ # ------------------------------------------------------------------
+
+ async def get_site_tool_toggles(self, site_id: str) -> dict[str, bool]:
+ """Get explicit tool toggle overrides for a site.
+
+ Only rows where a tool has been explicitly toggled are stored.
+ Tools not in the result are implicitly enabled.
+
+ Args:
+ site_id: Site UUID.
+
+ Returns:
+ Dict mapping ``tool_name`` → ``enabled`` (bool).
+ """
+ rows = await self.fetchall(
+ "SELECT tool_name, enabled FROM site_tool_toggles WHERE site_id = ?",
+ (site_id,),
+ )
+ return {row["tool_name"]: bool(row["enabled"]) for row in rows}
+
+ async def set_site_tool_toggle(
+ self,
+ site_id: str,
+ tool_name: str,
+ enabled: bool,
+ reason: str | None = None,
+ ) -> None:
+ """Upsert a single tool toggle for a site.
+
+ Args:
+ site_id: Site UUID.
+ tool_name: Fully-qualified tool name (e.g. ``coolify_list_applications``).
+ enabled: Whether the tool should be visible on this site.
+ reason: Optional note.
+ """
+ toggle_id = str(uuid.uuid4())
+ now = _utc_now()
+ await self.execute(
+ "INSERT INTO site_tool_toggles (id, site_id, tool_name, enabled, reason, updated_at) "
+ "VALUES (?, ?, ?, ?, ?, ?) "
+ "ON CONFLICT(site_id, tool_name) DO UPDATE SET "
+ "enabled = excluded.enabled, reason = excluded.reason, updated_at = excluded.updated_at",
+ (toggle_id, site_id, tool_name, 1 if enabled else 0, reason, now),
+ )
+
+ async def delete_site_tool_toggle(self, site_id: str, tool_name: str) -> bool:
+ """Delete a site's toggle for a tool (reverts to the default).
+
+ Args:
+ site_id: Site UUID.
+ tool_name: Fully-qualified tool name.
+
+ Returns:
+ True if a row was deleted.
+ """
+ cursor = await self.execute(
+ "DELETE FROM site_tool_toggles WHERE site_id = ? AND tool_name = ?",
+ (site_id, tool_name),
+ )
+ return cursor.rowcount > 0
+
+ async def bulk_set_site_tool_toggles(
+ self,
+ site_id: str,
+ toggles: list[tuple[str, bool]],
+ reason: str | None = None,
+ ) -> int:
+ """Upsert multiple tool toggles for a site in one transaction.
+
+ Args:
+ site_id: Site UUID.
+ toggles: List of ``(tool_name, enabled)`` pairs.
+ reason: Optional shared reason applied to every row.
+
+ Returns:
+ Number of rows affected.
+ """
+ if not toggles:
+ return 0
+ now = _utc_now()
+ rows = [
+ (str(uuid.uuid4()), site_id, tool_name, 1 if enabled else 0, reason, now)
+ for tool_name, enabled in toggles
+ ]
+ await self.executemany(
+ "INSERT INTO site_tool_toggles (id, site_id, tool_name, enabled, reason, updated_at) "
+ "VALUES (?, ?, ?, ?, ?, ?) "
+ "ON CONFLICT(site_id, tool_name) DO UPDATE SET "
+ "enabled = excluded.enabled, reason = excluded.reason, updated_at = excluded.updated_at",
+ rows,
+ )
+ return len(rows)
+
+ async def get_site_tool_scope(self, site_id: str) -> str:
+ """Return the site's ``tool_scope`` preset (defaults to ``'admin'``)."""
+ row = await self.fetchone("SELECT tool_scope FROM sites WHERE id = ?", (site_id,))
+ if row is None:
+ return "admin"
+ return row["tool_scope"] or "admin"
+
+ async def set_site_tool_scope(self, site_id: str, scope: str) -> None:
+ """Update the ``tool_scope`` preset for a site.
+
+ Args:
+ site_id: Site UUID.
+ scope: One of ``read``, ``read:sensitive``, ``deploy``,
+ ``write``, ``admin``, ``custom``.
+ """
+ await self.execute(
+ "UPDATE sites SET tool_scope = ? WHERE id = ?",
+ (scope, site_id),
+ )
+
# ======================================================================
# Module-level helpers
diff --git a/core/templates/dashboard/base.html b/core/templates/dashboard/base.html
index 469e2cd..8fcd15a 100644
--- a/core/templates/dashboard/base.html
+++ b/core/templates/dashboard/base.html
@@ -136,8 +136,8 @@
01-9-9m9 9c1.657 0 3-4.03 3-9s-1.343-9-3-9m0 18c-1.657 0-3-4.03-3-9s1.343-9 3-9m-9 9a9 9 0 019-9',
'/dashboard/sites'),
('services', t.get('services', 'Services'), 'M19 11H5m14 0a2 2 0 012 2v6a2 2 0 01-2 2H5a2 2 0 01-2-2v-6a2 2 0 012-2m14 0V9a2 2 0 00-2-2M5 11V9a2 2 0 012-2m0 0V5a2 2 0 012-2h6a2 2 0 012 2v2M7 7h10', '/dashboard/services'),
- ('connect', t.get('connect', 'Connect'), 'M13.828 10.172a4 4 0 00-5.656 0l-4 4a4 4 0 105.656
- 5.656l1.102-1.101m-.758-4.899a4 4 0 005.656 0l4-4a4 4 0 00-5.656-5.656l-1.1 1.1', '/dashboard/connect'),
+ ('keys', t.get('keys', 'API Keys'), 'M15 7a2 2 0 012 2m4 0a6 6 0 01-7.743 5.743L11 17H9v2H7v2H4a1 1 0
+ 01-1-1v-2.586a1 1 0 01.293-.707l5.964-5.964A6 6 0 1121 9z', '/dashboard/keys'),
('oauth_clients', t.oauth_clients, 'M9 12l2 2 4-4m5.618-4.016A11.955 11.955 0 0112 2.944a11.955 11.955 0
01-8.618 3.04A12.02 12.02 0 003 9c0 5.591 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622
0-1.042-.133-2.052-.382-3.016z', '/dashboard/oauth-clients'),
diff --git a/core/templates/dashboard/keys/list.html b/core/templates/dashboard/keys/list.html
new file mode 100644
index 0000000..d23019d
--- /dev/null
+++ b/core/templates/dashboard/keys/list.html
@@ -0,0 +1,493 @@
+{% extends "dashboard/base.html" %}
+
+{% block title %}{% if lang == 'fa' %}کلیدهای API{% else %}API Keys{% endif %} - MCP Hub{% endblock %}
+{% block page_title %}{% if lang == 'fa' %}کلیدهای API{% else %}API Keys{% endif %}{% endblock %}
+
+{% block content %}
+
+
+ {% if is_admin %}
+ {# ── Admin view: full filters, all project keys ── #}
+
+
+ {% if lang == 'fa' %}مدیریت کلیدهای API (Admin){% else %}Manage project API keys (admin){% endif %}
+
+
+
+
+
+ {% if lang == 'fa' %}ایجاد کلید جدید{% else %}Create New Key{% endif %}
+
+
+
+
+
+
+
+
+
+
+
+
+ {% if lang == 'fa' %}شناسه{% else %}Key ID{% endif %}
+ {% if lang == 'fa' %}پروژه{% else %}Project{% endif %}
+ {% if lang == 'fa' %}دسترسی{% else %}Scope{% endif %}
+ {% if lang == 'fa' %}توضیحات{% else %}Description{% endif %}
+ {% if lang == 'fa' %}وضعیت{% else %}Status{% endif %}
+ {% if lang == 'fa' %}استفاده{% else %}Usage{% endif %}
+ {% if lang == 'fa' %}عملیات{% else %}Actions{% endif %}
+
+
+
+ {% if api_keys %}
+ {% for key in api_keys %}
+
+
+
+
{{ key.key_id[:12] }}...
+
+
+
+
+
+
+ {% if key.project_id == '*' %}
+ {% if lang == 'fa' %}همه پروژهها{% else %}All Projects{% endif %}
+ {% else %}{{ key.project_id }} {% endif %}
+
+
+
+ {% for scope in key.scope.split() %}
+ {{ scope }}
+ {% endfor %}
+
+
+ {{ key.description or '-' }}
+
+ {% if key.revoked %}{% if lang == 'fa' %}لغو شده{% else %}Revoked{% endif %}
+ {% else %}{% if lang == 'fa' %}فعال{% else %}Active{% endif %}
{% endif %}
+
+ {{ key.usage_count }}
+
+
+ {% if not key.revoked %}
+
+
+
+ {% endif %}
+
+
+
+
+
+
+ {% endfor %}
+ {% else %}
+
+
+ {% if lang == 'fa' %}کلید API یافت نشد{% else %}No API keys found{% endif %}
+ {% if lang == 'fa' %}ایجاد اولین کلید{% else %}Create First Key{% endif %}
+
+ {% endif %}
+
+
+
+ {% if total_pages > 1 %}
+
+
+ {% if lang == 'fa' %}نمایش {{ ((page_number-1)*per_page)+1 }} تا {{ [page_number*per_page, total_count]|min }} از {{ total_count }}{% else %}Showing {{ ((page_number-1)*per_page)+1 }}–{{ [page_number*per_page, total_count]|min }} of {{ total_count }}{% endif %}
+
+
+
+ {% endif %}
+
+
+ {% else %}
+ {# ── User view: personal keys with scope selector ── #}
+
+
+
+ {% if lang == 'fa' %}کلیدهای API شخصی برای دسترسی به MCP{% else %}Your personal API keys for MCP access{% endif %}
+
+
+ {% if lang == 'fa' %}فیلتر ابزارهای هر سایت در تنظیمات سایت انجام میشود.{% else %}Per-site tool filters are configured in Site Settings .{% endif %}
+
+
+
+
+
+
+ {% if lang == 'fa' %}ایجاد کلید جدید{% else %}Create New Key{% endif %}
+
+
+
+
+
+
+
+
+
+ {% if lang == 'fa' %}نام{% else %}Name{% endif %}
+ Prefix
+ {% if lang == 'fa' %}دسترسی{% else %}Scope{% endif %}
+ Uses
+ {{ t.actions }}
+
+
+
+ {% if user_keys %}
+ {% for key in user_keys %}
+
+ {{ key.name }}
+ mhu_{{ key.key_prefix }}...
+
+ {% set scopes = (key.scopes or 'read write admin').split() %}
+
+ {% for s in scopes %}
+ {{ s }}
+ {% endfor %}
+
+
+ {{ key.use_count }}
+
+ {{ t.delete }}
+
+
+ {% endfor %}
+ {% else %}
+
+ {{ t.no_api_keys }}
+ {% if lang == 'fa' %}ایجاد اولین کلید{% else %}Create First Key{% endif %}
+
+ {% endif %}
+
+
+
+
+ {% endif %}
+
+
+{# ── Modals ── #}
+
+
+
+
+
+
+ {% if lang == 'fa' %}ایجاد کلید API جدید{% else %}Create New API Key{% endif %}
+
+
+ {% if is_admin %}
+ {# Admin create form — posts to existing admin endpoint #}
+
+ {% else %}
+ {# User create form — uses /api/keys via JS #}
+
+
+ {% if lang == 'fa' %}نام کلید{% else %}Key Name{% endif %}
+
+
+
+
{% if lang == 'fa' %}سطح دسترسی{% else %}Scope{% endif %}
+
+ Read — {% if lang == 'fa' %}فقط خواندن{% else %}view-only{% endif %}
+ Read + Sensitive — {% if lang == 'fa' %}خواندن + داده حساس{% else %}logs, envs{% endif %}
+ Deploy — {% if lang == 'fa' %}start/stop/restart{% else %}start/stop/restart{% endif %}
+ Write — {% if lang == 'fa' %}نوشتن + lifecycle{% else %}create/update + lifecycle{% endif %}
+ Full Access — {% if lang == 'fa' %}دسترسی کامل{% else %}all tools{% endif %}
+
+
+ {% if lang == 'fa' %}فیلتر ابزار هر سایت در تنظیمات سایت انجام میشود.{% else %}Per-site tool filters are set in Site Settings.{% endif %}
+
+
+
+ {% endif %}
+
+ {% if lang == 'fa' %}انصراف{% else %}Cancel{% endif %}
+ {% if lang == 'fa' %}ایجاد کلید{% else %}Create Key{% endif %}
+
+
+
+
+
+
+
+
+
+
+ {% if lang == 'fa' %}کلید API ایجاد شد{% else %}API Key Created{% endif %}
+
+
+
+
+
{% if lang == 'fa' %}این کلید فقط یکبار نمایش داده میشود.{% else %}This key will only be shown once. Save it now!{% endif %}
+
+
+
API Key
+
+
+ {% if lang == 'fa' %}کپی{% else %}Copy{% endif %}
+
+
+
+
+ {% if lang == 'fa' %}بستن{% else %}Done{% endif %}
+
+
+
+
+{% if is_admin %}
+
+
+
+
{% if lang == 'fa' %}لغو کلید API{% else %}Revoke API Key{% endif %}
+
{% if lang == 'fa' %}آیا مطمئنید که میخواهید کلید را لغو کنید؟{% else %}Are you sure you want to revoke ?{% endif %}
+
+ {% if lang == 'fa' %}انصراف{% else %}Cancel{% endif %}
+ {% if lang == 'fa' %}لغو کلید{% else %}Revoke{% endif %}
+
+
+
+
+
+
+
+
{% if lang == 'fa' %}حذف کلید API{% else %}Delete API Key{% endif %}
+
{% if lang == 'fa' %}آیا مطمئنید که میخواهید کلید را حذف کنید؟{% else %}Are you sure you want to delete ?{% endif %}
+
+ {% if lang == 'fa' %}انصراف{% else %}Cancel{% endif %}
+ {% if lang == 'fa' %}حذف{% else %}Delete{% endif %}
+
+
+
+{% endif %}
+
+{% endblock %}
+
+{% block scripts %}
+
+{% endblock %}
diff --git a/core/templates/dashboard/sites/edit.html b/core/templates/dashboard/sites/edit.html
index 0eb3bd3..65130b2 100644
--- a/core/templates/dashboard/sites/edit.html
+++ b/core/templates/dashboard/sites/edit.html
@@ -110,6 +110,47 @@
+
+
+
+
+
{% endblock %}
{% block scripts %}
@@ -168,5 +209,162 @@
btn.disabled = false;
}
});
+
+ // ── Tool Access ──────────────────────────────────────────────
+ const SCOPE_DESCS = {
+ 'read': '{% if lang == "fa" %}ابزارهای read-only (list/get) — بدون دادههای حساس{% else %}Read-only list/get tools — no sensitive data{% endif %}',
+ 'read:sensitive': '{% if lang == "fa" %}read + لاگها، بکاپها و متغیرهای محیطی{% else %}Read + logs, backups, and env vars{% endif %}',
+ 'deploy': '{% if lang == "fa" %}read + start/stop/restart/deploy{% else %}Read + start/stop/restart/deploy{% endif %}',
+ 'write': '{% if lang == "fa" %}read + lifecycle + ایجاد/بروزرسانی + env{% else %}Read + lifecycle + create/update + env{% endif %}',
+ 'admin': '{% if lang == "fa" %}دسترسی کامل به همه ابزارها{% else %}Full access to all tools{% endif %}',
+ 'custom': '{% if lang == "fa" %}بدون فیلتر سطح — فقط toggleهای دستی اعمال میشوند{% else %}No scope filter — only per-tool toggles apply{% endif %}',
+ };
+ const CAT_LABELS = {
+ 'read': '{% if lang == "fa" %}خواندن{% else %}Read{% endif %}',
+ 'read_sensitive': '{% if lang == "fa" %}خواندن حساس{% else %}Sensitive Read{% endif %}',
+ 'lifecycle': 'Lifecycle',
+ 'crud': 'CRUD',
+ 'env': '{% if lang == "fa" %}محیطی{% else %}Environment{% endif %}',
+ 'backup': 'Backup',
+ 'system': 'System',
+ };
+
+ function getCsrf() {
+ const m = document.cookie.match(/(?:^|;\s*)dashboard_csrf=([^;]+)/);
+ return m ? decodeURIComponent(m[1]) : '';
+ }
+
+ async function loadToolAccess() {
+ try {
+ const r = await fetch('/api/sites/' + siteId + '/tools');
+ if (!r.ok) { hideToolAccess(); return; }
+ const data = await r.json();
+ renderToolAccess(data);
+ } catch (_) { hideToolAccess(); }
+ }
+
+ function hideToolAccess() {
+ document.getElementById('tool-access-loading').textContent = '';
+ }
+
+ function renderToolAccess(data) {
+ const loading = document.getElementById('tool-access-loading');
+ const content = document.getElementById('tool-access-content');
+ loading.classList.add('hidden');
+ content.classList.remove('hidden');
+
+ // Set scope dropdown
+ const select = document.getElementById('tool-scope-select');
+ select.value = data.tool_scope || 'admin';
+ updateScopeDesc(data.tool_scope || 'admin');
+
+ // Scope change handler
+ select.onchange = async () => {
+ const scope = select.value;
+ updateScopeDesc(scope);
+ const statusEl = document.getElementById('scope-status');
+ statusEl.textContent = '{% if lang == "fa" %}در حال ذخیره...{% else %}Saving...{% endif %}';
+ statusEl.className = 'text-xs text-gray-400';
+ statusEl.classList.remove('hidden');
+ try {
+ const r = await fetch('/api/sites/' + siteId + '/tool-scope', {
+ method: 'PATCH',
+ headers: { 'Content-Type': 'application/json', 'x-csrf-token': getCsrf() },
+ body: JSON.stringify({ scope }),
+ });
+ if (r.ok) {
+ statusEl.textContent = '{% if lang == "fa" %}ذخیره شد{% else %}Saved{% endif %}';
+ statusEl.className = 'text-xs text-green-500';
+ } else {
+ statusEl.textContent = '{% if lang == "fa" %}خطا{% else %}Error saving{% endif %}';
+ statusEl.className = 'text-xs text-red-500';
+ }
+ } catch (_) {
+ statusEl.textContent = '{% if lang == "fa" %}خطای شبکه{% else %}Network error{% endif %}';
+ statusEl.className = 'text-xs text-red-500';
+ }
+ setTimeout(() => statusEl.classList.add('hidden'), 2000);
+ };
+
+ // Render per-tool list grouped by category
+ const grouped = {};
+ for (const tool of data.tools) {
+ const cat = tool.category || 'read';
+ if (!grouped[cat]) grouped[cat] = [];
+ grouped[cat].push(tool);
+ }
+ const catOrder = ['read', 'read_sensitive', 'lifecycle', 'crud', 'env', 'backup', 'system'];
+ const container = document.getElementById('tool-list');
+ container.innerHTML = '';
+ for (const cat of catOrder) {
+ if (!grouped[cat]) continue;
+ const section = document.createElement('div');
+ section.className = 'space-y-1';
+ const header = document.createElement('p');
+ header.className = 'text-xs font-semibold uppercase tracking-wider text-gray-400 dark:text-gray-500 mb-2';
+ header.textContent = CAT_LABELS[cat] || cat;
+ section.appendChild(header);
+ for (const tool of grouped[cat]) {
+ section.appendChild(renderToolRow(tool));
+ }
+ container.appendChild(section);
+ }
+ }
+
+ function updateScopeDesc(scope) {
+ const el = document.getElementById('scope-desc');
+ el.textContent = SCOPE_DESCS[scope] || '';
+ }
+
+ function renderToolRow(tool) {
+ const row = document.createElement('div');
+ row.id = 'tool-row-' + tool.name;
+ row.className = 'flex items-center justify-between py-1.5 px-2 rounded hover:bg-gray-50 dark:hover:bg-gray-700/30';
+
+ const left = document.createElement('div');
+ left.className = 'flex items-center gap-2 flex-1 min-w-0';
+
+ const nameEl = document.createElement('span');
+ nameEl.className = 'text-sm text-gray-800 dark:text-gray-200 truncate font-mono';
+ nameEl.textContent = tool.name;
+ nameEl.title = tool.description || '';
+ left.appendChild(nameEl);
+
+ if (tool.sensitivity === 'sensitive') {
+ const badge = document.createElement('span');
+ badge.className = 'flex-shrink-0 px-1.5 py-0.5 rounded text-xs font-medium bg-red-100 dark:bg-red-500/20 text-red-600 dark:text-red-400';
+ badge.textContent = '{% if lang == "fa" %}حساس{% else %}sensitive{% endif %}';
+ left.appendChild(badge);
+ }
+
+ // Toggle switch
+ const label = document.createElement('label');
+ label.className = 'relative inline-flex items-center cursor-pointer flex-shrink-0';
+ const input = document.createElement('input');
+ input.type = 'checkbox';
+ input.className = 'sr-only peer';
+ input.checked = tool.enabled !== false;
+ input.onchange = async () => {
+ const enabled = input.checked;
+ try {
+ const r = await fetch('/api/sites/' + siteId + '/tools/' + tool.name, {
+ method: 'PATCH',
+ headers: { 'Content-Type': 'application/json', 'x-csrf-token': getCsrf() },
+ body: JSON.stringify({ enabled }),
+ });
+ if (!r.ok) { input.checked = !enabled; }
+ } catch (_) { input.checked = !enabled; }
+ };
+ const slider = document.createElement('div');
+ slider.className = 'w-9 h-5 bg-gray-300 dark:bg-gray-600 peer-checked:bg-blue-600 rounded-full peer peer-focus:ring-2 peer-focus:ring-blue-300 dark:peer-focus:ring-blue-800 transition-colors after:content-[""] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:rounded-full after:h-4 after:w-4 after:transition-all peer-checked:after:translate-x-4';
+ label.appendChild(input);
+ label.appendChild(slider);
+
+ row.appendChild(left);
+ row.appendChild(label);
+ return row;
+ }
+
+ document.addEventListener('DOMContentLoaded', loadToolAccess);
{% endblock %}
diff --git a/core/templates/dashboard/sites/list.html b/core/templates/dashboard/sites/list.html
index 4c662f1..967c160 100644
--- a/core/templates/dashboard/sites/list.html
+++ b/core/templates/dashboard/sites/list.html
@@ -80,6 +80,10 @@
id="test-btn-{{ site.id }}">
{{ t.test_connection }}
+
+ {{ t.get('connect', 'Connect') }}
+
{{ t.edit }}
diff --git a/core/templates/dashboard/sites/view.html b/core/templates/dashboard/sites/view.html
new file mode 100644
index 0000000..bd4338a
--- /dev/null
+++ b/core/templates/dashboard/sites/view.html
@@ -0,0 +1,134 @@
+{% extends "dashboard/base.html" %}
+
+{% block title %}{{ site.alias }} - MCP Hub{% endblock %}
+{% block page_title %}{{ site.alias }}{% endblock %}
+
+{% block content %}
+
+
+
+
+
+
+ {% if lang == 'fa' %}آدرس MCP{% else %}MCP Endpoint{% endif %}
+
+
+
+ {{ mcp_url }}
+
+
+ {% if lang == 'fa' %}کپی{% else %}Copy{% endif %}
+
+
+
+ {% if lang == 'fa' %}
+ برای احراز هویت از کلید API (Bearer) یا OAuth استفاده کنید.
+ {% else %}
+ Authenticate with an API key (Bearer token) or OAuth.
+ {% endif %}
+
+
+
+
+
+
+ {% if lang == 'fa' %}نمونه کدهای پیکربندی{% else %}Configuration Snippets{% endif %}
+
+
+
+
+ {% if lang == 'fa' %}انتخاب کلاینت{% else %}Select Client{% endif %}
+
+
+ {% for client in clients %}
+ {{ client.label }}
+ {% endfor %}
+
+
+
+
+
{% if lang == 'fa' %}در حال بارگذاری...{% else %}Loading...{% endif %}
+
+ {% if lang == 'fa' %}کپی{% else %}Copy{% endif %}
+
+
+
+
+
+ {% if lang == 'fa' %}
+ نکته: از streamableHttp برای Claude Desktop و http برای VS Code/Claude Code استفاده کنید.
+ {% else %}
+ Note: Use streamableHttp for Claude Desktop, http for VS Code/Claude Code.
+ {% endif %}
+
+
+
+
+
+ {% if lang == 'fa' %}
+ API Key: از صفحه کلیدها بسازید:
+ {% else %}
+ API Key: Create one on the API Keys page:
+ {% endif %}
+
+
"Authorization": "Bearer mhu_YOUR_API_KEY_HERE"
+
+
+
+{% endblock %}
+
+{% block scripts %}
+
+{% endblock %}
diff --git a/core/tool_access.py b/core/tool_access.py
new file mode 100644
index 0000000..e67f13a
--- /dev/null
+++ b/core/tool_access.py
@@ -0,0 +1,326 @@
+"""Tool access manager — site-scoped visibility and per-site toggles (F.7b).
+
+Provides a central pipeline that filters the set of MCP tools presented for
+a user endpoint based on:
+
+1. **Scope → category mapping.** Every ``ToolDefinition`` carries a
+ ``category`` field (e.g. ``read``, ``lifecycle``, ``crud``, ``system``).
+ An API key's declared scopes **and** the site's stored ``tool_scope``
+ preset each map to a set of allowed categories via
+ :data:`SCOPE_TO_CATEGORIES`. A tool is visible only if its category is in
+ the intersection — the narrower of the two layers wins.
+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.
+
+Tools whose ``category`` is not in :data:`KNOWN_CATEGORIES` are **always
+visible** (backward compatibility — legacy plugins that have not been
+annotated yet default to ``category="read"``, which belongs to the ``read``
+scope set anyway, but an unknown value would be preserved).
+
+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__)
+
+
+# Mapping from scope → set of tool categories that scope may see.
+# Used for BOTH API-key scopes and per-site ``tool_scope`` presets.
+# Scopes are additive: presenting multiple scopes yields the union.
+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 categories — any tool whose category is outside this set is
+# treated as "always visible" for backward compatibility.
+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"
+
+
+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
+
+
+class ToolAccessManager:
+ """Central manager for scope-based visibility and per-site tool toggles."""
+
+ def apply_scope_filter(
+ self,
+ tools: list[ToolDefinition],
+ scopes: list[str],
+ ) -> list[ToolDefinition]:
+ """Drop tools whose category is not allowed by the presented scopes.
+
+ Tools with an unknown category (e.g. legacy plugins not yet annotated)
+ are always kept — backward compatibility.
+
+ Args:
+ tools: Candidate tool list.
+ scopes: Scopes presented on the API key (or a single-element list
+ containing a site's ``tool_scope`` preset).
+
+ Returns:
+ Filtered tool list.
+ """
+ allowed = scopes_to_categories(scopes)
+ if not allowed:
+ # No recognised scopes — preserve legacy behaviour and return
+ # only tools with unknown categories.
+ 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)
+
+ 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])
+
+ 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
diff --git a/core/tool_generator.py b/core/tool_generator.py
index 2ab3492..8901e79 100644
--- a/core/tool_generator.py
+++ b/core/tool_generator.py
@@ -181,6 +181,9 @@ class ToolGenerator:
description = spec["description"]
schema = spec["schema"]
scope = spec.get("scope", "read")
+ # F.7: optional category + sensitivity for scope-based visibility
+ category = spec.get("category", "read")
+ sensitivity = spec.get("sensitivity", "normal")
# Create full tool name
tool_name = f"{plugin_type}_{action_name}"
@@ -202,6 +205,8 @@ class ToolGenerator:
handler=handler,
required_scope=scope,
plugin_type=plugin_type,
+ category=category,
+ sensitivity=sensitivity,
)
def _add_site_parameter(
diff --git a/core/tool_registry.py b/core/tool_registry.py
index 7e5db1b..f06412e 100644
--- a/core/tool_registry.py
+++ b/core/tool_registry.py
@@ -27,6 +27,10 @@ class ToolDefinition(BaseModel):
handler: Async function that executes the tool
required_scope: Required API key scope ("read", "write", "admin")
plugin_type: Plugin type this tool belongs to (e.g., "wordpress")
+ category: Tool category for scope-based visibility filtering (F.7)
+ One of: "read", "read_sensitive", "lifecycle", "crud", "env",
+ "backup", "system". Defaults to "read" for backward compatibility.
+ sensitivity: "normal" or "sensitive" (logs, envs, backups, connection strings).
"""
name: str = Field(..., description="Unique tool identifier")
@@ -40,6 +44,14 @@ class ToolDefinition(BaseModel):
default="read", description="Required API key scope (read/write/admin)"
)
plugin_type: str = Field(..., description="Plugin type (wordpress, gitea, etc)")
+ category: str = Field(
+ default="read",
+ description="Tool category for scope-based visibility (F.7)",
+ )
+ sensitivity: str = Field(
+ default="normal",
+ description="Data sensitivity: normal or sensitive (F.7)",
+ )
model_config = ConfigDict(arbitrary_types_allowed=True) # Allow Callable type
diff --git a/core/user_endpoints.py b/core/user_endpoints.py
index 7014a53..d6ab8ca 100644
--- a/core/user_endpoints.py
+++ b/core/user_endpoints.py
@@ -27,6 +27,8 @@ from typing import Any
from starlette.requests import Request
from starlette.responses import JSONResponse, Response
+from core.tool_registry import ToolDefinition
+
logger = logging.getLogger(__name__)
# Per-user rate limiting defaults
@@ -36,9 +38,6 @@ USER_RATE_LIMIT_PER_HR = int(os.getenv("USER_RATE_LIMIT_PER_HR", "500"))
# In-memory rate limit tracking: user_id -> list of timestamps
_rate_limits: dict[str, list[float]] = {}
-# Cache for tool schemas per plugin type (computed once)
-_tool_schema_cache: dict[str, list[dict[str, Any]]] = {}
-
def _check_user_rate_limit(user_id: str) -> tuple[bool, str]:
"""Check per-user rate limits.
@@ -71,24 +70,15 @@ def _check_user_rate_limit(user_id: str) -> tuple[bool, str]:
return True, ""
-def _get_tools_for_plugin(plugin_type: str) -> list[dict[str, Any]]:
- """Get MCP tool definitions for a plugin type (cached).
+def _tools_to_mcp_schema(tools: list[ToolDefinition]) -> list[dict[str, Any]]:
+ """Convert ToolDefinition objects into MCP ``tools/list`` response shape.
- Returns tool schemas with the ``site`` parameter removed
- (auto-injected for user endpoints).
+ Strips the auto-injected ``site`` parameter, since user endpoints bind a
+ single site per alias.
"""
- if plugin_type in _tool_schema_cache:
- return _tool_schema_cache[plugin_type]
-
- from core.tool_registry import get_tool_registry
-
- registry = get_tool_registry()
- tools = registry.get_by_plugin_type(plugin_type)
-
result = []
for tool_def in tools:
schema = deepcopy(tool_def.input_schema)
- # Remove 'site' parameter (auto-injected)
if "properties" in schema:
schema["properties"].pop("site", None)
if "required" in schema and "site" in schema["required"]:
@@ -101,11 +91,24 @@ def _get_tools_for_plugin(plugin_type: str) -> list[dict[str, Any]]:
"inputSchema": schema,
}
)
-
- _tool_schema_cache[plugin_type] = result
return result
+async def _get_visible_tools_for_site(
+ site_id: str,
+ key_scopes: list[str],
+ plugin_type: str,
+) -> list[dict[str, Any]]:
+ """Return tools/list payload filtered by key scope + site scope + toggles (F.7b)."""
+ from core.tool_access import get_tool_access_manager
+
+ access = get_tool_access_manager()
+ tools = await access.get_visible_tools(
+ site_id=site_id, key_scopes=key_scopes, plugin_type=plugin_type
+ )
+ return _tools_to_mcp_schema(tools)
+
+
async def _execute_tool(
tool_name: str,
arguments: dict[str, Any],
@@ -353,7 +356,7 @@ async def user_mcp_handler(request: Request) -> Response:
return Response(status_code=204)
elif method == "tools/list":
- tools = _get_tools_for_plugin(site["plugin_type"])
+ tools = await _get_visible_tools_for_site(site["id"], key_scopes, site["plugin_type"])
return JSONResponse(_jsonrpc_result(req_id, {"tools": tools}))
elif method == "tools/call":
@@ -378,19 +381,56 @@ async def user_mcp_handler(request: Request) -> Response:
required_scope = tool_def.required_scope
# key_scopes is set during authentication (both mhu_ and JWT paths)
+ # F.7b: enforce category-based scope allowlist in addition to the
+ # legacy read/write/admin hierarchy. A tool is allowed only if BOTH
+ # (a) the legacy hierarchy grants it, AND
+ # (b) the tool's category is in BOTH the key-scope set AND the
+ # site's stored tool_scope set (the narrower layer wins).
+ from core.tool_access import KNOWN_CATEGORIES, SCOPE_CUSTOM, scopes_to_categories
+
scope_hierarchy = {"read": 1, "write": 2, "admin": 3}
required_level = scope_hierarchy.get(required_scope, 0)
key_level = max([scope_hierarchy.get(s, 0) for s in key_scopes] + [0])
+ legacy_ok = key_level >= required_level
- if key_level < required_level:
+ key_cats = scopes_to_categories(key_scopes)
+ key_category_ok = tool_def.category not in KNOWN_CATEGORIES or tool_def.category in key_cats
+
+ # Site-level scope check (skipped for "custom" preset).
+ site_scope = site.get("tool_scope") or "admin"
+ if site_scope and site_scope != SCOPE_CUSTOM:
+ site_cats = scopes_to_categories([site_scope])
+ site_category_ok = (
+ tool_def.category not in KNOWN_CATEGORIES or tool_def.category in site_cats
+ )
+ else:
+ site_category_ok = True
+
+ if not (legacy_ok and key_category_ok and site_category_ok):
return JSONResponse(
_jsonrpc_error(
req_id,
-32600,
- f"Insufficient scope. Tool '{tool_name}' requires '{required_scope}' scope.",
+ f"Insufficient scope. Tool '{tool_name}' requires "
+ f"scope '{required_scope}' (category '{tool_def.category}').",
)
)
+ # F.7b: honour per-site tool toggles — a disabled tool cannot be called
+ # even if scopes would otherwise allow it.
+ try:
+ toggles = await db.get_site_tool_toggles(site["id"])
+ if not toggles.get(tool_name, True):
+ return JSONResponse(
+ _jsonrpc_error(
+ req_id,
+ -32600,
+ f"Tool '{tool_name}' is disabled for this site.",
+ )
+ )
+ except Exception as exc: # non-fatal — fall through on DB errors
+ logger.warning("Failed to check site tool toggles for %s: %s", site["id"], exc)
+
# Decrypt credentials
try:
from core.encryption import get_credential_encryption
diff --git a/docs/prompts/next-session-f7-tool-access.md b/docs/prompts/next-session-f7-tool-access.md
new file mode 100644
index 0000000..3cec449
--- /dev/null
+++ b/docs/prompts/next-session-f7-tool-access.md
@@ -0,0 +1,136 @@
+از مهارت project-ops برای آشنایی با محیط استفاده کن. حافظه و فایلهای مرجع را بررسی کن.
+
+## فایلهای مرجع
+- docs/plans/2026-03-25-v4-development-cycle.md (mcphub-internal) ← Phase F.7 طراحی کامل
+- core/tool_generator.py (mcphub-internal) ← تولید ابزار فعلی
+- core/tool_registry.py (mcphub-internal) ← رجیستری ابزار
+- core/user_endpoints.py (mcphub-internal) ← فیلتر ابزار در endpoint کاربر
+- core/user_keys.py (mcphub-internal) ← سیستم API key کاربر
+- core/database.py (mcphub-internal) ← دیتابیس و مایگریشن
+- core/plugin_visibility.py (mcphub-internal) ← فیلتر پلاگین فعلی
+- core/dashboard/routes.py (mcphub-internal) ← روتهای داشبورد
+- server.py (mcphub-internal) ← middleware و scope enforcement
+- CLAUDE.md (mcphub-internal)
+
+## وضعیت فعلی
+- MCPHub: v3.8.0 — 633 ابزار، 10 پلاگین، 67 ابزار Coolify
+- تستها: 766 (internal), 734 (public)
+- CI سبز
+- Scope فعلی: read/write/admin (سه سطح ساده)
+- فیلتر فعلی: فقط plugin-level (ENABLED_PLUGINS) — بدون per-tool toggle
+
+## ریپازیتوری
+- MCPHub (internal): `/config/workspace/mcphub-internal` (branch Phase-1)
+
+## هدف session: F.7 — Smart Tool Visibility & Scope-Based Access Control
+
+### مشکلاتی که حل میشوند
+1. همه ابزارهای یک پلاگین فعال به همه کاربران نشان داده میشوند — کنترل per-tool نداریم
+2. وقتی کاربر API key با scope خاص (مثلا read) میسازد، باز هم همه ابزارها در tools/list نمایش داده میشوند
+3. ابزارهای وردپرس که نیاز به افزونههای کمکی دارند (SEO Bridge, WP-CLI) بدون بررسی prerequisite نشان داده میشوند
+4. کاربران نمیتوانند ابزارهایی که نیاز ندارند را غیرفعال کنند
+
+### مدل scope پیشنهادی (گسترشیافته)
+فعلی: `read`, `write`, `admin`
+جدید:
+- `deploy` — عملیات lifecycle (start/stop/restart/deploy) + read
+- `read:sensitive` — read + لاگ، env var، بکاپ، connection string
+
+**نگاشت scope → دستهبندی ابزار:**
+| Scope | ابزارها |
+|-------|---------|
+| `read` | list_*, get_* (بدون sensitive) |
+| `read:sensitive` | read + *_logs, *_envs, *_backups |
+| `deploy` | read + start_*, stop_*, restart_*, deploy |
+| `write` | deploy + create_*, update_*, delete_*_env |
+| `admin` | write + delete_* (منابع)، create_server |
+
+### بخش اول: Core — ساختار داده و مدیریت دسترسی (بدون UI)
+
+#### مرحله ۱: دیتابیس
+- [ ] جدول `user_tool_toggles` در `core/database.py`
+ ```sql
+ CREATE TABLE user_tool_toggles (
+ id TEXT PRIMARY KEY, user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
+ tool_name TEXT NOT NULL, enabled INTEGER NOT NULL DEFAULT 1,
+ reason TEXT, updated_at TEXT NOT NULL, UNIQUE(user_id, tool_name)
+ );
+ ```
+- [ ] جدول `scope_presets` — پریستهای scope سیستمی + سفارشی
+- [ ] Migration اجرا شود
+
+#### مرحله ۲: ماژول tool_access.py
+- [ ] `core/tool_access.py` — کلاس `ToolAccessManager`
+- [ ] `get_visible_tools(user_id, scopes, plugin_type)` → لیست فیلتر شده
+- [ ] `apply_scope_filter(tools, scopes)` → فقط ابزارهای مجاز بر اساس scope
+- [ ] `apply_user_toggles(tools, user_id)` → اعمال toggleهای کاربر
+- [ ] `toggle_tool(user_id, tool_name, enabled)` → ذخیره تنظیم
+- [ ] `bulk_toggle_by_scope(user_id, scope_name)` → فعال/غیرفعال دستهجمعی
+
+#### مرحله ۳: Tool metadata enhancement
+- [ ] اضافه کردن `sensitivity` و `category` به tool specs در handlerها:
+ - `sensitivity`: "normal" | "sensitive" (لاگ، env، بکاپ)
+ - `category`: "read" | "lifecycle" | "crud" | "env" | "backup" | "system"
+- [ ] شروع از Coolify (آخرین و تمیزترین) سپس سایر پلاگینها
+- [ ] ToolDefinition در tool_registry.py آپدیت شود
+
+#### مرحله ۴: فیلتر در user_endpoints.py
+- [ ] `_get_tools_for_plugin()` از ToolAccessManager استفاده کند
+- [ ] Pipeline فیلتر:
+ 1. plugin_visibility (موجود)
+ 2. scope-to-tool mapping (جدید)
+ 3. user toggles (جدید)
+- [ ] Scope enforcement در middleware آپدیت شود (server.py)
+
+#### مرحله ۵: تست
+- [ ] `tests/test_tool_access.py` — unit tests
+- [ ] تستهای scope mapping: key با scope "read" → فقط ابزارهای read
+- [ ] تستهای toggle: کاربر disable کرده → ابزار در tools/list نیست
+- [ ] تستهای integration: API key scope → فیلتر واقعی
+
+### بخش دوم: API — روتهای مدیریت toggle
+
+- [ ] `GET /api/user/tools` — لیست ابزارها با وضعیت toggle
+- [ ] `PATCH /api/user/tools/{tool_name}` — تغییر toggle
+- [ ] `POST /api/user/tools/bulk-toggle` — toggle دستهجمعی بر اساس scope
+- [ ] `GET /api/user/scope-presets` — لیست presetها
+- [ ] تست: روتها کار کنند
+
+### بخش سوم: Prerequisites (وردپرس/ووکامرس)
+
+- [ ] `check_prerequisites(tools, site_config)` در tool_access.py
+- [ ] تشخیص SEO Bridge: `wp-json/airano-mcp-seo-bridge/v1/status`
+- [ ] تشخیص WP-CLI: بررسی `container` field در credentials
+- [ ] تشخیص WooCommerce: `wp-json/wc/v3/system_status`
+- [ ] ابزارهای وابسته علامتگذاری شوند (نه حذف — فقط annotation)
+
+### بخش چهارم: UI — صفحه مدیریت ابزار
+
+- [ ] `core/templates/dashboard/tool-preferences.html`
+- [ ] لیست ابزارها گروهبندی شده بر اساس category
+- [ ] Toggle switch برای هر ابزار
+- [ ] Badge برای prerequisite (نصب نشده / نیاز به Docker)
+- [ ] Dropdown برای اعمال scope preset
+- [ ] در صفحه Connect: پیشنمایش ابزارها هنگام ساخت API key
+
+### بخش پنجم: ثبت و تست نهایی
+
+- [ ] `uvx --python 3.12 black .`
+- [ ] `uvx ruff check --fix .`
+- [ ] `pytest` — همه تستها سبز
+- [ ] Commit: `feat(F.7): add smart tool visibility and scope-based access control`
+- [ ] Push و درخواست redeploy
+- [ ] تست live: ساخت API key با scope "read" → بررسی tools/list
+- [ ] Sync به نسخه عمومی
+- [ ] آپدیت ورژن به v3.9.0 (اگر تأیید شد)
+- [ ] حافظه آپدیت شود
+- [ ] پلن آپدیت شود (F.7 complete)
+
+## نکات مهم
+- فیلتر scope باید backward-compatible باشد — keyهای موجود بدون تغییر کار کنند
+- Default: همه ابزارها فعال — فقط explicit disable ذخیره شود
+- `user_tool_toggles` فقط overrideها رو ذخیره میکنه، نه همه ابزارها
+- Prerequisite check باید non-blocking باشه — ابزار حذف نشه، فقط annotate بشه
+- server.py نیازی به تغییر زیاد ندارد — فقط middleware scope check آپدیت شود
+- ایمیل git داخلی: mcphub.dev@gmail.com
+- بخش اول و دوم اولویت اصلی هستند — بخش سوم و چهارم اگر وقت شد
diff --git a/docs/prompts/next-session-f7b-ui.md b/docs/prompts/next-session-f7b-ui.md
new file mode 100644
index 0000000..de93184
--- /dev/null
+++ b/docs/prompts/next-session-f7b-ui.md
@@ -0,0 +1,98 @@
+از مهارت project-ops برای آشنایی با محیط استفاده کن. حافظه و فایلهای مرجع را بررسی کن.
+
+## فایلهای مرجع
+- docs/plans/2026-04-04-f7b-site-scoped-tool-access.md ← پلن کامل F.7b
+- core/tool_access.py ← ToolAccessManager (سایتمحور — session 1)
+- core/dashboard/routes.py ← روتهای API site tools (بخش F.7b)
+- core/templates/dashboard/sites/edit.html ← صفحه edit سایت (بدون بخش tools)
+- core/templates/dashboard/connect.html ← صفحه connect فعلی (config snippets + keys)
+- core/templates/dashboard/api-keys/list.html ← صفحه admin کلیدها (UI بهتر)
+- core/dashboard/routes.py::dashboard_connect_page / dashboard_api_keys_list
+- CLAUDE.md (mcphub-internal)
+
+## وضعیت فعلی
+- MCPHub: v3.8.0 + F.7b session 1 (commit روی Phase-1)
+- Tests: 813 passed، CI سبز
+- Backend F.7b کامل است: per-site tool_scope + site_tool_toggles + 4 روت جدید تحت `/api/sites/{site_id}/...` + `/api/scope-presets`
+- فقط UI باقی مانده — هدف این session
+
+## ریپازیتوری
+- MCPHub (internal): `/config/workspace/mcphub-internal` (branch Phase-1)
+
+## هدف session: F.7b — UI + page merge
+
+### ۱. بخش "Tool Access" در صفحه edit سایت
+فایل: `core/templates/dashboard/sites/edit.html`
+
+- [ ] اضافه کردن یک کارت جدید "Tool Access" بعد از فرم credentials
+- [ ] Dropdown برای `tool_scope` (values: read / read:sensitive / deploy / write / admin / custom)
+ - PATCH روی `/api/sites/{site_id}/tool-scope` با body `{scope: "..."}`
+ - توضیح کوتاه کنار هر گزینه: "Read (X tools)" — شمارش زنده از `/api/sites/{site_id}/tools`
+- [ ] Collapsible "Advanced — per-tool overrides":
+ - گرید/لیست گروهبندی شده بر اساس `category` (read / read_sensitive / lifecycle / crud / env / backup / system)
+ - Toggle switch برای هر ابزار → PATCH `/api/sites/{site_id}/tools/{tool_name}` با `{enabled: bool}`
+ - Badge قرمز برای `sensitivity=sensitive`
+ - نام کوتاه از `name`، tooltip با `description`
+- [ ] استفاده از HTMX (در پروژه موجود است) برای updates بدون full reload
+- [ ] CSRF token از cookie `dashboard_csrf` به header `X-CSRF-Token`
+
+### ۲. انتقال config snippets از connect به صفحه سایت
+فایل: `core/templates/dashboard/sites/view.html` (یا ایجاد اگر وجود ندارد)
+
+- [ ] هر سایت در `/dashboard/sites/{id}` نمایش دهد:
+ - URL MCP مخصوص آن سایت: `{PUBLIC_URL}/u/{user_id}/{alias}/mcp`
+ - Tabs یا accordion با snippets برای Claude Desktop / Cursor / Zed / کلاینتهای دیگر
+ - استفاده از `core/config_snippets.py::get_supported_clients` (موجود)
+- [ ] از صفحه `/dashboard/sites` (list) دکمه "Connect" به این صفحه لینک بزند
+
+### ۳. ادغام `/dashboard/connect` و `/dashboard/api-keys` → `/dashboard/keys` (گزینه A)
+UI مبنا: `core/templates/dashboard/api-keys/list.html` (قشنگتر و کاملتر است طبق تأیید کاربر)
+
+- [ ] ساخت handler `dashboard_keys_unified(request)` که بر اساس session type branch میزند:
+ - OAuth user → نمایش `user_api_keys` برای آن کاربر
+ - Admin/master → نمایش کامل `api_keys` (همان view فعلی)
+- [ ] template جدید `core/templates/dashboard/keys/list.html` با ادغام design از `api-keys/list.html`
+ - User view: سادهتر، scope selector در create dialog، لیست کلیدهای خود کاربر
+ - Admin view: فیلترهای کامل (project, status, search, pagination) — بدون تغییر
+- [ ] **Scope selector در create-key dialog** — این بخش حیاتی است:
+ - Radio/select: read / read:sensitive / deploy / write / admin
+ - Helper text: "Per-site tool filters are set in Site Settings"
+ - POST به `/api/keys` (همان endpoint فعلی) با `scopes: ""`
+- [ ] Redirect های قدیمی:
+ - `/dashboard/connect` → `/dashboard/keys` (301)
+ - `/dashboard/api-keys` → `/dashboard/keys` (301)
+- [ ] حذف handler های قدیمی `dashboard_connect_page` و `dashboard_api_keys_list` و یا تبدیل به thin wrapper redirect
+- [ ] منوی navigation sidebar را update کن — فقط یک entry "API Keys"
+
+### ۴. گزینه B (ادغام عمیق DB) — deferred
+در پلن session 1 ذکر شده اما اجرا نمیکنیم مگر کاربر صراحتاً درخواست کند. کامنت در code اضافه کنید به `api_create_key` که "dual-table model is intentional — see F.7b plan".
+
+### ۵. تست
+- [ ] `tests/test_dashboard_keys_unified.py` — تست منوی unified، scope selector، 301 redirect از URL های قدیمی
+- [ ] `tests/test_sites_tool_access_ui.py` — smoke test که edit page با tool_scope=read درست render شود (میتوان با TestClient چک کرد که template بدون 500 میآید)
+- [ ] بهروزرسانی `tests/test_dashboard.py::test_dashboard_connect_page` → به `/dashboard/keys` منتقل شود یا به پذیرش redirect
+- [ ] pytest کامل سبز
+
+### ۶. ورژن، sync، commit
+- [ ] `uvx --python 3.12 black . && uvx --python 3.12 ruff check --fix .`
+- [ ] bump version به `v3.9.0` در pyproject.toml + `__version__` در server.py (اگر وجود دارد)
+- [ ] Commit: `feat(F.7b): tool access UI + unified keys page (v3.9.0)`
+- [ ] Push به Phase-1
+- [ ] `python3.11 scripts/community-build/sync.py --output ../mcphub/` سپس در repo عمومی `black` + `ruff`
+- [ ] Commit عمومی با ایمیل `hi.airano@gmail.com` و push
+- [ ] درخواست deploy از کاربر
+
+### ۷. تست live پس از deploy
+- [ ] ورود به `/dashboard/sites/{id}/edit` → بخش Tool Access → تغییر scope به `read` → save
+- [ ] بدون ساخت کلید جدید، MCP client (همان کلید admin موجود) روی آن alias → `tools/list` باید فقط ابزارهای read را نشان دهد
+- [ ] تغییر به `custom` → Advanced → disable یک ابزار خاص (مثلاً `coolify_delete_server`) → تست
+- [ ] ساخت کلید جدید از صفحه unified با scope=`read` → بررسی در لیست
+
+## نکات مهم
+- **Backward compatibility:** سایتهای موجود `tool_scope='admin'` دارند (default migration v7) → هیچ تغییر رفتاری روی سایتهای قدیمی
+- **فیلترها:** key scope و site scope **intersect** میشوند. admin key + site=read → فقط read. write key + site=deploy → فقط read + lifecycle.
+- **CSRF:** middleware روی `/api/sites/*` فعال است. UI باید header `X-CSRF-Token` از cookie `dashboard_csrf` بفرستد. HTMX این را با `hx-headers` هندل میکند.
+- **CSS:** پروژه Tailwind دارد. از همان کلاسهای موجود در `api-keys/list.html` استفاده کن برای consistency.
+- **i18n:** پروژه EN/FA است. متنهای جدید را به `core/i18n.py` اضافه کن.
+- **ایمیل git داخلی:** mcphub.dev@gmail.com | ایمیل عمومی: hi.airano@gmail.com
+- **نباید:** توابع F.7 v1 (با `user_` prefix) را بازگردانی کنی. همه سایتمحور است.
diff --git a/plugins/coolify/handlers/applications.py b/plugins/coolify/handlers/applications.py
index 233e241..a803cfd 100644
--- a/plugins/coolify/handlers/applications.py
+++ b/plugins/coolify/handlers/applications.py
@@ -11,6 +11,7 @@ def get_tool_specifications() -> list[dict[str, Any]]:
return [
{
"name": "list_applications",
+ "category": "read",
"method_name": "list_applications",
"description": "List all Coolify applications. Optionally filter by tag name.",
"schema": {
@@ -26,6 +27,7 @@ def get_tool_specifications() -> list[dict[str, Any]]:
},
{
"name": "get_application",
+ "category": "read",
"method_name": "get_application",
"description": "Get details of a specific Coolify application by UUID.",
"schema": {
@@ -43,6 +45,7 @@ def get_tool_specifications() -> list[dict[str, Any]]:
},
{
"name": "create_application_public",
+ "category": "crud",
"method_name": "create_application_public",
"description": (
"Create a Coolify application from a public Git repository. "
@@ -113,6 +116,7 @@ def get_tool_specifications() -> list[dict[str, Any]]:
},
{
"name": "create_application_dockerfile",
+ "category": "crud",
"method_name": "create_application_dockerfile",
"description": (
"Create a Coolify application from a Dockerfile (without git). "
@@ -170,6 +174,7 @@ def get_tool_specifications() -> list[dict[str, Any]]:
},
{
"name": "create_application_docker_image",
+ "category": "crud",
"method_name": "create_application_docker_image",
"description": (
"Create a Coolify application from a Docker image. "
@@ -233,6 +238,7 @@ def get_tool_specifications() -> list[dict[str, Any]]:
},
{
"name": "create_application_compose",
+ "category": "crud",
"method_name": "create_application_compose",
"description": (
"Create a Coolify application from Docker Compose. "
@@ -278,6 +284,7 @@ def get_tool_specifications() -> list[dict[str, Any]]:
},
{
"name": "update_application",
+ "category": "crud",
"method_name": "update_application",
"description": (
"Update a Coolify application settings. Supports name, description, "
@@ -357,6 +364,7 @@ def get_tool_specifications() -> list[dict[str, Any]]:
},
{
"name": "delete_application",
+ "category": "system",
"method_name": "delete_application",
"description": (
"Delete a Coolify application permanently. "
@@ -397,6 +405,7 @@ def get_tool_specifications() -> list[dict[str, Any]]:
},
{
"name": "start_application",
+ "category": "lifecycle",
"method_name": "start_application",
"description": "Deploy/start a Coolify application. Triggers a new deployment.",
"schema": {
@@ -424,6 +433,7 @@ def get_tool_specifications() -> list[dict[str, Any]]:
},
{
"name": "stop_application",
+ "category": "lifecycle",
"method_name": "stop_application",
"description": "Stop a running Coolify application.",
"schema": {
@@ -446,6 +456,7 @@ def get_tool_specifications() -> list[dict[str, Any]]:
},
{
"name": "restart_application",
+ "category": "lifecycle",
"method_name": "restart_application",
"description": "Restart a Coolify application.",
"schema": {
@@ -463,6 +474,8 @@ def get_tool_specifications() -> list[dict[str, Any]]:
},
{
"name": "get_application_logs",
+ "category": "read_sensitive",
+ "sensitivity": "sensitive",
"method_name": "get_application_logs",
"description": "Get logs for a Coolify application.",
"schema": {
@@ -487,6 +500,8 @@ def get_tool_specifications() -> list[dict[str, Any]]:
},
{
"name": "list_application_envs",
+ "category": "read_sensitive",
+ "sensitivity": "sensitive",
"method_name": "list_application_envs",
"description": "List environment variables for a Coolify application.",
"schema": {
@@ -504,6 +519,8 @@ def get_tool_specifications() -> list[dict[str, Any]]:
},
{
"name": "create_application_env",
+ "category": "env",
+ "sensitivity": "sensitive",
"method_name": "create_application_env",
"description": "Create an environment variable for a Coolify application.",
"schema": {
@@ -550,6 +567,8 @@ def get_tool_specifications() -> list[dict[str, Any]]:
},
{
"name": "update_application_env",
+ "category": "env",
+ "sensitivity": "sensitive",
"method_name": "update_application_env",
"description": "Update an environment variable for a Coolify application.",
"schema": {
@@ -592,6 +611,8 @@ def get_tool_specifications() -> list[dict[str, Any]]:
},
{
"name": "update_application_envs_bulk",
+ "category": "env",
+ "sensitivity": "sensitive",
"method_name": "update_application_envs_bulk",
"description": "Bulk update environment variables for a Coolify application.",
"schema": {
@@ -625,6 +646,8 @@ def get_tool_specifications() -> list[dict[str, Any]]:
},
{
"name": "delete_application_env",
+ "category": "env",
+ "sensitivity": "sensitive",
"method_name": "delete_application_env",
"description": "Delete an environment variable from a Coolify application.",
"schema": {
diff --git a/plugins/coolify/handlers/databases.py b/plugins/coolify/handlers/databases.py
index 55b11fb..fe6c936 100644
--- a/plugins/coolify/handlers/databases.py
+++ b/plugins/coolify/handlers/databases.py
@@ -10,6 +10,7 @@ def _create_db_spec(db_type: str, description: str) -> dict[str, Any]:
"""Generate a create_* tool spec for a database type."""
return {
"name": f"create_{db_type}",
+ "category": "crud",
"method_name": f"create_{db_type}",
"description": description,
"schema": {
@@ -55,6 +56,7 @@ def get_tool_specifications() -> list[dict[str, Any]]:
specs = [
{
"name": "list_databases",
+ "category": "read",
"method_name": "list_databases",
"description": "List all Coolify databases.",
"schema": {
@@ -65,6 +67,8 @@ def get_tool_specifications() -> list[dict[str, Any]]:
},
{
"name": "get_database",
+ "category": "read_sensitive",
+ "sensitivity": "sensitive",
"method_name": "get_database",
"description": "Get details of a specific Coolify database by UUID.",
"schema": {
@@ -82,6 +86,7 @@ def get_tool_specifications() -> list[dict[str, Any]]:
},
{
"name": "update_database",
+ "category": "crud",
"method_name": "update_database",
"description": "Update a Coolify database settings.",
"schema": {
@@ -119,6 +124,7 @@ def get_tool_specifications() -> list[dict[str, Any]]:
},
{
"name": "delete_database",
+ "category": "system",
"method_name": "delete_database",
"description": (
"Delete a Coolify database permanently. "
@@ -154,6 +160,7 @@ def get_tool_specifications() -> list[dict[str, Any]]:
},
{
"name": "start_database",
+ "category": "lifecycle",
"method_name": "start_database",
"description": "Start a Coolify database.",
"schema": {
@@ -171,6 +178,7 @@ def get_tool_specifications() -> list[dict[str, Any]]:
},
{
"name": "stop_database",
+ "category": "lifecycle",
"method_name": "stop_database",
"description": "Stop a running Coolify database.",
"schema": {
@@ -188,6 +196,7 @@ def get_tool_specifications() -> list[dict[str, Any]]:
},
{
"name": "restart_database",
+ "category": "lifecycle",
"method_name": "restart_database",
"description": "Restart a Coolify database.",
"schema": {
@@ -222,6 +231,8 @@ def get_tool_specifications() -> list[dict[str, Any]]:
[
{
"name": "get_database_backups",
+ "category": "backup",
+ "sensitivity": "sensitive",
"method_name": "get_database_backups",
"description": "Get backup configuration and history for a Coolify database.",
"schema": {
@@ -239,6 +250,8 @@ def get_tool_specifications() -> list[dict[str, Any]]:
},
{
"name": "create_database_backup",
+ "category": "backup",
+ "sensitivity": "sensitive",
"method_name": "create_database_backup",
"description": "Create a manual backup of a Coolify database.",
"schema": {
@@ -256,6 +269,8 @@ def get_tool_specifications() -> list[dict[str, Any]]:
},
{
"name": "list_backup_executions",
+ "category": "backup",
+ "sensitivity": "sensitive",
"method_name": "list_backup_executions",
"description": "List all backup executions across all databases.",
"schema": {
diff --git a/plugins/coolify/handlers/deployments.py b/plugins/coolify/handlers/deployments.py
index 74a64ab..8f0ca86 100644
--- a/plugins/coolify/handlers/deployments.py
+++ b/plugins/coolify/handlers/deployments.py
@@ -11,6 +11,7 @@ def get_tool_specifications() -> list[dict[str, Any]]:
return [
{
"name": "list_deployments",
+ "category": "read",
"method_name": "list_deployments",
"description": "List all running deployments on the Coolify instance.",
"schema": {
@@ -21,6 +22,7 @@ def get_tool_specifications() -> list[dict[str, Any]]:
},
{
"name": "get_deployment",
+ "category": "read",
"method_name": "get_deployment",
"description": "Get details of a specific deployment by UUID.",
"schema": {
@@ -38,6 +40,7 @@ def get_tool_specifications() -> list[dict[str, Any]]:
},
{
"name": "cancel_deployment",
+ "category": "lifecycle",
"method_name": "cancel_deployment",
"description": "Cancel a running deployment.",
"schema": {
@@ -55,6 +58,7 @@ def get_tool_specifications() -> list[dict[str, Any]]:
},
{
"name": "deploy",
+ "category": "lifecycle",
"method_name": "deploy",
"description": (
"Trigger deployment by tag name or resource UUID. "
@@ -82,6 +86,7 @@ def get_tool_specifications() -> list[dict[str, Any]]:
},
{
"name": "list_app_deployments",
+ "category": "read",
"method_name": "list_app_deployments",
"description": "List deployment history for a specific application.",
"schema": {
diff --git a/plugins/coolify/handlers/projects.py b/plugins/coolify/handlers/projects.py
index 30f745e..b993495 100644
--- a/plugins/coolify/handlers/projects.py
+++ b/plugins/coolify/handlers/projects.py
@@ -11,6 +11,7 @@ def get_tool_specifications() -> list[dict[str, Any]]:
return [
{
"name": "list_projects",
+ "category": "read",
"method_name": "list_projects",
"description": "List all Coolify projects.",
"schema": {
@@ -21,6 +22,7 @@ def get_tool_specifications() -> list[dict[str, Any]]:
},
{
"name": "get_project",
+ "category": "read",
"method_name": "get_project",
"description": "Get details of a specific Coolify project by UUID.",
"schema": {
@@ -38,6 +40,7 @@ def get_tool_specifications() -> list[dict[str, Any]]:
},
{
"name": "create_project",
+ "category": "crud",
"method_name": "create_project",
"description": (
"Create a new Coolify project. "
@@ -62,6 +65,7 @@ def get_tool_specifications() -> list[dict[str, Any]]:
},
{
"name": "update_project",
+ "category": "crud",
"method_name": "update_project",
"description": "Update a Coolify project name or description.",
"schema": {
@@ -87,6 +91,7 @@ def get_tool_specifications() -> list[dict[str, Any]]:
},
{
"name": "delete_project",
+ "category": "system",
"method_name": "delete_project",
"description": (
"Delete a Coolify project permanently. "
@@ -107,6 +112,7 @@ def get_tool_specifications() -> list[dict[str, Any]]:
},
{
"name": "list_environments",
+ "category": "read",
"method_name": "list_environments",
"description": "List all environments in a Coolify project.",
"schema": {
@@ -124,6 +130,7 @@ def get_tool_specifications() -> list[dict[str, Any]]:
},
{
"name": "get_environment",
+ "category": "read",
"method_name": "get_environment",
"description": ("Get details of a specific environment in a Coolify project by name."),
"schema": {
@@ -146,6 +153,7 @@ def get_tool_specifications() -> list[dict[str, Any]]:
},
{
"name": "create_environment",
+ "category": "crud",
"method_name": "create_environment",
"description": "Create a new environment in a Coolify project.",
"schema": {
diff --git a/plugins/coolify/handlers/servers.py b/plugins/coolify/handlers/servers.py
index 7612574..f39238e 100644
--- a/plugins/coolify/handlers/servers.py
+++ b/plugins/coolify/handlers/servers.py
@@ -11,6 +11,7 @@ def get_tool_specifications() -> list[dict[str, Any]]:
return [
{
"name": "list_servers",
+ "category": "read",
"method_name": "list_servers",
"description": "List all servers registered in the Coolify instance.",
"schema": {
@@ -21,6 +22,7 @@ def get_tool_specifications() -> list[dict[str, Any]]:
},
{
"name": "get_server",
+ "category": "read",
"method_name": "get_server",
"description": "Get details of a specific server by UUID, including settings.",
"schema": {
@@ -38,6 +40,7 @@ def get_tool_specifications() -> list[dict[str, Any]]:
},
{
"name": "create_server",
+ "category": "system",
"method_name": "create_server",
"description": (
"Register a new server in Coolify. "
@@ -98,6 +101,7 @@ def get_tool_specifications() -> list[dict[str, Any]]:
},
{
"name": "update_server",
+ "category": "crud",
"method_name": "update_server",
"description": "Update server configuration.",
"schema": {
@@ -142,6 +146,7 @@ def get_tool_specifications() -> list[dict[str, Any]]:
},
{
"name": "delete_server",
+ "category": "system",
"method_name": "delete_server",
"description": "Delete a server from Coolify. This cannot be undone!",
"schema": {
@@ -159,6 +164,7 @@ def get_tool_specifications() -> list[dict[str, Any]]:
},
{
"name": "get_server_resources",
+ "category": "read",
"method_name": "get_server_resources",
"description": (
"Get all resources (applications, databases, services) "
@@ -179,6 +185,7 @@ def get_tool_specifications() -> list[dict[str, Any]]:
},
{
"name": "get_server_domains",
+ "category": "read",
"method_name": "get_server_domains",
"description": "Get all domains configured on a specific server.",
"schema": {
@@ -196,6 +203,7 @@ def get_tool_specifications() -> list[dict[str, Any]]:
},
{
"name": "validate_server",
+ "category": "read",
"method_name": "validate_server",
"description": "Validate server connectivity and configuration.",
"schema": {
diff --git a/plugins/coolify/handlers/services.py b/plugins/coolify/handlers/services.py
index ef1129b..3805b63 100644
--- a/plugins/coolify/handlers/services.py
+++ b/plugins/coolify/handlers/services.py
@@ -11,6 +11,7 @@ def get_tool_specifications() -> list[dict[str, Any]]:
return [
{
"name": "list_services",
+ "category": "read",
"method_name": "list_services",
"description": "List all Coolify services.",
"schema": {
@@ -21,6 +22,7 @@ def get_tool_specifications() -> list[dict[str, Any]]:
},
{
"name": "get_service",
+ "category": "read",
"method_name": "get_service",
"description": "Get details of a specific Coolify service by UUID.",
"schema": {
@@ -38,6 +40,7 @@ def get_tool_specifications() -> list[dict[str, Any]]:
},
{
"name": "create_service",
+ "category": "crud",
"method_name": "create_service",
"description": (
"Create a Coolify service from a predefined template. "
@@ -94,6 +97,7 @@ def get_tool_specifications() -> list[dict[str, Any]]:
},
{
"name": "update_service",
+ "category": "crud",
"method_name": "update_service",
"description": "Update a Coolify service settings.",
"schema": {
@@ -123,6 +127,7 @@ def get_tool_specifications() -> list[dict[str, Any]]:
},
{
"name": "delete_service",
+ "category": "system",
"method_name": "delete_service",
"description": (
"Delete a Coolify service permanently. "
@@ -158,6 +163,7 @@ def get_tool_specifications() -> list[dict[str, Any]]:
},
{
"name": "start_service",
+ "category": "lifecycle",
"method_name": "start_service",
"description": "Start a Coolify service.",
"schema": {
@@ -175,6 +181,7 @@ def get_tool_specifications() -> list[dict[str, Any]]:
},
{
"name": "stop_service",
+ "category": "lifecycle",
"method_name": "stop_service",
"description": "Stop a running Coolify service.",
"schema": {
@@ -192,6 +199,7 @@ def get_tool_specifications() -> list[dict[str, Any]]:
},
{
"name": "restart_service",
+ "category": "lifecycle",
"method_name": "restart_service",
"description": "Restart a Coolify service.",
"schema": {
@@ -209,6 +217,8 @@ def get_tool_specifications() -> list[dict[str, Any]]:
},
{
"name": "list_service_envs",
+ "category": "read_sensitive",
+ "sensitivity": "sensitive",
"method_name": "list_service_envs",
"description": "List environment variables for a Coolify service.",
"schema": {
@@ -226,6 +236,8 @@ def get_tool_specifications() -> list[dict[str, Any]]:
},
{
"name": "create_service_env",
+ "category": "env",
+ "sensitivity": "sensitive",
"method_name": "create_service_env",
"description": "Create an environment variable for a Coolify service.",
"schema": {
@@ -257,6 +269,8 @@ def get_tool_specifications() -> list[dict[str, Any]]:
},
{
"name": "update_service_env",
+ "category": "env",
+ "sensitivity": "sensitive",
"method_name": "update_service_env",
"description": "Update an environment variable for a Coolify service.",
"schema": {
@@ -287,6 +301,8 @@ def get_tool_specifications() -> list[dict[str, Any]]:
},
{
"name": "update_service_envs_bulk",
+ "category": "env",
+ "sensitivity": "sensitive",
"method_name": "update_service_envs_bulk",
"description": "Bulk update environment variables for a Coolify service.",
"schema": {
@@ -317,6 +333,8 @@ def get_tool_specifications() -> list[dict[str, Any]]:
},
{
"name": "delete_service_env",
+ "category": "env",
+ "sensitivity": "sensitive",
"method_name": "delete_service_env",
"description": "Delete an environment variable from a Coolify service.",
"schema": {
diff --git a/pyproject.toml b/pyproject.toml
index 9baa5c2..d53cc16 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -1,6 +1,6 @@
[project]
name = "mcphub-server"
-version = "3.8.0"
+version = "3.9.0"
description = "AI-native management hub for WordPress, WooCommerce, and self-hosted services via Model Context Protocol (MCP)"
authors = [
{name = "MCP Hub", email = "contact@mcphub.dev"}
diff --git a/server.py b/server.py
index c8a90af..5a930e7 100644
--- a/server.py
+++ b/server.py
@@ -58,16 +58,22 @@ from core import (
set_api_key_context,
)
from core.dashboard.routes import (
- # E.3: Site Management routes
+ # F.7b: Per-site tool visibility
+ api_bulk_toggle_site_tools,
api_create_key,
+ # E.3: Site Management routes
api_create_site,
api_delete_key,
api_delete_site,
api_get_config,
api_list_keys,
+ api_list_site_tools,
api_list_sites,
+ api_patch_site_tool,
# K.5: Settings routes
api_save_setting,
+ api_scope_presets,
+ api_set_site_tool_scope,
api_test_site,
api_update_site,
# E.2: OAuth Social Login routes
@@ -80,7 +86,6 @@ from core.dashboard.routes import (
dashboard_api_keys_create,
dashboard_api_keys_delete,
# K.3: API Keys routes
- dashboard_api_keys_list,
dashboard_api_keys_revoke,
dashboard_api_project_detail,
dashboard_api_projects,
@@ -88,11 +93,11 @@ from core.dashboard.routes import (
# K.4: Audit Logs routes
dashboard_audit_logs_list,
# E.3: Dashboard pages
- dashboard_connect_page,
- # K.5: Health Monitoring routes
dashboard_health_page,
dashboard_health_projects_partial,
dashboard_home,
+ # F.7b session 2: Unified keys page
+ dashboard_keys_unified,
dashboard_login_page,
dashboard_login_submit,
dashboard_logout,
@@ -114,6 +119,7 @@ from core.dashboard.routes import (
dashboard_sites_add,
dashboard_sites_edit,
dashboard_sites_list,
+ dashboard_sites_view,
# Bug C: User OAuth client routes
dashboard_user_oauth_clients_create,
dashboard_user_oauth_clients_delete,
@@ -4783,6 +4789,7 @@ def create_multi_endpoint_app(transport: str = "streamable-http"):
Route("/dashboard/profile", dashboard_profile_page, methods=["GET"]),
Route("/dashboard/sites/add", dashboard_sites_add, methods=["GET"]),
Route("/dashboard/sites/{id}/edit", dashboard_sites_edit, methods=["GET"]),
+ Route("/dashboard/sites/{id}", dashboard_sites_view, methods=["GET"]),
Route("/dashboard/sites", dashboard_sites_list, methods=["GET"]),
# Bug C: User OAuth client routes (must be before /dashboard/connect)
Route(
@@ -4790,7 +4797,12 @@ def create_multi_endpoint_app(transport: str = "streamable-http"):
dashboard_user_oauth_clients_list,
methods=["GET"],
),
- Route("/dashboard/connect", dashboard_connect_page, methods=["GET"]),
+ # F.7b session 2: /dashboard/connect → /dashboard/keys (301)
+ Route(
+ "/dashboard/connect",
+ lambda r: RedirectResponse("/dashboard/keys", status_code=301),
+ methods=["GET"],
+ ),
# F.3: Service pages (must be before /dashboard catch-all)
Route("/dashboard/services", dashboard_services_list, methods=["GET"]),
Route("/dashboard/services/{plugin_type}", dashboard_service_page, methods=["GET"]),
@@ -4812,8 +4824,14 @@ def create_multi_endpoint_app(transport: str = "streamable-http"):
dashboard_api_project_detail,
methods=["GET"],
),
- # Dashboard API Keys routes (Phase K.3)
- Route("/dashboard/api-keys", dashboard_api_keys_list, methods=["GET"]),
+ # Dashboard API Keys routes (Phase K.3 + F.7b session 2: unified /dashboard/keys)
+ Route("/dashboard/keys", dashboard_keys_unified, methods=["GET"]),
+ # /dashboard/api-keys → /dashboard/keys (301)
+ Route(
+ "/dashboard/api-keys",
+ lambda r: RedirectResponse("/dashboard/keys", status_code=301),
+ methods=["GET"],
+ ),
Route("/api/dashboard/api-keys/create", dashboard_api_keys_create, methods=["POST"]),
Route(
"/api/dashboard/api-keys/{key_id}/revoke", dashboard_api_keys_revoke, methods=["POST"]
@@ -4862,6 +4880,24 @@ def create_multi_endpoint_app(transport: str = "streamable-http"):
Route("/api/keys/{id}", api_delete_key, methods=["DELETE"]),
# Config snippet API (E.3)
Route("/api/config/{alias}", api_get_config, methods=["GET"]),
+ # F.7b: Per-site tool visibility management
+ Route("/api/sites/{site_id}/tools", api_list_site_tools, methods=["GET"]),
+ Route(
+ "/api/sites/{site_id}/tools/bulk-toggle",
+ api_bulk_toggle_site_tools,
+ methods=["POST"],
+ ),
+ Route(
+ "/api/sites/{site_id}/tools/{tool_name}",
+ api_patch_site_tool,
+ methods=["PATCH"],
+ ),
+ Route(
+ "/api/sites/{site_id}/tool-scope",
+ api_set_site_tool_scope,
+ methods=["PATCH"],
+ ),
+ Route("/api/scope-presets", api_scope_presets, methods=["GET"]),
# OAuth endpoints
Route("/.well-known/oauth-authorization-server", oauth_metadata, methods=["GET"]),
# Path-specific OAuth protected resource metadata (must come before root)
diff --git a/tests/test_dashboard.py b/tests/test_dashboard.py
index db3407b..7041c5a 100644
--- a/tests/test_dashboard.py
+++ b/tests/test_dashboard.py
@@ -374,37 +374,15 @@ class TestDashboardCookieManagement:
def test_dashboard_connect_page(monkeypatch):
- """Test that the /dashboard/connect page renders successfully without 500 errors."""
+ """Test that /dashboard/connect redirects to /dashboard/keys (F.7b session 2)."""
from server import create_multi_endpoint_app
from starlette.testclient import TestClient
- import core.dashboard.routes
- import core.site_api
- import core.user_keys
-
app = create_multi_endpoint_app()
- client = TestClient(app)
-
- def mock_req(*args):
- return {"user_id": "abc", "type": "user"}, None
-
- monkeypatch.setattr(core.dashboard.routes, "_require_user_session", mock_req)
-
- async def mock_sites(*args):
- return [{"alias": "Test", "plugin_type": "dummy"}]
-
- monkeypatch.setattr(core.site_api, "get_user_sites", mock_sites)
-
- class MockKeyMgr:
- async def list_keys(self, *a):
- return [
- {"id": "1", "name": "Key", "key_prefix": "prefix", "scopes": "all", "use_count": 0}
- ]
-
- monkeypatch.setattr(core.user_keys, "get_user_key_manager", lambda: MockKeyMgr())
+ client = TestClient(app, follow_redirects=False)
resp = client.get("/dashboard/connect")
- assert resp.status_code == 200
- assert "Test" in resp.text
- assert "Key" in resp.text
+ # /dashboard/connect now redirects 301 to /dashboard/keys
+ assert resp.status_code == 301
+ assert "/dashboard/keys" in resp.headers["location"]
diff --git a/tests/test_dashboard_keys_unified.py b/tests/test_dashboard_keys_unified.py
new file mode 100644
index 0000000..49725de
--- /dev/null
+++ b/tests/test_dashboard_keys_unified.py
@@ -0,0 +1,81 @@
+"""Tests for the unified /dashboard/keys page (F.7b session 2)."""
+
+from __future__ import annotations
+
+import pytest
+from starlette.testclient import TestClient
+
+import core.dashboard.routes as routes_module
+import core.database as db_module
+from core.database import Database
+
+
+@pytest.fixture
+async def patched_db(tmp_path, monkeypatch):
+ database = Database(str(tmp_path / "keys.db"))
+ await database.initialize()
+ monkeypatch.setattr(db_module, "_database", database)
+ yield database
+ await database.close()
+ monkeypatch.setattr(db_module, "_database", None)
+
+
+@pytest.fixture
+async def user_row(patched_db):
+ return await patched_db.create_user(
+ email="keys@example.com",
+ name="keysuser",
+ provider="github",
+ provider_id="gh-keys-user",
+ )
+
+
+@pytest.fixture
+def user_client(monkeypatch, user_row, patched_db):
+ from server import create_multi_endpoint_app
+
+ def fake_user_session(_request):
+ return {"user_id": user_row["id"], "type": "user"}, None
+
+ monkeypatch.setattr(routes_module, "_require_user_session", fake_user_session)
+
+ # Also patch auth so dashboard_keys_unified finds the user session
+ class FakeAuth:
+ def get_session_from_request(self, _r):
+ return None # not admin
+
+ def get_user_session_from_request(self, _r):
+ return {"user_id": user_row["id"], "type": "user"}
+
+ monkeypatch.setattr(routes_module, "get_dashboard_auth", lambda: FakeAuth())
+
+ app = create_multi_endpoint_app()
+ return TestClient(app, follow_redirects=False)
+
+
+class TestUnifiedKeysUserView:
+ def test_get_keys_page_returns_200(self, user_client):
+ r = user_client.get("/dashboard/keys")
+ assert r.status_code == 200
+ assert "API Key" in r.text or "کلید" in r.text
+
+ def test_old_connect_redirects_301(self, user_client):
+ r = user_client.get("/dashboard/connect")
+ assert r.status_code == 301
+ assert "/dashboard/keys" in r.headers["location"]
+
+ def test_old_api_keys_redirects_301(self, user_client):
+ r = user_client.get("/dashboard/api-keys")
+ assert r.status_code == 301
+ assert "/dashboard/keys" in r.headers["location"]
+
+ def test_user_view_has_scope_selector(self, user_client):
+ r = user_client.get("/dashboard/keys")
+ assert r.status_code == 200
+ # Scope dropdown options should be present
+ assert "read:sensitive" in r.text or "Read" in r.text
+
+ def test_user_view_shows_create_button(self, user_client):
+ r = user_client.get("/dashboard/keys")
+ assert r.status_code == 200
+ assert "Create" in r.text or "ایجاد" in r.text
diff --git a/tests/test_database.py b/tests/test_database.py
index 3633a27..d3cfe8f 100644
--- a/tests/test_database.py
+++ b/tests/test_database.py
@@ -538,6 +538,72 @@ class TestEmptyResults:
# ---------------------------------------------------------------------------
+class TestSiteToolToggles:
+ """F.7b: per-site tool toggle and tool_scope helpers."""
+
+ @pytest.mark.unit
+ async def test_empty_toggles_by_default(self, db, site_row):
+ assert await db.get_site_tool_toggles(site_row["id"]) == {}
+
+ @pytest.mark.unit
+ async def test_set_and_get_toggle(self, db, site_row):
+ await db.set_site_tool_toggle(
+ site_row["id"], "coolify_list_applications", False, reason="not needed"
+ )
+ toggles = await db.get_site_tool_toggles(site_row["id"])
+ assert toggles == {"coolify_list_applications": False}
+
+ @pytest.mark.unit
+ async def test_toggle_is_upsert(self, db, site_row):
+ await db.set_site_tool_toggle(site_row["id"], "coolify_get_server", False)
+ await db.set_site_tool_toggle(site_row["id"], "coolify_get_server", True)
+ toggles = await db.get_site_tool_toggles(site_row["id"])
+ assert toggles == {"coolify_get_server": True}
+
+ @pytest.mark.unit
+ async def test_delete_toggle(self, db, site_row):
+ await db.set_site_tool_toggle(site_row["id"], "coolify_get_server", False)
+ removed = await db.delete_site_tool_toggle(site_row["id"], "coolify_get_server")
+ assert removed is True
+ assert await db.get_site_tool_toggles(site_row["id"]) == {}
+
+ @pytest.mark.unit
+ async def test_bulk_set(self, db, site_row):
+ n = await db.bulk_set_site_tool_toggles(
+ site_row["id"],
+ [("coolify_list_applications", False), ("coolify_start_application", False)],
+ reason="bulk:deploy",
+ )
+ assert n == 2
+ toggles = await db.get_site_tool_toggles(site_row["id"])
+ assert toggles == {
+ "coolify_list_applications": False,
+ "coolify_start_application": False,
+ }
+
+ @pytest.mark.unit
+ async def test_toggle_cascades_on_site_delete(self, db, user_row, site_row):
+ await db.set_site_tool_toggle(site_row["id"], "coolify_get_server", False)
+ await db.delete_site(site_row["id"], user_row["id"])
+ rows = await db.fetchall(
+ "SELECT * FROM site_tool_toggles WHERE site_id = ?", (site_row["id"],)
+ )
+ assert rows == []
+
+ @pytest.mark.unit
+ async def test_default_tool_scope_is_admin(self, db, site_row):
+ assert await db.get_site_tool_scope(site_row["id"]) == "admin"
+
+ @pytest.mark.unit
+ async def test_set_tool_scope(self, db, site_row):
+ await db.set_site_tool_scope(site_row["id"], "read")
+ assert await db.get_site_tool_scope(site_row["id"]) == "read"
+
+ @pytest.mark.unit
+ async def test_unknown_site_tool_scope_defaults_admin(self, db):
+ assert await db.get_site_tool_scope("does-not-exist") == "admin"
+
+
class TestModuleHelpers:
"""Test get_database() and initialize_database() helpers."""
diff --git a/tests/test_site_tools_api.py b/tests/test_site_tools_api.py
new file mode 100644
index 0000000..0c43e5b
--- /dev/null
+++ b/tests/test_site_tools_api.py
@@ -0,0 +1,264 @@
+"""Integration tests for the per-site tool visibility API (F.7b).
+
+Exercises the five routes registered in server.py:
+
+* ``GET /api/sites/{site_id}/tools``
+* ``PATCH /api/sites/{site_id}/tools/{tool_name}``
+* ``POST /api/sites/{site_id}/tools/bulk-toggle``
+* ``PATCH /api/sites/{site_id}/tool-scope``
+* ``GET /api/scope-presets``
+
+Uses the real ``create_multi_endpoint_app()`` so routes wire to the actual
+Coolify tool registry. User session auth is stubbed.
+"""
+
+from __future__ import annotations
+
+import pytest
+from starlette.testclient import TestClient
+
+import core.dashboard.routes as routes_module
+import core.database as db_module
+import core.tool_access as tool_access_module
+from core.database import Database
+
+
+@pytest.fixture
+async def patched_db(tmp_path, monkeypatch):
+ database = Database(str(tmp_path / "api.db"))
+ await database.initialize()
+ monkeypatch.setattr(db_module, "_database", database)
+ yield database
+ await database.close()
+ monkeypatch.setattr(db_module, "_database", None)
+
+
+@pytest.fixture
+def patched_access(monkeypatch):
+ monkeypatch.setattr(tool_access_module, "_manager", None)
+
+
+@pytest.fixture
+async def user_row(patched_db):
+ return await patched_db.create_user(
+ email="api@example.com",
+ name="api",
+ provider="github",
+ provider_id="gh-api-user",
+ )
+
+
+@pytest.fixture
+async def coolify_site(patched_db, user_row):
+ return await patched_db.create_site(
+ user_id=user_row["id"],
+ plugin_type="coolify",
+ alias="prod",
+ url="https://coolify.example.com",
+ credentials=b"x",
+ )
+
+
+@pytest.fixture
+async def other_user_site(patched_db):
+ """A site belonging to a different user — for ownership-check tests."""
+ other = await patched_db.create_user(
+ email="other@example.com",
+ name="other",
+ provider="github",
+ provider_id="gh-other",
+ )
+ return await patched_db.create_site(
+ user_id=other["id"],
+ plugin_type="coolify",
+ alias="theirs",
+ url="https://other.example.com",
+ credentials=b"x",
+ )
+
+
+@pytest.fixture
+def client(monkeypatch, user_row, patched_db, patched_access):
+ """Build the Starlette app and patch user session auth.
+
+ Also pre-sets a matching CSRF cookie + default X-CSRF-Token header so that
+ mutating requests to ``/api/sites/*`` bypass the Double-Submit CSRF guard
+ in ``DashboardCSRFMiddleware``.
+ """
+ from server import create_multi_endpoint_app
+
+ def fake_require_user_session(_request):
+ return {"user_id": user_row["id"], "type": "user"}, None
+
+ monkeypatch.setattr(routes_module, "_require_user_session", fake_require_user_session)
+
+ app = create_multi_endpoint_app()
+ tc = TestClient(app)
+ tc.cookies.set("dashboard_csrf", "test-csrf-token")
+ tc.headers.update({"x-csrf-token": "test-csrf-token"})
+ return tc
+
+
+# ---------------------------------------------------------------------------
+# GET /api/sites/{site_id}/tools
+# ---------------------------------------------------------------------------
+
+
+class TestListSiteTools:
+ def test_returns_plugin_tools_all_enabled(self, client, coolify_site):
+ resp = client.get(f"/api/sites/{coolify_site['id']}/tools")
+ assert resp.status_code == 200
+ body = resp.json()
+ assert body["site_id"] == coolify_site["id"]
+ assert body["plugin_type"] == "coolify"
+ assert body["tool_scope"] == "admin"
+ tools = body["tools"]
+ by_name = {t["name"]: t for t in tools}
+ assert "coolify_list_applications" in by_name
+ assert "coolify_delete_server" in by_name
+ assert all(t["enabled"] for t in tools)
+
+ def test_carries_category_and_sensitivity(self, client, coolify_site):
+ tools = client.get(f"/api/sites/{coolify_site['id']}/tools").json()["tools"]
+ by_name = {t["name"]: t for t in tools}
+ assert by_name["coolify_list_applications"]["category"] == "read"
+ assert by_name["coolify_delete_server"]["category"] == "system"
+ assert by_name["coolify_get_application_logs"]["sensitivity"] == "sensitive"
+
+ def test_only_own_sites_visible(self, client, other_user_site):
+ """A site owned by a different user must 404."""
+ resp = client.get(f"/api/sites/{other_user_site['id']}/tools")
+ assert resp.status_code == 404
+
+
+# ---------------------------------------------------------------------------
+# PATCH /api/sites/{site_id}/tools/{tool_name}
+# ---------------------------------------------------------------------------
+
+
+class TestPatchSiteTool:
+ def test_disable_reflected_in_list(self, client, coolify_site):
+ r = client.patch(
+ f"/api/sites/{coolify_site['id']}/tools/coolify_list_applications",
+ json={"enabled": False, "reason": "not needed"},
+ )
+ assert r.status_code == 200
+ assert r.json()["enabled"] is False
+
+ tools = client.get(f"/api/sites/{coolify_site['id']}/tools").json()["tools"]
+ by_name = {t["name"]: t["enabled"] for t in tools}
+ assert by_name["coolify_list_applications"] is False
+ assert by_name["coolify_start_application"] is True
+
+ def test_reenable_round_trip(self, client, coolify_site):
+ base = f"/api/sites/{coolify_site['id']}/tools/coolify_list_applications"
+ client.patch(base, json={"enabled": False})
+ client.patch(base, json={"enabled": True})
+ tools = client.get(f"/api/sites/{coolify_site['id']}/tools").json()["tools"]
+ by_name = {t["name"]: t["enabled"] for t in tools}
+ assert by_name["coolify_list_applications"] is True
+
+ def test_unknown_tool_404(self, client, coolify_site):
+ r = client.patch(
+ f"/api/sites/{coolify_site['id']}/tools/coolify_nonsense",
+ json={"enabled": False},
+ )
+ assert r.status_code == 404
+
+ def test_wrong_plugin_400(self, client, coolify_site):
+ """Tool from another plugin should be rejected."""
+ r = client.patch(
+ f"/api/sites/{coolify_site['id']}/tools/wordpress_list_posts",
+ json={"enabled": False},
+ )
+ assert r.status_code in (400, 404)
+
+ def test_missing_enabled_400(self, client, coolify_site):
+ r = client.patch(
+ f"/api/sites/{coolify_site['id']}/tools/coolify_list_applications",
+ json={},
+ )
+ assert r.status_code == 400
+
+
+# ---------------------------------------------------------------------------
+# POST /api/sites/{site_id}/tools/bulk-toggle
+# ---------------------------------------------------------------------------
+
+
+class TestBulkToggle:
+ def test_bulk_disable_deploy_scope(self, client, coolify_site):
+ r = client.post(
+ f"/api/sites/{coolify_site['id']}/tools/bulk-toggle",
+ json={"scope": "deploy", "enabled": False},
+ )
+ assert r.status_code == 200
+ assert r.json()["affected"] >= 5
+
+ tools = client.get(f"/api/sites/{coolify_site['id']}/tools").json()["tools"]
+ by_name = {t["name"]: t["enabled"] for t in tools}
+ assert by_name["coolify_list_applications"] is False
+ assert by_name["coolify_start_application"] is False
+ assert by_name["coolify_create_application_public"] is True
+ assert by_name["coolify_delete_server"] is True
+
+ def test_unknown_scope_400(self, client, coolify_site):
+ r = client.post(
+ f"/api/sites/{coolify_site['id']}/tools/bulk-toggle",
+ json={"scope": "bogus", "enabled": False},
+ )
+ assert r.status_code == 400
+
+ def test_bad_body_400(self, client, coolify_site):
+ r = client.post(
+ f"/api/sites/{coolify_site['id']}/tools/bulk-toggle",
+ json={"scope": "read"},
+ )
+ assert r.status_code == 400
+
+
+# ---------------------------------------------------------------------------
+# PATCH /api/sites/{site_id}/tool-scope
+# ---------------------------------------------------------------------------
+
+
+class TestSetSiteToolScope:
+ def test_set_read_scope(self, client, coolify_site):
+ r = client.patch(
+ f"/api/sites/{coolify_site['id']}/tool-scope",
+ json={"scope": "read"},
+ )
+ assert r.status_code == 200
+ assert r.json()["tool_scope"] == "read"
+ listing = client.get(f"/api/sites/{coolify_site['id']}/tools").json()
+ assert listing["tool_scope"] == "read"
+
+ def test_invalid_scope_400(self, client, coolify_site):
+ r = client.patch(
+ f"/api/sites/{coolify_site['id']}/tool-scope",
+ json={"scope": "superadmin"},
+ )
+ assert r.status_code == 400
+
+ def test_accepts_all_known_presets(self, client, coolify_site):
+ for scope in ("read", "read:sensitive", "deploy", "write", "admin", "custom"):
+ r = client.patch(
+ f"/api/sites/{coolify_site['id']}/tool-scope",
+ json={"scope": scope},
+ )
+ assert r.status_code == 200, scope
+
+
+# ---------------------------------------------------------------------------
+# GET /api/scope-presets
+# ---------------------------------------------------------------------------
+
+
+class TestScopePresets:
+ def test_returns_all_scopes(self, client):
+ body = client.get("/api/scope-presets").json()
+ assert "presets" in body
+ presets = body["presets"]
+ assert set(presets.keys()) == {"read", "read:sensitive", "deploy", "write", "admin"}
+ assert "read" in presets["read"]
+ assert "system" in presets["admin"]
diff --git a/tests/test_sites_tool_access_ui.py b/tests/test_sites_tool_access_ui.py
new file mode 100644
index 0000000..231d17d
--- /dev/null
+++ b/tests/test_sites_tool_access_ui.py
@@ -0,0 +1,110 @@
+"""Smoke tests for sites edit page Tool Access section and sites view page (F.7b session 2)."""
+
+from __future__ import annotations
+
+import pytest
+from starlette.testclient import TestClient
+
+import core.dashboard.routes as routes_module
+import core.database as db_module
+import core.tool_access as tool_access_module
+from core.database import Database
+
+
+@pytest.fixture
+async def patched_db(tmp_path, monkeypatch):
+ database = Database(str(tmp_path / "ui.db"))
+ await database.initialize()
+ monkeypatch.setattr(db_module, "_database", database)
+ yield database
+ await database.close()
+ monkeypatch.setattr(db_module, "_database", None)
+
+
+@pytest.fixture
+def patched_access(monkeypatch):
+ monkeypatch.setattr(tool_access_module, "_manager", None)
+
+
+@pytest.fixture
+async def user_row(patched_db):
+ return await patched_db.create_user(
+ email="ui@example.com",
+ name="uitester",
+ provider="github",
+ provider_id="gh-ui-user",
+ )
+
+
+@pytest.fixture
+async def coolify_site(patched_db, user_row):
+ return await patched_db.create_site(
+ user_id=user_row["id"],
+ plugin_type="coolify",
+ alias="ui-prod",
+ url="https://coolify.example.com",
+ credentials=b"x",
+ )
+
+
+@pytest.fixture
+def client(monkeypatch, user_row, patched_db, patched_access):
+ from server import create_multi_endpoint_app
+
+ def fake_user_session(_request):
+ return {"user_id": user_row["id"], "type": "user"}, None
+
+ monkeypatch.setattr(routes_module, "_require_user_session", fake_user_session)
+
+ # Patch auth for dashboard_keys_unified
+ class FakeAuth:
+ def get_session_from_request(self, _r):
+ return None
+
+ def get_user_session_from_request(self, _r):
+ return {"user_id": user_row["id"], "type": "user"}
+
+ monkeypatch.setattr(routes_module, "get_dashboard_auth", lambda: FakeAuth())
+
+ app = create_multi_endpoint_app()
+ tc = TestClient(app, follow_redirects=False)
+ tc.cookies.set("dashboard_csrf", "test-csrf")
+ tc.headers.update({"x-csrf-token": "test-csrf"})
+ return tc
+
+
+class TestSitesEditToolAccess:
+ def test_edit_page_renders_without_500(self, client, coolify_site):
+ r = client.get(f"/dashboard/sites/{coolify_site['id']}/edit")
+ assert r.status_code == 200
+
+ def test_edit_page_contains_tool_access_card(self, client, coolify_site):
+ r = client.get(f"/dashboard/sites/{coolify_site['id']}/edit")
+ assert r.status_code == 200
+ assert "tool-access-card" in r.text or "Tool Access" in r.text
+
+ def test_edit_page_has_scope_select(self, client, coolify_site):
+ r = client.get(f"/dashboard/sites/{coolify_site['id']}/edit")
+ assert r.status_code == 200
+ assert "tool-scope-select" in r.text
+
+
+class TestSitesViewPage:
+ def test_view_page_renders_without_500(self, client, coolify_site):
+ r = client.get(f"/dashboard/sites/{coolify_site['id']}")
+ assert r.status_code == 200
+
+ def test_view_page_shows_mcp_url(self, client, coolify_site):
+ r = client.get(f"/dashboard/sites/{coolify_site['id']}")
+ assert r.status_code == 200
+ assert "mcp-url" in r.text
+ assert coolify_site["alias"] in r.text
+
+ def test_view_page_has_client_selector(self, client, coolify_site):
+ r = client.get(f"/dashboard/sites/{coolify_site['id']}")
+ assert r.status_code == 200
+ assert "config-client" in r.text
+
+ def test_nonexistent_site_redirects(self, client):
+ r = client.get("/dashboard/sites/nonexistent-id")
+ assert r.status_code in (302, 303)
diff --git a/tests/test_tool_access.py b/tests/test_tool_access.py
new file mode 100644
index 0000000..67f04a5
--- /dev/null
+++ b/tests/test_tool_access.py
@@ -0,0 +1,364 @@
+"""Tests for site-scoped tool visibility & per-site toggles (F.7b).
+
+Covers:
+ * ``ToolDefinition`` backward-compatible defaults
+ * ``ToolAccessManager.apply_scope_filter`` for each scope
+ * Per-site toggle filtering
+ * Site-level ``tool_scope`` preset as a second restrictive layer
+ * ``bulk_toggle_by_scope`` scoped to a single plugin
+"""
+
+from __future__ import annotations
+
+from collections.abc import AsyncGenerator, Generator
+
+import pytest
+
+import core.database as db_module
+import core.tool_access as tool_access_module
+import core.tool_registry as tool_registry_module
+from core.database import Database
+from core.tool_access import (
+ SCOPE_TO_CATEGORIES,
+ ToolAccessManager,
+ scopes_to_categories,
+)
+from core.tool_registry import ToolDefinition, ToolRegistry
+
+# ---------------------------------------------------------------------------
+# Fixtures
+# ---------------------------------------------------------------------------
+
+
+async def _noop_handler(**_kwargs):
+ return "ok"
+
+
+def _make_tool(name: str, category: str = "read", plugin_type: str = "coolify") -> ToolDefinition:
+ return ToolDefinition(
+ name=name,
+ description=f"desc {name}",
+ input_schema={"type": "object", "properties": {}},
+ handler=_noop_handler,
+ required_scope="read",
+ plugin_type=plugin_type,
+ category=category,
+ )
+
+
+_SAMPLE_TOOLS: list[ToolDefinition] = [
+ _make_tool("coolify_list_applications", "read"),
+ _make_tool("coolify_get_application_logs", "read_sensitive"),
+ _make_tool("coolify_start_application", "lifecycle"),
+ _make_tool("coolify_stop_application", "lifecycle"),
+ _make_tool("coolify_create_application_public", "crud"),
+ _make_tool("coolify_create_application_env", "env"),
+ _make_tool("coolify_get_database_backups", "backup"),
+ _make_tool("coolify_delete_server", "system"),
+ # Legacy plugin tool without a category annotation (defaults to "read").
+ _make_tool("wordpress_list_posts", "read", plugin_type="wordpress"),
+]
+
+
+@pytest.fixture
+def fresh_registry(monkeypatch) -> Generator[ToolRegistry, None, None]:
+ """Install a clean ToolRegistry populated with _SAMPLE_TOOLS."""
+ registry = ToolRegistry()
+ for tool in _SAMPLE_TOOLS:
+ registry.register(tool)
+ monkeypatch.setattr(tool_registry_module, "_tool_registry", registry)
+ yield registry
+
+
+@pytest.fixture
+async def db(tmp_path, monkeypatch) -> AsyncGenerator[Database, None]:
+ path = str(tmp_path / "toolacc.db")
+ database = Database(path)
+ await database.initialize()
+ monkeypatch.setattr(db_module, "_database", database)
+ yield database
+ await database.close()
+ monkeypatch.setattr(db_module, "_database", None)
+
+
+@pytest.fixture
+def access_mgr(monkeypatch) -> ToolAccessManager:
+ monkeypatch.setattr(tool_access_module, "_manager", None)
+ return ToolAccessManager()
+
+
+@pytest.fixture
+async def coolify_site(db):
+ user = await db.create_user(
+ email="toolacc@example.com",
+ name="ToolAcc",
+ provider="github",
+ provider_id="gh-toolacc-1",
+ )
+ return await db.create_site(
+ user_id=user["id"],
+ plugin_type="coolify",
+ alias="prod",
+ url="https://coolify.example.com",
+ credentials=b"x",
+ )
+
+
+@pytest.fixture
+async def wordpress_site(db):
+ user = await db.create_user(
+ email="wp@example.com",
+ name="wp",
+ provider="github",
+ provider_id="gh-wp-1",
+ )
+ return await db.create_site(
+ user_id=user["id"],
+ plugin_type="wordpress",
+ alias="blog",
+ url="https://blog.example.com",
+ credentials=b"x",
+ )
+
+
+# ---------------------------------------------------------------------------
+# ToolDefinition defaults
+# ---------------------------------------------------------------------------
+
+
+class TestToolDefinitionDefaults:
+ def test_default_category_and_sensitivity(self):
+ t = ToolDefinition(
+ name="legacy_tool",
+ description="legacy",
+ handler=_noop_handler,
+ plugin_type="wordpress",
+ )
+ assert t.category == "read"
+ assert t.sensitivity == "normal"
+
+ def test_explicit_category(self):
+ t = ToolDefinition(
+ name="new_tool",
+ description="x",
+ handler=_noop_handler,
+ plugin_type="coolify",
+ category="crud",
+ sensitivity="sensitive",
+ )
+ assert t.category == "crud"
+ assert t.sensitivity == "sensitive"
+
+
+# ---------------------------------------------------------------------------
+# Scope → category mapping
+# ---------------------------------------------------------------------------
+
+
+class TestScopesToCategories:
+ def test_read_only(self):
+ assert scopes_to_categories(["read"]) == {"read"}
+
+ def test_write_is_superset_of_read(self):
+ cats = scopes_to_categories(["write"])
+ assert "read" in cats and "crud" in cats and "lifecycle" in cats
+ assert "system" not in cats
+
+ def test_admin_includes_everything(self):
+ assert scopes_to_categories(["admin"]) == SCOPE_TO_CATEGORIES["admin"]
+
+ def test_additive_scopes(self):
+ cats = scopes_to_categories(["read", "deploy"])
+ assert cats == {"read", "lifecycle"}
+
+ def test_unknown_scope_ignored(self):
+ assert scopes_to_categories(["bogus"]) == set()
+
+
+# ---------------------------------------------------------------------------
+# apply_scope_filter
+# ---------------------------------------------------------------------------
+
+
+class TestScopeFilter:
+ def test_read_scope_drops_lifecycle_crud_system(self, access_mgr):
+ out = {t.name for t in access_mgr.apply_scope_filter(_SAMPLE_TOOLS, ["read"])}
+ assert "coolify_list_applications" in out
+ assert "wordpress_list_posts" in out # legacy default category
+ assert "coolify_start_application" not in out
+ assert "coolify_create_application_public" not in out
+ assert "coolify_delete_server" not in out
+ assert "coolify_get_application_logs" not in out
+
+ def test_read_sensitive_includes_logs_and_backups(self, access_mgr):
+ out = {t.name for t in access_mgr.apply_scope_filter(_SAMPLE_TOOLS, ["read:sensitive"])}
+ assert "coolify_get_application_logs" in out
+ assert "coolify_get_database_backups" in out
+ assert "coolify_start_application" not in out
+ assert "coolify_create_application_public" not in out
+
+ def test_deploy_scope_includes_lifecycle_only(self, access_mgr):
+ out = {t.name for t in access_mgr.apply_scope_filter(_SAMPLE_TOOLS, ["deploy"])}
+ assert "coolify_start_application" in out
+ assert "coolify_stop_application" in out
+ assert "coolify_list_applications" in out
+ assert "coolify_create_application_public" not in out
+ assert "coolify_delete_server" not in out
+
+ def test_write_scope_excludes_system(self, access_mgr):
+ out = {t.name for t in access_mgr.apply_scope_filter(_SAMPLE_TOOLS, ["write"])}
+ assert "coolify_create_application_public" in out
+ assert "coolify_start_application" in out
+ assert "coolify_create_application_env" in out
+ assert "coolify_delete_server" not in out
+ assert "coolify_get_application_logs" not in out
+
+ def test_admin_keeps_everything(self, access_mgr):
+ out = {t.name for t in access_mgr.apply_scope_filter(_SAMPLE_TOOLS, ["admin"])}
+ assert out == {t.name for t in _SAMPLE_TOOLS}
+
+
+# ---------------------------------------------------------------------------
+# Per-site toggles
+# ---------------------------------------------------------------------------
+
+
+class TestSiteToggles:
+ async def test_disable_hides_tool(self, db, coolify_site, access_mgr, fresh_registry):
+ await access_mgr.toggle_tool(coolify_site["id"], "coolify_list_applications", enabled=False)
+ tools = await access_mgr.get_visible_tools(
+ site_id=coolify_site["id"], key_scopes=["admin"], plugin_type="coolify"
+ )
+ names = {t.name for t in tools}
+ assert "coolify_list_applications" not in names
+ assert "coolify_start_application" in names
+
+ async def test_toggles_are_per_site(
+ self, db, coolify_site, wordpress_site, access_mgr, fresh_registry
+ ):
+ # Disabling a tool on one site must not affect another site.
+ await access_mgr.toggle_tool(coolify_site["id"], "coolify_list_applications", enabled=False)
+ # Second coolify site inherits nothing.
+ other_site = await db.create_site(
+ user_id=coolify_site["user_id"],
+ plugin_type="coolify",
+ alias="staging",
+ url="https://staging.example.com",
+ credentials=b"x",
+ )
+ tools = await access_mgr.get_visible_tools(
+ site_id=other_site["id"], key_scopes=["admin"], plugin_type="coolify"
+ )
+ assert "coolify_list_applications" in {t.name for t in tools}
+
+ async def test_toggle_independent_of_scope(self, db, coolify_site, access_mgr, fresh_registry):
+ await access_mgr.toggle_tool(coolify_site["id"], "coolify_list_applications", enabled=False)
+ tools = await access_mgr.get_visible_tools(
+ site_id=coolify_site["id"], key_scopes=["read"], plugin_type="coolify"
+ )
+ assert "coolify_list_applications" not in {t.name for t in tools}
+
+
+# ---------------------------------------------------------------------------
+# Site-level tool_scope preset
+# ---------------------------------------------------------------------------
+
+
+class TestSiteToolScope:
+ async def test_default_admin_shows_all(self, db, coolify_site, access_mgr, fresh_registry):
+ tools = await access_mgr.get_visible_tools(
+ site_id=coolify_site["id"], key_scopes=["admin"], plugin_type="coolify"
+ )
+ assert {t.name for t in tools} == {
+ t.name for t in _SAMPLE_TOOLS if t.plugin_type == "coolify"
+ }
+
+ async def test_read_preset_restricts_even_admin_key(
+ self, db, coolify_site, access_mgr, fresh_registry
+ ):
+ """Site scope is restrictive — admin key but site=read → only read tools."""
+ await db.set_site_tool_scope(coolify_site["id"], "read")
+ tools = await access_mgr.get_visible_tools(
+ site_id=coolify_site["id"], key_scopes=["admin"], plugin_type="coolify"
+ )
+ assert {t.name for t in tools} == {"coolify_list_applications"}
+
+ async def test_key_scope_and_site_scope_intersect(
+ self, db, coolify_site, access_mgr, fresh_registry
+ ):
+ """Key=write + site=deploy → intersection = lifecycle + read."""
+ await db.set_site_tool_scope(coolify_site["id"], "deploy")
+ tools = await access_mgr.get_visible_tools(
+ site_id=coolify_site["id"], key_scopes=["write"], plugin_type="coolify"
+ )
+ names = {t.name for t in tools}
+ assert "coolify_list_applications" in names # read
+ assert "coolify_start_application" in names # lifecycle
+ assert "coolify_stop_application" in names # lifecycle
+ assert "coolify_create_application_public" not in names # crud (not in deploy)
+ assert "coolify_create_application_env" not in names # env (not in deploy)
+
+ async def test_custom_preset_skips_site_filter(
+ self, db, coolify_site, access_mgr, fresh_registry
+ ):
+ """tool_scope='custom' means per-tool toggles only — no category gate."""
+ await db.set_site_tool_scope(coolify_site["id"], "custom")
+ tools = await access_mgr.get_visible_tools(
+ site_id=coolify_site["id"], key_scopes=["admin"], plugin_type="coolify"
+ )
+ # All coolify tools visible because no site-scope filter applied.
+ assert "coolify_delete_server" in {t.name for t in tools}
+
+
+# ---------------------------------------------------------------------------
+# Bulk toggle (scoped to a plugin)
+# ---------------------------------------------------------------------------
+
+
+class TestBulkToggle:
+ async def test_bulk_disable_by_scope_affects_only_plugin(
+ self, db, coolify_site, access_mgr, fresh_registry
+ ):
+ n = await access_mgr.bulk_toggle_by_scope(
+ coolify_site["id"], "deploy", enabled=False, plugin_type="coolify"
+ )
+ # deploy → read + lifecycle. In sample: list + 2 lifecycle = 3
+ assert n == 3
+
+ tools = await access_mgr.get_visible_tools(
+ site_id=coolify_site["id"], key_scopes=["admin"], plugin_type="coolify"
+ )
+ names = {t.name for t in tools}
+ assert "coolify_start_application" not in names
+ assert "coolify_stop_application" not in names
+ assert "coolify_list_applications" not in names
+ assert "coolify_create_application_public" in names
+ assert "coolify_delete_server" in names
+
+ async def test_unknown_scope_raises(self, db, coolify_site, access_mgr, fresh_registry):
+ with pytest.raises(ValueError):
+ await access_mgr.bulk_toggle_by_scope(
+ coolify_site["id"], "does_not_exist", enabled=False
+ )
+
+
+# ---------------------------------------------------------------------------
+# list_tools_for_site end-to-end
+# ---------------------------------------------------------------------------
+
+
+class TestListToolsForSite:
+ async def test_returns_plugin_tools_with_enabled_flag(
+ self, db, coolify_site, access_mgr, fresh_registry
+ ):
+ tools = await access_mgr.list_tools_for_site(coolify_site["id"], "coolify")
+ by_name = {t["name"]: t for t in tools}
+ assert "coolify_list_applications" in by_name
+ assert by_name["coolify_list_applications"]["enabled"] is True
+ assert by_name["coolify_delete_server"]["category"] == "system"
+
+ async def test_respects_toggles(self, db, coolify_site, access_mgr, fresh_registry):
+ await access_mgr.toggle_tool(coolify_site["id"], "coolify_delete_server", enabled=False)
+ tools = await access_mgr.list_tools_for_site(coolify_site["id"], "coolify")
+ by_name = {t["name"]: t for t in tools}
+ assert by_name["coolify_delete_server"]["enabled"] is False
diff --git a/tests/test_user_endpoints.py b/tests/test_user_endpoints.py
index f1b93cc..0f1f8cf 100644
--- a/tests/test_user_endpoints.py
+++ b/tests/test_user_endpoints.py
@@ -85,12 +85,12 @@ def _clear_rate_limits():
@pytest.fixture(autouse=True)
def _clear_tool_cache():
- """Clear the tool schema cache between tests."""
- import core.user_endpoints as mod
+ """No-op: the per-plugin tool schema cache was removed in F.7.
- mod._tool_schema_cache.clear()
+ Retained so the rest of the test fixtures stay unchanged; the scope-filter
+ pipeline runs on every ``tools/list`` call so there is nothing to clear.
+ """
yield
- mod._tool_schema_cache.clear()
@pytest.fixture