3 Commits

Author SHA1 Message Date
d8a0805412 feat(F.7b): tool access UI + unified keys page (v3.9.0)
Some checks failed
Release / Test before release (push) Has been cancelled
Release / Publish to PyPI (push) Has been cancelled
Release / Publish to Docker Hub (push) Has been cancelled
Release / Create GitHub Release (push) Has been cancelled
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-06 21:00:42 +02:00
3fc02b5734 chore: bump version to v3.8.0
Some checks failed
Release / Test before release (push) Has been cancelled
Release / Publish to PyPI (push) Has been cancelled
Release / Publish to Docker Hub (push) Has been cancelled
Release / Create GitHub Release (push) Has been cancelled
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-04 15:43:37 +02:00
d7e3946e11 feat(F.17): add Coolify projects, databases, services — 67 tools total
Add 3 new Coolify plugin handlers (37 new tools, 67 total):
- projects.py: 8 tools (CRUD projects + environments)
- databases.py: 16 tools (CRUD, lifecycle, 6 DB types, backups)
- services.py: 13 tools (CRUD, lifecycle, env vars)

734 tests passing. Total platform tools: 633.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-04 15:34:30 +02:00
37 changed files with 5098 additions and 76 deletions

View File

@@ -13,8 +13,8 @@ Connect your sites, stores, repos, and databases — manage them all through Cla
[![Python 3.11+](https://img.shields.io/badge/python-3.11+-3776ab.svg)](https://www.python.org/)
[![PyPI](https://img.shields.io/pypi/v/mcphub-server.svg)](https://pypi.org/project/mcphub-server/)
[![Docker](https://img.shields.io/docker/v/airano/mcphub?label=docker)](https://hub.docker.com/r/airano/mcphub)
[![Tests: 686 passing](https://img.shields.io/badge/tests-686%20passing-brightgreen.svg)]()
[![Tools: 596](https://img.shields.io/badge/tools-596-orange.svg)]()
[![Tests: 481 passing](https://img.shields.io/badge/tests-481%20passing-brightgreen.svg)]()
[![Tools: 565](https://img.shields.io/badge/tools-565-orange.svg)]()
[![CI](https://github.com/airano-ir/mcphub/actions/workflows/ci.yml/badge.svg)](https://github.com/airano-ir/mcphub/actions/workflows/ci.yml)
</div>
@@ -25,7 +25,7 @@ Connect your sites, stores, repos, and databases — manage them all through Cla
WordPress powers 43% of the web. WooCommerce runs 36% of online stores. Yet **no MCP server existed** for managing them through AI — until now.
MCP Hub is the first MCP server that lets you manage WordPress, WooCommerce, and 8 other self-hosted services through any AI assistant. Instead of clicking through dashboards, just tell your AI what to do:
MCP Hub is the first MCP server that lets you manage WordPress, WooCommerce, and 7 other self-hosted services through any AI assistant. Instead of clicking through dashboards, just tell your AI what to do:
> *"Update the SEO meta description for all WooCommerce products that don't have one"*
>
@@ -62,9 +62,8 @@ MCP Hub is the first MCP server that lets you manage WordPress, WooCommerce, and
| **OpenPanel** | 42 | Events, export, insights, profiles, projects, system |
| **Appwrite** | 100 | Databases, auth, storage, functions, teams, messaging |
| **Directus** | 100 | Collections, items, users, files, flows, permissions |
| **Coolify** | 30 | Applications, deployments, servers, env vars, logs |
| **System** | 24 | Health monitoring, API keys, OAuth management, audit |
| **Total** | **596** | Constant count — scales to unlimited sites |
| **Total** | **565** | Constant count — scales to unlimited sites |
---
@@ -165,7 +164,6 @@ MASTER_API_KEY=your-secure-key-here
| OpenPanel | URL, Client ID, Client Secret | OpenPanel Dashboard → Project Settings |
| Appwrite | URL, API Key, Project ID | Appwrite Console → Settings → API Keys |
| Directus | URL, Static Token | Directus Admin → Settings |
| Coolify | URL, API Token | Coolify → Keys & Tokens → API tokens |
</details>
@@ -278,12 +276,11 @@ MCP Hub supports **Open Dynamic Client Registration** (RFC 7591). ChatGPT can au
/openpanel/mcp → OpenPanel tools (42 tools)
/appwrite/mcp → Appwrite tools (100 tools)
/directus/mcp → Directus tools (100 tools)
/coolify/mcp → Coolify tools (30 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` (596 tools) to minimize token usage.
**Recommendation**: Use plugin-specific endpoints instead of `/mcp` (565 tools) to minimize token usage.
| Endpoint | Use Case | Tools |
|----------|----------|------:|

View File

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

View File

@@ -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

View File

@@ -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'),

View File

@@ -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 %}
<div class="space-y-6">
{% if is_admin %}
{# ── Admin view: full filters, all project keys ── #}
<div class="flex flex-wrap items-center justify-between gap-4">
<p class="text-gray-500 dark:text-gray-400 text-sm">
{% if lang == 'fa' %}مدیریت کلیدهای API (Admin){% else %}Manage project API keys (admin){% endif %}
</p>
<button onclick="openCreateModal()"
class="px-4 py-2 bg-primary-600 hover:bg-primary-700 rounded-lg text-white text-sm font-medium transition-colors flex items-center">
<svg class="w-5 h-5 {% if lang == 'fa' %}ml-2{% else %}mr-2{% endif %}" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4"/>
</svg>
{% if lang == 'fa' %}ایجاد کلید جدید{% else %}Create New Key{% endif %}
</button>
</div>
<!-- Admin Filters -->
<div class="bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700 p-4">
<form method="GET" class="flex flex-wrap items-center gap-4">
{% if lang and lang != 'en' %}<input type="hidden" name="lang" value="{{ lang }}">{% endif %}
<div class="flex items-center gap-2">
<label class="text-sm text-gray-500 dark:text-gray-400">{{ t.filter }}:</label>
<select name="project" onchange="this.form.submit()"
class="bg-gray-50 dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-lg px-3 py-2 text-gray-900 dark:text-white text-sm focus:ring-2 focus:ring-primary-500">
<option value="">{% if lang == 'fa' %}همه پروژه‌ها{% else %}All Projects{% endif %}</option>
<option value="*" {% if selected_project == '*' %}selected{% endif %}>{% if lang == 'fa' %}کلیدهای عمومی (*){% else %}Global Keys (*){% endif %}</option>
{% for project in available_projects %}
<option value="{{ project.full_id }}" {% if selected_project == project.full_id %}selected{% endif %}>
{{ project.plugin_type }}: {{ project.alias or project.site_id }}
</option>
{% endfor %}
</select>
</div>
<div class="flex items-center gap-2">
<label class="text-sm text-gray-500 dark:text-gray-400">{% if lang == 'fa' %}وضعیت:{% else %}Status:{% endif %}</label>
<select name="status" onchange="this.form.submit()"
class="bg-gray-50 dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-lg px-3 py-2 text-gray-900 dark:text-white text-sm focus:ring-2 focus:ring-primary-500">
<option value="active" {% if selected_status == 'active' %}selected{% endif %}>{% if lang == 'fa' %}فعال{% else %}Active{% endif %}</option>
<option value="all" {% if selected_status == 'all' %}selected{% endif %}>{% if lang == 'fa' %}همه{% else %}All{% endif %}</option>
<option value="revoked" {% if selected_status == 'revoked' %}selected{% endif %}>{% if lang == 'fa' %}لغو شده{% else %}Revoked{% endif %}</option>
</select>
</div>
<div class="flex-1 min-w-[200px]">
<input type="text" name="search" value="{{ search_query }}"
placeholder="{% if lang == 'fa' %}جستجو...{% else %}Search description...{% endif %}"
class="w-full bg-gray-50 dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-lg px-4 py-2 text-gray-900 dark:text-white text-sm focus:ring-2 focus:ring-primary-500">
</div>
<button type="submit" class="px-4 py-2 bg-primary-600 hover:bg-primary-700 rounded-lg text-white text-sm transition-colors">{{ t.search }}</button>
{% if search_query or selected_project or selected_status != 'active' %}
<a href="/dashboard/keys{% if lang and lang != 'en' %}?lang={{ lang }}{% endif %}"
class="px-4 py-2 bg-gray-200 dark:bg-gray-700 hover:bg-gray-300 dark:hover:bg-gray-600 rounded-lg text-gray-800 dark:text-white text-sm transition-colors">{{ t['clear'] }}</a>
{% endif %}
</form>
</div>
<!-- Admin Keys Table -->
<div class="bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700 overflow-hidden">
<div class="overflow-x-auto">
<table class="w-full">
<thead class="bg-gray-50 dark:bg-gray-700/50">
<tr>
<th class="px-6 py-4 text-{{ 'right' if lang == 'fa' else 'left' }} text-sm font-medium text-gray-700 dark:text-gray-300">{% if lang == 'fa' %}شناسه{% else %}Key ID{% endif %}</th>
<th class="px-6 py-4 text-{{ 'right' if lang == 'fa' else 'left' }} text-sm font-medium text-gray-700 dark:text-gray-300">{% if lang == 'fa' %}پروژه{% else %}Project{% endif %}</th>
<th class="px-6 py-4 text-{{ 'right' if lang == 'fa' else 'left' }} text-sm font-medium text-gray-700 dark:text-gray-300">{% if lang == 'fa' %}دسترسی{% else %}Scope{% endif %}</th>
<th class="px-6 py-4 text-{{ 'right' if lang == 'fa' else 'left' }} text-sm font-medium text-gray-700 dark:text-gray-300">{% if lang == 'fa' %}توضیحات{% else %}Description{% endif %}</th>
<th class="px-6 py-4 text-{{ 'right' if lang == 'fa' else 'left' }} text-sm font-medium text-gray-700 dark:text-gray-300">{% if lang == 'fa' %}وضعیت{% else %}Status{% endif %}</th>
<th class="px-6 py-4 text-{{ 'right' if lang == 'fa' else 'left' }} text-sm font-medium text-gray-700 dark:text-gray-300">{% if lang == 'fa' %}استفاده{% else %}Usage{% endif %}</th>
<th class="px-6 py-4 text-{{ 'right' if lang == 'fa' else 'left' }} text-sm font-medium text-gray-700 dark:text-gray-300">{% if lang == 'fa' %}عملیات{% else %}Actions{% endif %}</th>
</tr>
</thead>
<tbody class="divide-y divide-gray-200 dark:divide-gray-700">
{% if api_keys %}
{% for key in api_keys %}
<tr class="hover:bg-gray-50 dark:hover:bg-gray-700/30 transition-colors {% if key.revoked %}opacity-50{% endif %}">
<td class="px-6 py-4">
<div class="flex items-center">
<span class="text-sm text-gray-700 dark:text-gray-300 font-mono">{{ key.key_id[:12] }}...</span>
<button onclick="copyToClipboard('{{ key.key_id }}')" class="{% if lang == 'fa' %}mr-2{% else %}ml-2{% endif %} text-gray-400 hover:text-white transition-colors" title="Copy ID">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z"/></svg>
</button>
</div>
</td>
<td class="px-6 py-4">
{% if key.project_id == '*' %}
<span class="px-2 py-1 bg-yellow-500/20 text-yellow-400 text-xs rounded-lg font-medium">{% if lang == 'fa' %}همه پروژه‌ها{% else %}All Projects{% endif %}</span>
{% else %}<span class="text-sm text-gray-700 dark:text-gray-300">{{ key.project_id }}</span>{% endif %}
</td>
<td class="px-6 py-4">
<div class="flex flex-wrap gap-1">
{% for scope in key.scope.split() %}
<span class="px-2 py-0.5 {% if scope == 'admin' %}bg-red-500/20 text-red-400{% elif scope == 'write' %}bg-orange-500/20 text-orange-400{% else %}bg-blue-500/20 text-blue-400{% endif %} text-xs rounded font-medium">{{ scope }}</span>
{% endfor %}
</div>
</td>
<td class="px-6 py-4"><span class="text-sm text-gray-500 dark:text-gray-400">{{ key.description or '-' }}</span></td>
<td class="px-6 py-4">
{% if key.revoked %}<div class="flex items-center"><span class="w-2 h-2 bg-red-500 rounded-full {% if lang == 'fa' %}ml-2{% else %}mr-2{% endif %}"></span><span class="text-sm text-red-400">{% if lang == 'fa' %}لغو شده{% else %}Revoked{% endif %}</span></div>
{% else %}<div class="flex items-center"><span class="w-2 h-2 bg-green-500 rounded-full status-pulse {% if lang == 'fa' %}ml-2{% else %}mr-2{% endif %}"></span><span class="text-sm text-green-400">{% if lang == 'fa' %}فعال{% else %}Active{% endif %}</span></div>{% endif %}
</td>
<td class="px-6 py-4"><span class="text-sm text-gray-700 dark:text-gray-300">{{ key.usage_count }}</span></td>
<td class="px-6 py-4">
<div class="flex items-center gap-2">
{% if not key.revoked %}
<button onclick="openRevokeModal('{{ key.key_id }}', '{{ key.description or key.key_id[:12] }}')"
class="p-2 bg-red-500/20 hover:bg-red-500/30 rounded-lg text-red-400 transition-colors" title="{% if lang == 'fa' %}لغو{% else %}Revoke{% endif %}">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M18.364 18.364A9 9 0 005.636 5.636m12.728 12.728A9 9 0 015.636 5.636m12.728 12.728L5.636 5.636"/></svg>
</button>
{% endif %}
<button onclick="openDeleteModal('{{ key.key_id }}', '{{ key.description or key.key_id[:12] }}')"
class="p-2 bg-gray-200 dark:bg-gray-700 hover:bg-gray-300 dark:hover:bg-gray-600 rounded-lg text-gray-400 hover:text-white transition-colors" title="{% if lang == 'fa' %}حذف{% else %}Delete{% endif %}">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"/></svg>
</button>
</div>
</td>
</tr>
{% endfor %}
{% else %}
<tr><td colspan="7" class="px-6 py-12 text-center">
<svg class="w-12 h-12 mx-auto mb-4 text-gray-500" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="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"/></svg>
<p class="text-gray-500 dark:text-gray-400">{% if lang == 'fa' %}کلید API یافت نشد{% else %}No API keys found{% endif %}</p>
<button onclick="openCreateModal()" class="mt-4 px-4 py-2 bg-primary-600 hover:bg-primary-700 rounded-lg text-white text-sm transition-colors">{% if lang == 'fa' %}ایجاد اولین کلید{% else %}Create First Key{% endif %}</button>
</td></tr>
{% endif %}
</tbody>
</table>
</div>
{% if total_pages > 1 %}
<div class="px-6 py-4 border-t border-gray-200 dark:border-gray-700 flex items-center justify-between">
<p class="text-sm text-gray-500 dark:text-gray-400">
{% 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 %}
</p>
<div class="flex items-center gap-2">
{% if page_number > 1 %}<a href="?page={{ page_number-1 }}{% if selected_project %}&project={{ selected_project }}{% endif %}{% if selected_status %}&status={{ selected_status }}{% endif %}{% if search_query %}&search={{ search_query }}{% endif %}{% if lang and lang != 'en' %}&lang={{ lang }}{% endif %}" class="px-3 py-1.5 bg-gray-200 dark:bg-gray-700 hover:bg-gray-300 dark:hover:bg-gray-600 rounded-lg text-sm text-gray-800 dark:text-white">{% if lang == 'fa' %}قبلی{% else %}Previous{% endif %}</a>{% endif %}
{% for p in range(1, total_pages+1) %}{% if p == page_number %}<span class="px-3 py-1.5 bg-primary-600 rounded-lg text-sm text-white">{{ p }}</span>{% elif p == 1 or p == total_pages or (p >= page_number-2 and p <= page_number+2) %}<a href="?page={{ p }}{% if selected_project %}&project={{ selected_project }}{% endif %}{% if selected_status %}&status={{ selected_status }}{% endif %}{% if search_query %}&search={{ search_query }}{% endif %}{% if lang and lang != 'en' %}&lang={{ lang }}{% endif %}" class="px-3 py-1.5 bg-gray-200 dark:bg-gray-700 hover:bg-gray-300 dark:hover:bg-gray-600 rounded-lg text-sm text-gray-800 dark:text-white">{{ p }}</a>{% endif %}{% endfor %}
{% if page_number < total_pages %}<a href="?page={{ page_number+1 }}{% if selected_project %}&project={{ selected_project }}{% endif %}{% if selected_status %}&status={{ selected_status }}{% endif %}{% if search_query %}&search={{ search_query }}{% endif %}{% if lang and lang != 'en' %}&lang={{ lang }}{% endif %}" class="px-3 py-1.5 bg-gray-200 dark:bg-gray-700 hover:bg-gray-300 dark:hover:bg-gray-600 rounded-lg text-sm text-gray-800 dark:text-white">{% if lang == 'fa' %}بعدی{% else %}Next{% endif %}</a>{% endif %}
</div>
</div>
{% endif %}
</div>
{% else %}
{# ── User view: personal keys with scope selector ── #}
<div class="flex flex-wrap items-center justify-between gap-4">
<div>
<p class="text-gray-500 dark:text-gray-400 text-sm">
{% if lang == 'fa' %}کلیدهای API شخصی برای دسترسی به MCP{% else %}Your personal API keys for MCP access{% endif %}
</p>
<p class="text-gray-400 dark:text-gray-500 text-xs mt-1">
{% if lang == 'fa' %}فیلتر ابزارهای هر سایت در <a href="/dashboard/sites{% if lang and lang != 'en' %}?lang={{ lang }}{% endif %}" class="underline">تنظیمات سایت</a> انجام می‌شود.{% else %}Per-site tool filters are configured in <a href="/dashboard/sites{% if lang and lang != 'en' %}?lang={{ lang }}{% endif %}" class="underline">Site Settings</a>.{% endif %}
</p>
</div>
<button onclick="openCreateModal()"
class="px-4 py-2 bg-primary-600 hover:bg-primary-700 rounded-lg text-white text-sm font-medium transition-colors flex items-center">
<svg class="w-5 h-5 {% if lang == 'fa' %}ml-2{% else %}mr-2{% endif %}" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4"/>
</svg>
{% if lang == 'fa' %}ایجاد کلید جدید{% else %}Create New Key{% endif %}
</button>
</div>
<!-- User Keys Table -->
<div class="bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700 overflow-hidden">
<div class="overflow-x-auto">
<table class="w-full">
<thead class="bg-gray-50 dark:bg-gray-700/50">
<tr>
<th class="px-6 py-4 text-{{ 'right' if lang == 'fa' else 'left' }} text-sm font-medium text-gray-500 dark:text-gray-300">{% if lang == 'fa' %}نام{% else %}Name{% endif %}</th>
<th class="px-6 py-4 text-{{ 'right' if lang == 'fa' else 'left' }} text-sm font-medium text-gray-500 dark:text-gray-300">Prefix</th>
<th class="px-6 py-4 text-{{ 'right' if lang == 'fa' else 'left' }} text-sm font-medium text-gray-500 dark:text-gray-300">{% if lang == 'fa' %}دسترسی{% else %}Scope{% endif %}</th>
<th class="px-6 py-4 text-{{ 'right' if lang == 'fa' else 'left' }} text-sm font-medium text-gray-500 dark:text-gray-300">Uses</th>
<th class="px-6 py-4 text-{{ 'right' if lang == 'fa' else 'left' }} text-sm font-medium text-gray-500 dark:text-gray-300">{{ t.actions }}</th>
</tr>
</thead>
<tbody class="divide-y divide-gray-100 dark:divide-gray-700">
{% if user_keys %}
{% for key in user_keys %}
<tr id="ukey-{{ key.id }}">
<td class="px-6 py-4 text-gray-900 dark:text-white">{{ key.name }}</td>
<td class="px-6 py-4 text-gray-500 dark:text-gray-400 font-mono text-sm">mhu_{{ key.key_prefix }}...</td>
<td class="px-6 py-4">
{% set scopes = (key.scopes or 'read write admin').split() %}
<div class="flex flex-wrap gap-1">
{% for s in scopes %}
<span class="px-2 py-0.5 {% if s == 'admin' %}bg-red-100 dark:bg-red-500/20 text-red-600 dark:text-red-400{% elif s == 'write' %}bg-orange-100 dark:bg-orange-500/20 text-orange-600 dark:text-orange-400{% elif s == 'deploy' %}bg-yellow-100 dark:bg-yellow-500/20 text-yellow-700 dark:text-yellow-400{% else %}bg-blue-100 dark:bg-blue-500/20 text-blue-700 dark:text-blue-400{% endif %} text-xs rounded font-medium">{{ s }}</span>
{% endfor %}
</div>
</td>
<td class="px-6 py-4 text-gray-500 dark:text-gray-400 text-sm">{{ key.use_count }}</td>
<td class="px-6 py-4">
<button onclick="deleteUserKey('{{ key.id }}')" class="text-sm text-red-600 dark:text-red-400 hover:text-red-500">{{ t.delete }}</button>
</td>
</tr>
{% endfor %}
{% else %}
<tr><td colspan="5" class="px-6 py-12 text-center">
<p class="text-gray-500 dark:text-gray-400 mb-4">{{ t.no_api_keys }}</p>
<button onclick="openCreateModal()" class="px-4 py-2 bg-primary-600 hover:bg-primary-700 rounded-lg text-white text-sm transition-colors">{% if lang == 'fa' %}ایجاد اولین کلید{% else %}Create First Key{% endif %}</button>
</td></tr>
{% endif %}
</tbody>
</table>
</div>
</div>
{% endif %}
</div>
{# ── Modals ── #}
<!-- Create Key Modal -->
<div id="createModal" class="fixed inset-0 bg-black/50 hidden items-center justify-center z-50">
<div class="bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700 w-full max-w-md mx-4">
<div class="p-6 border-b border-gray-200 dark:border-gray-700">
<h3 class="text-lg font-semibold text-gray-900 dark:text-white">
{% if lang == 'fa' %}ایجاد کلید API جدید{% else %}Create New API Key{% endif %}
</h3>
</div>
{% if is_admin %}
{# Admin create form — posts to existing admin endpoint #}
<form id="createKeyForm" method="POST" action="/api/dashboard/api-keys/create" class="p-6 space-y-4">
{% if lang and lang != 'en' %}<input type="hidden" name="lang" value="{{ lang }}">{% endif %}
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">{% if lang == 'fa' %}پروژه{% else %}Project{% endif %}</label>
<select name="project_id" required class="w-full bg-gray-50 dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-lg px-4 py-2 text-gray-900 dark:text-white focus:ring-2 focus:ring-primary-500">
<option value="*">{% if lang == 'fa' %}همه پروژه‌ها (*){% else %}All Projects (*){% endif %}</option>
{% for project in available_projects %}
<option value="{{ project.full_id }}">{{ project.plugin_type }}: {{ project.alias or project.site_id }}</option>
{% endfor %}
</select>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">{% if lang == 'fa' %}سطح دسترسی{% else %}Scope{% endif %}</label>
<div class="space-y-2">
<label class="flex items-center"><input type="checkbox" name="scope_read" value="read" checked class="rounded text-primary-600"><span class="ml-2 text-sm text-gray-700 dark:text-gray-300">read</span></label>
<label class="flex items-center"><input type="checkbox" name="scope_write" value="write" class="rounded text-primary-600"><span class="ml-2 text-sm text-gray-700 dark:text-gray-300">write</span></label>
<label class="flex items-center"><input type="checkbox" name="scope_admin" value="admin" class="rounded text-primary-600"><span class="ml-2 text-sm text-gray-700 dark:text-gray-300">admin</span></label>
</div>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">{% if lang == 'fa' %}توضیحات (اختیاری){% else %}Description (optional){% endif %}</label>
<input type="text" name="description" placeholder="{% if lang == 'fa' %}مثال: CI/CD{% else %}e.g., CI/CD key{% endif %}"
class="w-full bg-gray-50 dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-lg px-4 py-2 text-gray-900 dark:text-white focus:ring-2 focus:ring-primary-500">
</div>
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">{% if lang == 'fa' %}انقضا{% else %}Expiration{% endif %}</label>
<select name="expires_in_days" class="w-full bg-gray-50 dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-lg px-4 py-2 text-gray-900 dark:text-white focus:ring-2 focus:ring-primary-500">
<option value="">{% if lang == 'fa' %}بدون انقضا{% else %}Never expires{% endif %}</option>
<option value="7">7 {% if lang == 'fa' %}روز{% else %}days{% endif %}</option>
<option value="30">30 {% if lang == 'fa' %}روز{% else %}days{% endif %}</option>
<option value="90">90 {% if lang == 'fa' %}روز{% else %}days{% endif %}</option>
<option value="365">1 {% if lang == 'fa' %}سال{% else %}year{% endif %}</option>
</select>
</div>
</form>
{% else %}
{# User create form — uses /api/keys via JS #}
<div class="p-6 space-y-4">
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">{% if lang == 'fa' %}نام کلید{% else %}Key Name{% endif %}</label>
<input type="text" id="new-key-name" placeholder="{% if lang == 'fa' %}مثال: Claude Desktop{% else %}e.g., Claude Desktop{% endif %}"
class="w-full bg-gray-50 dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-lg px-4 py-2.5 text-gray-900 dark:text-white placeholder-gray-400 focus:ring-2 focus:ring-blue-500">
</div>
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">{% if lang == 'fa' %}سطح دسترسی{% else %}Scope{% endif %}</label>
<select id="new-key-scope" class="w-full bg-gray-50 dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-lg px-4 py-2.5 text-gray-900 dark:text-white focus:ring-2 focus:ring-blue-500">
<option value="read">Read — {% if lang == 'fa' %}فقط خواندن{% else %}view-only{% endif %}</option>
<option value="read:sensitive">Read + Sensitive — {% if lang == 'fa' %}خواندن + داده حساس{% else %}logs, envs{% endif %}</option>
<option value="deploy">Deploy — {% if lang == 'fa' %}start/stop/restart{% else %}start/stop/restart{% endif %}</option>
<option value="write">Write — {% if lang == 'fa' %}نوشتن + lifecycle{% else %}create/update + lifecycle{% endif %}</option>
<option value="read write admin" selected>Full Access — {% if lang == 'fa' %}دسترسی کامل{% else %}all tools{% endif %}</option>
</select>
<p class="text-xs text-gray-400 dark:text-gray-500 mt-1.5">
{% if lang == 'fa' %}فیلتر ابزار هر سایت در تنظیمات سایت انجام می‌شود.{% else %}Per-site tool filters are set in Site Settings.{% endif %}
</p>
</div>
</div>
{% endif %}
<div class="p-6 border-t border-gray-200 dark:border-gray-700 flex justify-end gap-3">
<button onclick="closeCreateModal()" class="px-4 py-2 bg-gray-200 dark:bg-gray-700 hover:bg-gray-300 dark:hover:bg-gray-600 rounded-lg text-gray-800 dark:text-white text-sm">{% if lang == 'fa' %}انصراف{% else %}Cancel{% endif %}</button>
<button onclick="submitCreate()" class="px-4 py-2 bg-primary-600 hover:bg-primary-700 rounded-lg text-white text-sm">{% if lang == 'fa' %}ایجاد کلید{% else %}Create Key{% endif %}</button>
</div>
</div>
</div>
<!-- New Key Display Modal -->
<div id="newKeyModal" class="fixed inset-0 bg-black/50 hidden items-center justify-center z-50">
<div class="bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700 w-full max-w-lg mx-4">
<div class="p-6 border-b border-gray-200 dark:border-gray-700">
<h3 class="text-lg font-semibold text-gray-900 dark:text-white flex items-center gap-2">
<svg class="w-6 h-6 text-green-400" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"/></svg>
{% if lang == 'fa' %}کلید API ایجاد شد{% else %}API Key Created{% endif %}
</h3>
</div>
<div class="p-6 space-y-4">
<div class="bg-yellow-500/10 border border-yellow-500/30 rounded-lg p-4">
<p class="text-sm text-yellow-400">{% if lang == 'fa' %}این کلید فقط یکبار نمایش داده می‌شود.{% else %}This key will only be shown once. Save it now!{% endif %}</p>
</div>
<div>
<label class="block text-sm font-medium text-gray-400 mb-2">API Key</label>
<div class="flex items-center gap-2">
<input type="text" id="newKeyValue" readonly class="flex-1 bg-gray-50 dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-lg px-4 py-2 text-gray-900 dark:text-white font-mono text-sm">
<button onclick="copyNewKey()" class="px-4 py-2 bg-primary-600 hover:bg-primary-700 rounded-lg text-white text-sm">{% if lang == 'fa' %}کپی{% else %}Copy{% endif %}</button>
</div>
</div>
</div>
<div class="p-6 border-t border-gray-200 dark:border-gray-700 flex justify-end">
<button onclick="closeNewKeyModal()" class="px-4 py-2 bg-primary-600 hover:bg-primary-700 rounded-lg text-white text-sm">{% if lang == 'fa' %}بستن{% else %}Done{% endif %}</button>
</div>
</div>
</div>
{% if is_admin %}
<!-- Revoke Modal -->
<div id="revokeModal" class="fixed inset-0 bg-black/50 hidden items-center justify-center z-50">
<div class="bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700 w-full max-w-md mx-4">
<div class="p-6 border-b border-gray-200 dark:border-gray-700"><h3 class="text-lg font-semibold text-gray-900 dark:text-white">{% if lang == 'fa' %}لغو کلید API{% else %}Revoke API Key{% endif %}</h3></div>
<div class="p-6"><p class="text-gray-700 dark:text-gray-300">{% if lang == 'fa' %}آیا مطمئنید که می‌خواهید کلید <span id="revokeKeyName" class="font-mono text-white"></span> را لغو کنید؟{% else %}Are you sure you want to revoke <span id="revokeKeyName" class="font-mono text-white"></span>?{% endif %}</p></div>
<div class="p-6 border-t border-gray-200 dark:border-gray-700 flex justify-end gap-3">
<button onclick="closeRevokeModal()" class="px-4 py-2 bg-gray-200 dark:bg-gray-700 hover:bg-gray-300 dark:hover:bg-gray-600 rounded-lg text-gray-800 dark:text-white text-sm">{% if lang == 'fa' %}انصراف{% else %}Cancel{% endif %}</button>
<button id="confirmRevokeBtn" class="px-4 py-2 bg-red-600 hover:bg-red-700 rounded-lg text-white text-sm">{% if lang == 'fa' %}لغو کلید{% else %}Revoke{% endif %}</button>
</div>
</div>
</div>
<!-- Delete Modal -->
<div id="deleteModal" class="fixed inset-0 bg-black/50 hidden items-center justify-center z-50">
<div class="bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700 w-full max-w-md mx-4">
<div class="p-6 border-b border-gray-200 dark:border-gray-700"><h3 class="text-lg font-semibold text-gray-900 dark:text-white">{% if lang == 'fa' %}حذف کلید API{% else %}Delete API Key{% endif %}</h3></div>
<div class="p-6"><p class="text-gray-700 dark:text-gray-300">{% if lang == 'fa' %}آیا مطمئنید که می‌خواهید کلید <span id="deleteKeyName" class="font-mono text-white"></span> را حذف کنید؟{% else %}Are you sure you want to delete <span id="deleteKeyName" class="font-mono text-white"></span>?{% endif %}</p></div>
<div class="p-6 border-t border-gray-200 dark:border-gray-700 flex justify-end gap-3">
<button onclick="closeDeleteModal()" class="px-4 py-2 bg-gray-200 dark:bg-gray-700 hover:bg-gray-300 dark:hover:bg-gray-600 rounded-lg text-gray-800 dark:text-white text-sm">{% if lang == 'fa' %}انصراف{% else %}Cancel{% endif %}</button>
<button id="confirmDeleteBtn" class="px-4 py-2 bg-red-600 hover:bg-red-700 rounded-lg text-white text-sm">{% if lang == 'fa' %}حذف{% else %}Delete{% endif %}</button>
</div>
</div>
</div>
{% endif %}
{% endblock %}
{% block scripts %}
<script>
(function() {
const isAdmin = {{ 'true' if is_admin else 'false' }};
const lang = '{{ lang }}';
let currentRevokeKeyId = null;
let currentDeleteKeyId = null;
function copyToClipboard(text) {
navigator.clipboard.writeText(text).then(() => showToast(lang === 'fa' ? 'کپی شد!' : 'Copied!'));
}
window.copyToClipboard = copyToClipboard;
function showToast(msg) {
const t = document.createElement('div');
t.className = 'fixed bottom-4 right-4 bg-gray-800 border border-gray-700 rounded-lg px-4 py-2 text-white text-sm z-50';
t.textContent = msg;
document.body.appendChild(t);
setTimeout(() => t.remove(), 2000);
}
function openCreateModal() {
document.getElementById('createModal').classList.remove('hidden');
document.getElementById('createModal').classList.add('flex');
}
window.openCreateModal = openCreateModal;
function closeCreateModal() {
document.getElementById('createModal').classList.add('hidden');
document.getElementById('createModal').classList.remove('flex');
}
window.closeCreateModal = closeCreateModal;
function closeNewKeyModal() {
document.getElementById('newKeyModal').classList.add('hidden');
document.getElementById('newKeyModal').classList.remove('flex');
location.reload();
}
window.closeNewKeyModal = closeNewKeyModal;
function copyNewKey() {
copyToClipboard(document.getElementById('newKeyValue').value);
}
window.copyNewKey = copyNewKey;
function showNewKey(key) {
document.getElementById('newKeyValue').value = key;
document.getElementById('newKeyModal').classList.remove('hidden');
document.getElementById('newKeyModal').classList.add('flex');
}
async function submitCreate() {
if (isAdmin) {
// Admin: collect scopes + submit form via JS
const form = document.getElementById('createKeyForm');
const formData = new FormData(form);
const scopes = [];
if (form.querySelector('[name="scope_read"]')?.checked) scopes.push('read');
if (form.querySelector('[name="scope_write"]')?.checked) scopes.push('write');
if (form.querySelector('[name="scope_admin"]')?.checked) scopes.push('admin');
if (scopes.length === 0) { alert(lang === 'fa' ? 'حداقل یک دسترسی انتخاب کنید' : 'Select at least one scope'); return; }
const data = {
project_id: formData.get('project_id'),
scope: scopes.join(' '),
description: formData.get('description') || null,
expires_in_days: formData.get('expires_in_days') ? parseInt(formData.get('expires_in_days')) : null,
};
const r = await fetch('/api/dashboard/api-keys/create', {
method: 'POST', headers: { 'Content-Type': 'application/json' }, credentials: 'same-origin',
body: JSON.stringify(data),
});
const res = await r.json();
if (res.error) { alert(res.error); return; }
closeCreateModal();
showNewKey(res.key);
} else {
// User: POST to /api/keys
const name = document.getElementById('new-key-name').value.trim();
const scope = document.getElementById('new-key-scope').value;
if (!name) { alert(lang === 'fa' ? 'نام کلید الزامی است' : 'Key name is required'); return; }
const r = await fetch('/api/keys', {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name, scopes: scope }),
});
const res = await r.json();
if (!r.ok) { alert(res.error || 'Failed'); return; }
closeCreateModal();
showNewKey(res.key.key);
}
}
window.submitCreate = submitCreate;
async function deleteUserKey(keyId) {
if (!confirm(lang === 'fa' ? 'این کلید حذف شود؟' : 'Delete this key?')) return;
const r = await fetch('/api/keys/' + keyId, { method: 'DELETE' });
if (r.ok) {
const row = document.getElementById('ukey-' + keyId);
if (row) row.remove();
}
}
window.deleteUserKey = deleteUserKey;
{% if is_admin %}
function openRevokeModal(keyId, keyName) {
currentRevokeKeyId = keyId;
document.getElementById('revokeKeyName').textContent = keyName;
document.getElementById('revokeModal').classList.remove('hidden');
document.getElementById('revokeModal').classList.add('flex');
}
window.openRevokeModal = openRevokeModal;
function closeRevokeModal() {
document.getElementById('revokeModal').classList.add('hidden');
document.getElementById('revokeModal').classList.remove('flex');
}
window.closeRevokeModal = closeRevokeModal;
document.getElementById('confirmRevokeBtn').onclick = async function() {
if (!currentRevokeKeyId) return;
const r = await fetch('/api/dashboard/api-keys/' + currentRevokeKeyId + '/revoke', { method: 'POST', credentials: 'same-origin' });
if (r.ok) location.reload();
else { const d = await r.json(); alert(d.error || 'Failed'); }
};
function openDeleteModal(keyId, keyName) {
currentDeleteKeyId = keyId;
document.getElementById('deleteKeyName').textContent = keyName;
document.getElementById('deleteModal').classList.remove('hidden');
document.getElementById('deleteModal').classList.add('flex');
}
window.openDeleteModal = openDeleteModal;
function closeDeleteModal() {
document.getElementById('deleteModal').classList.add('hidden');
document.getElementById('deleteModal').classList.remove('flex');
}
window.closeDeleteModal = closeDeleteModal;
document.getElementById('confirmDeleteBtn').onclick = async function() {
if (!currentDeleteKeyId) return;
const r = await fetch('/api/dashboard/api-keys/' + currentDeleteKeyId, { method: 'DELETE', credentials: 'same-origin' });
if (r.ok) location.reload();
else { const d = await r.json(); alert(d.error || 'Failed'); }
};
{% endif %}
})();
</script>
{% endblock %}

View File

@@ -110,6 +110,47 @@
</div>
</form>
</div>
<!-- Tool Access Card -->
<div id="tool-access-card" class="bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700 p-6 space-y-4">
<h3 class="text-base font-semibold text-gray-900 dark:text-white">
{% if lang == 'fa' %}دسترسی به ابزارها{% else %}Tool Access{% endif %}
</h3>
<div id="tool-access-loading" class="text-gray-400 dark:text-gray-500 text-sm">
{% if lang == 'fa' %}در حال بارگذاری...{% else %}Loading...{% endif %}
</div>
<div id="tool-access-content" class="hidden space-y-4">
<!-- Scope selector -->
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
{% if lang == 'fa' %}سطح دسترسی{% else %}Access Scope{% endif %}
</label>
<select id="tool-scope-select"
class="w-full bg-gray-50 dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-lg px-4 py-2.5 text-gray-900 dark:text-white focus:ring-2 focus:ring-blue-500 focus:border-transparent">
<option value="read">{% if lang == 'fa' %}فقط خواندن (Read){% else %}Read{% endif %}</option>
<option value="read:sensitive">{% if lang == 'fa' %}خواندن + داده حساس{% else %}Read + Sensitive{% endif %}</option>
<option value="deploy">{% if lang == 'fa' %}استقرار (start/stop/restart){% else %}Deploy{% endif %}</option>
<option value="write">{% if lang == 'fa' %}نوشتن + Lifecycle{% else %}Write{% endif %}</option>
<option value="admin">{% if lang == 'fa' %}دسترسی کامل{% else %}Admin (all tools){% endif %}</option>
<option value="custom">{% if lang == 'fa' %}سفارشی (per-tool){% else %}Custom{% endif %}</option>
</select>
<p id="scope-desc" class="text-xs text-gray-500 dark:text-gray-400 mt-1.5"></p>
</div>
<!-- Status message for scope save -->
<div id="scope-status" class="hidden text-xs"></div>
<!-- Advanced: per-tool overrides -->
<details id="tool-overrides-section">
<summary class="cursor-pointer text-sm font-medium text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white select-none py-1">
{% if lang == 'fa' %}پیشرفته — انتخاب دستی ابزارها{% else %}Advanced — per-tool overrides{% endif %}
</summary>
<div id="tool-list" class="mt-3 space-y-4 border-t border-gray-200 dark:border-gray-700 pt-4"></div>
</details>
</div>
</div>
</div>
{% 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);
</script>
{% endblock %}

View File

@@ -80,6 +80,10 @@
id="test-btn-{{ site.id }}">
{{ t.test_connection }}
</button>
<a href="/dashboard/sites/{{ site.id }}{% if lang and lang != 'en' %}?lang={{ lang }}{% endif %}"
class="text-sm text-blue-600 dark:text-blue-400 hover:text-blue-500">
{{ t.get('connect', 'Connect') }}
</a>
<a href="/dashboard/sites/{{ site.id }}/edit{% if lang and lang != 'en' %}?lang={{ lang }}{% endif %}"
class="text-sm text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white">
{{ t.edit }}

View File

@@ -0,0 +1,134 @@
{% extends "dashboard/base.html" %}
{% block title %}{{ site.alias }} - MCP Hub{% endblock %}
{% block page_title %}{{ site.alias }}{% endblock %}
{% block content %}
<div class="max-w-3xl mx-auto space-y-6">
<div class="flex items-center gap-4 mb-6">
<a href="/dashboard/sites{% if lang and lang != 'en' %}?lang={{ lang }}{% endif %}"
class="text-gray-500 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white transition-colors">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 19l-7-7 7-7" />
</svg>
</a>
<h2 class="text-xl font-semibold text-gray-900 dark:text-white">
{{ site.alias }}
<span class="ml-2 inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-blue-100 dark:bg-blue-500/20 text-blue-700 dark:text-blue-300">
{{ plugin_names.get(site.plugin_type, site.plugin_type) }}
</span>
</h2>
<a href="/dashboard/sites/{{ site.id }}/edit{% if lang and lang != 'en' %}?lang={{ lang }}{% endif %}"
class="ml-auto text-sm text-gray-500 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white">
{% if lang == 'fa' %}ویرایش{% else %}Edit{% endif %}
</a>
</div>
<!-- MCP Endpoint URL -->
<div class="bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700 p-6">
<h3 class="text-base font-semibold text-gray-900 dark:text-white mb-3">
{% if lang == 'fa' %}آدرس MCP{% else %}MCP Endpoint{% endif %}
</h3>
<div class="flex items-center gap-2">
<code id="mcp-url" class="flex-1 bg-gray-100 dark:bg-gray-900 border border-gray-200 dark:border-gray-700 rounded-lg px-4 py-2 text-sm font-mono text-gray-800 dark:text-gray-200 overflow-x-auto">
{{ mcp_url }}
</code>
<button onclick="copyMcpUrl()" class="flex-shrink-0 px-4 py-2 bg-blue-600 hover:bg-blue-700 rounded-lg text-white text-sm transition-colors">
{% if lang == 'fa' %}کپی{% else %}Copy{% endif %}
</button>
</div>
<p class="text-xs text-gray-500 dark:text-gray-400 mt-2">
{% if lang == 'fa' %}
برای احراز هویت از کلید API (Bearer) یا OAuth استفاده کنید.
{% else %}
Authenticate with an API key (Bearer token) or OAuth.
{% endif %}
</p>
</div>
<!-- Config Snippets -->
<div class="bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700 p-6">
<h3 class="text-base font-semibold text-gray-900 dark:text-white mb-4">
{% if lang == 'fa' %}نمونه کدهای پیکربندی{% else %}Configuration Snippets{% endif %}
</h3>
<div class="mb-4">
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
{% if lang == 'fa' %}انتخاب کلاینت{% else %}Select Client{% endif %}
</label>
<select id="config-client" onchange="updateConfig()"
class="w-full bg-gray-50 dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-lg px-4 py-2.5 text-gray-900 dark:text-white focus:ring-2 focus:ring-blue-500">
{% for client in clients %}
<option value="{{ client.id }}">{{ client.label }}</option>
{% endfor %}
</select>
</div>
<div class="relative">
<pre id="config-output" class="bg-gray-100 dark:bg-gray-900 rounded-lg p-4 text-sm text-gray-700 dark:text-gray-300 overflow-x-auto border border-gray-200 dark:border-gray-700 min-h-[100px]">{% if lang == 'fa' %}در حال بارگذاری...{% else %}Loading...{% endif %}</pre>
<button onclick="copyConfig()" id="copy-config-btn"
class="absolute top-2 right-2 text-xs bg-gray-200 dark:bg-gray-700 hover:bg-gray-300 dark:hover:bg-gray-600 text-gray-600 dark:text-gray-300 px-3 py-1 rounded transition-colors">
{% if lang == 'fa' %}کپی{% else %}Copy{% endif %}
</button>
</div>
<div id="transport-note" class="mt-4 bg-blue-50 dark:bg-blue-500/10 border border-blue-200 dark:border-blue-500/30 rounded-lg p-4">
<p class="text-sm text-blue-700 dark:text-blue-400">
{% if lang == 'fa' %}
<strong>نکته:</strong> از <code>streamableHttp</code> برای Claude Desktop و <code>http</code> برای VS Code/Claude Code استفاده کنید.
{% else %}
<strong>Note:</strong> Use <code>streamableHttp</code> for Claude Desktop, <code>http</code> for VS Code/Claude Code.
{% endif %}
</p>
</div>
<div id="bearer-hint" class="mt-3 bg-gray-50 dark:bg-gray-700/50 border border-gray-200 dark:border-gray-600 rounded-lg p-4">
<p class="text-sm text-gray-600 dark:text-gray-400 mb-2">
{% if lang == 'fa' %}
<strong>API Key:</strong> از صفحه <a href="/dashboard/keys{% if lang and lang != 'en' %}?lang={{ lang }}{% endif %}" class="underline">کلیدها</a> بسازید:
{% else %}
<strong>API Key:</strong> Create one on the <a href="/dashboard/keys{% if lang and lang != 'en' %}?lang={{ lang }}{% endif %}" class="underline">API Keys</a> page:
{% endif %}
</p>
<code class="block bg-gray-100 dark:bg-gray-900 px-3 py-1.5 rounded text-xs font-mono text-gray-800 dark:text-gray-200">"Authorization": "Bearer mhu_YOUR_API_KEY_HERE"</code>
</div>
</div>
</div>
{% endblock %}
{% block scripts %}
<script>
const siteAlias = "{{ site.alias }}";
const WEB_CLIENTS = ['claude_connectors', 'chatgpt'];
async function updateConfig() {
const client = document.getElementById('config-client')?.value;
if (!client) return;
const isWeb = WEB_CLIENTS.includes(client);
document.getElementById('transport-note').style.display = isWeb ? 'none' : '';
document.getElementById('bearer-hint').style.display = isWeb ? 'none' : '';
try {
const r = await fetch('/api/config/' + siteAlias + '?client=' + client);
const data = await r.json();
document.getElementById('config-output').textContent = r.ok ? data.config : '{% if lang == "fa" %}خطا در بارگذاری{% else %}Error loading config{% endif %}';
} catch (_) {
document.getElementById('config-output').textContent = '{% if lang == "fa" %}خطای شبکه{% else %}Network error{% endif %}';
}
}
function copyMcpUrl() {
navigator.clipboard.writeText(document.getElementById('mcp-url').textContent.trim());
}
function copyConfig() {
const text = document.getElementById('config-output').textContent;
navigator.clipboard.writeText(text);
const btn = document.getElementById('copy-config-btn');
const orig = btn.textContent;
btn.textContent = '{% if lang == "fa" %}کپی شد!{% else %}Copied!{% endif %}';
setTimeout(() => btn.textContent = orig, 2000);
}
document.addEventListener('DOMContentLoaded', updateConfig);
</script>
{% endblock %}

326
core/tool_access.py Normal file
View File

@@ -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

View File

@@ -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(

View File

@@ -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

View File

@@ -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

View File

@@ -0,0 +1,115 @@
# Next Session — F.17 Phase 3: Projects + Phase 2: Databases & Services
> Session prompt. Copy and paste into a new Claude Code conversation.
---
## Prompt:
```
از مهارت project-ops برای آشنایی با محیط استفاده کن. حافظه و فایل‌های مرجع را بررسی کن.
## فایل‌های مرجع
- docs/plans/2026-04-02-coolify-mcp-plugin-design.md (mcphub-internal) ← طرح کامل پلاگین
- docs/plans/2026-03-25-v4-development-cycle.md (mcphub-internal) ← Phase F.17 آپدیت شده
- plugins/coolify/ (mcphub-internal) ← پلاگین Phase 1 (الگوی اصلی)
- plugins/gitea/ (mcphub-internal) ← الگوی مرجع معماری
- CLAUDE.md (mcphub-internal)
## وضعیت فعلی
- MCPHub: v3.7.0 — 596 ابزار، 10 پلاگین
- Coolify Phase 1: ✅ 30 ابزار (17 app + 5 deploy + 8 server) — deployed, tested, synced
- MCP endpoint فعال: mcphub-coolify در Claude Code (30 ابزار لود شده)
- CI سبز، 718 تست (internal), 686 تست (public)
## ریپازیتوری
- MCPHub (internal): `/config/workspace/mcphub-internal` (branch Phase-1)
## هدف session: F.17 Phase 3 + Phase 2
### بخش اول: Phase 3 — Projects & Environments (8 ابزار)
> اولویت بالا — بدون project_uuid ساخت app/db/service بلاک است
#### مرحله ۱: Projects Handler
- [ ] `plugins/coolify/handlers/projects.py`
- [ ] ابزارها:
- list_projects (GET /projects) — read
- get_project (GET /projects/{uuid}) — read
- create_project (POST /projects) — write
- update_project (PATCH /projects/{uuid}) — write
- delete_project (DELETE /projects/{uuid}) — admin
- list_environments (GET /projects/{uuid}/environments) — read
- get_environment (GET /projects/{uuid}/environments/{name}) — read
- create_environment (POST /projects/{uuid}/environments) — write
- [ ] متدهای client در `client.py` اضافه شود
- [ ] در `handlers/__init__.py` ایمپورت projects اضافه شود
- [ ] در `plugin.py` — specs و __getattr__ آپدیت شود
#### مرحله ۲: تست و دیپلوی Phase 3
- [ ] `tests/test_coolify_projects.py` — Unit tests با mocked HTTP
- [ ] `pytest tests/test_coolify*.py -v`
- [ ] Commit: `feat(F.17): add projects handler — Phase 3 (8 tools)`
- [ ] Push و درخواست redeploy
- [ ] تست live: list_projects → پیدا کردن project_uuid
- [ ] تست ساخت container: create_application_docker_image با project_uuid واقعی → دیپلوی nginx → تأیید → حذف
### بخش دوم: Phase 2 — Databases (16 ابزار)
- [ ] `plugins/coolify/handlers/databases.py`
- [ ] ابزارها:
- list_databases, get_database — read
- update_database — write
- delete_database — admin
- start_database, stop_database, restart_database — write
- create_postgresql, create_mysql, create_mariadb — write
- create_mongodb, create_redis, create_clickhouse — write
- get_database_backups — read
- create_database_backup — write
- list_backup_executions — read
- [ ] متدهای client اضافه شود
- [ ] تست: `tests/test_coolify_databases.py`
### بخش سوم: Phase 2 — Services (13 ابزار)
- [ ] `plugins/coolify/handlers/services.py`
- [ ] ابزارها:
- list_services, get_service — read
- create_service — write
- update_service — write
- delete_service — admin
- start_service, stop_service, restart_service — write
- list_service_envs — read
- create_service_env — write
- update_service_env — write
- update_service_envs_bulk — write
- delete_service_env — write
- [ ] متدهای client اضافه شود
- [ ] تست: `tests/test_coolify_services.py`
### بخش چهارم: ثبت و تست نهایی
- [ ] handlers/__init__.py آپدیت (projects, databases, services)
- [ ] plugin.py آپدیت (specs + __getattr__)
- [ ] server.py — نیازی به تغییر ندارد (coolify قبلا ثبت شده)
- [ ] `uvx --python 3.12 black .`
- [ ] `uvx ruff check --fix .`
- [ ] `pytest` (همه تست‌ها سبز)
- [ ] Commit: `feat(F.17): add databases + services handlers — Phase 2 (29 tools)`
- [ ] Push و درخواست redeploy
- [ ] تست live: list_databases, list_services
- [ ] آپدیت ورژن به v3.8.0 (اگر تأیید شد)
### بخش پنجم: Sync و داکیومنت
- [ ] Sync به نسخه عمومی: `python3.11 scripts/community-build/sync.py --output ../mcphub/`
- [ ] black + ruff + pytest در نسخه عمومی
- [ ] README آپدیت (تعداد ابزار، جدول Coolify)
- [ ] Commit و push نسخه عمومی
- [ ] mcp-skills/skills/coolify/SKILL.md آپدیت (67 ابزار)
- [ ] حافظه آپدیت شود
- [ ] پلن آپدیت شود (Phase 2+3 complete)
## نکات مهم
- server.py نیازی به تغییر ندارد — coolify قبلا register شده و generate_tools خودکار specs جدید رو می‌خونه
- site_api.py نیازی به تغییر ندارد — credential fields و display name قبلا اضافه شده
- الگوی handler: از applications.py کپی کن (آخرین و تمیزترین)
- هر بخش (Phase 3, databases, services) جداگانه commit شود
- قبل از هر عملیات write در تست live از کاربر تأیید بگیر
- ایمیل git داخلی: mcphub.dev@gmail.com
```

View File

@@ -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
- بخش اول و دوم اولویت اصلی هستند — بخش سوم و چهارم اگر وقت شد

View File

@@ -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: "<selected>"`
- [ ] 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) را بازگردانی کنی. همه سایت‌محور است.

View File

@@ -267,3 +267,163 @@ class CoolifyClient:
async def validate_server(self, uuid: str) -> dict:
"""Validate server connectivity and configuration."""
return await self.request("GET", f"servers/{uuid}/validate")
# --- Projects ---
async def list_projects(self) -> list[dict]:
"""List all projects."""
return await self.request("GET", "projects")
async def get_project(self, uuid: str) -> dict:
"""Get project by UUID."""
return await self.request("GET", f"projects/{uuid}")
async def create_project(self, data: dict) -> dict:
"""Create a new project."""
return await self.request("POST", "projects", json_data=data)
async def update_project(self, uuid: str, data: dict) -> dict:
"""Update project by UUID."""
return await self.request("PATCH", f"projects/{uuid}", json_data=data)
async def delete_project(self, uuid: str) -> dict:
"""Delete project by UUID."""
return await self.request("DELETE", f"projects/{uuid}")
# --- Environments ---
async def list_environments(self, project_uuid: str) -> list[dict]:
"""List environments in a project."""
return await self.request("GET", f"projects/{project_uuid}/environments")
async def get_environment(self, project_uuid: str, environment_name: str) -> dict:
"""Get environment by name."""
return await self.request("GET", f"projects/{project_uuid}/environments/{environment_name}")
async def create_environment(self, project_uuid: str, data: dict) -> dict:
"""Create environment in a project."""
return await self.request("POST", f"projects/{project_uuid}/environments", json_data=data)
# --- Databases ---
async def list_databases(self) -> list[dict]:
"""List all databases."""
return await self.request("GET", "databases")
async def get_database(self, uuid: str) -> dict:
"""Get database by UUID."""
return await self.request("GET", f"databases/{uuid}")
async def update_database(self, uuid: str, data: dict) -> dict:
"""Update database by UUID."""
return await self.request("PATCH", f"databases/{uuid}", json_data=data)
async def delete_database(
self,
uuid: str,
delete_configurations: bool = True,
delete_volumes: bool = True,
docker_cleanup: bool = True,
) -> dict:
"""Delete database by UUID."""
params = {
"delete_configurations": str(delete_configurations).lower(),
"delete_volumes": str(delete_volumes).lower(),
"docker_cleanup": str(docker_cleanup).lower(),
}
return await self.request("DELETE", f"databases/{uuid}", params=params)
async def start_database(self, uuid: str) -> dict:
"""Start database."""
return await self.request("GET", f"databases/{uuid}/start")
async def stop_database(self, uuid: str) -> dict:
"""Stop database."""
return await self.request("GET", f"databases/{uuid}/stop")
async def restart_database(self, uuid: str) -> dict:
"""Restart database."""
return await self.request("GET", f"databases/{uuid}/restart")
async def create_database(self, db_type: str, data: dict) -> dict:
"""Create a database of given type (postgresql, mysql, mariadb, mongodb, redis, clickhouse)."""
return await self.request("POST", f"databases/{db_type}", json_data=data)
async def get_database_backups(self, uuid: str) -> dict:
"""Get database backups."""
return await self.request("GET", f"databases/{uuid}/backups")
async def create_database_backup(self, uuid: str) -> dict:
"""Create a manual database backup."""
return await self.request("POST", f"databases/{uuid}/backups")
async def list_backup_executions(self) -> list[dict]:
"""List all backup executions."""
return await self.request("GET", "databases/backup-executions")
# --- Services ---
async def list_services(self) -> list[dict]:
"""List all services."""
return await self.request("GET", "services")
async def get_service(self, uuid: str) -> dict:
"""Get service by UUID."""
return await self.request("GET", f"services/{uuid}")
async def create_service(self, data: dict) -> dict:
"""Create a service from template."""
return await self.request("POST", "services", json_data=data)
async def update_service(self, uuid: str, data: dict) -> dict:
"""Update service by UUID."""
return await self.request("PATCH", f"services/{uuid}", json_data=data)
async def delete_service(
self,
uuid: str,
delete_configurations: bool = True,
delete_volumes: bool = True,
docker_cleanup: bool = True,
) -> dict:
"""Delete service by UUID."""
params = {
"delete_configurations": str(delete_configurations).lower(),
"delete_volumes": str(delete_volumes).lower(),
"docker_cleanup": str(docker_cleanup).lower(),
}
return await self.request("DELETE", f"services/{uuid}", params=params)
async def start_service(self, uuid: str) -> dict:
"""Start service."""
return await self.request("GET", f"services/{uuid}/start")
async def stop_service(self, uuid: str) -> dict:
"""Stop service."""
return await self.request("GET", f"services/{uuid}/stop")
async def restart_service(self, uuid: str) -> dict:
"""Restart service."""
return await self.request("GET", f"services/{uuid}/restart")
# --- Service Environment Variables ---
async def list_service_envs(self, uuid: str) -> list[dict]:
"""List service environment variables."""
return await self.request("GET", f"services/{uuid}/envs")
async def create_service_env(self, uuid: str, data: dict) -> dict:
"""Create service environment variable."""
return await self.request("POST", f"services/{uuid}/envs", json_data=data)
async def update_service_env(self, uuid: str, data: dict) -> dict:
"""Update service environment variable."""
return await self.request("PATCH", f"services/{uuid}/envs", json_data=data)
async def update_service_envs_bulk(self, uuid: str, data: list[dict]) -> dict:
"""Bulk update service environment variables."""
return await self.request("PATCH", f"services/{uuid}/envs/bulk", json_data={"data": data})
async def delete_service_env(self, uuid: str, env_uuid: str) -> dict:
"""Delete service environment variable."""
return await self.request("DELETE", f"services/{uuid}/envs/{env_uuid}")

View File

@@ -4,10 +4,13 @@ Coolify Plugin Handlers
All tool handlers for Coolify operations.
"""
from . import applications, deployments, servers
from . import applications, databases, deployments, projects, servers, services
__all__ = [
"applications",
"databases",
"deployments",
"projects",
"servers",
"services",
]

View File

@@ -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": {

View File

@@ -0,0 +1,420 @@
"""Database Handler — manages Coolify databases, lifecycle, and backups."""
import json
from typing import Any
from plugins.coolify.client import CoolifyClient
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": {
"type": "object",
"properties": {
"project_uuid": {
"type": "string",
"description": "Project UUID",
"minLength": 1,
},
"server_uuid": {
"type": "string",
"description": "Server UUID",
"minLength": 1,
},
"environment_name": {
"type": "string",
"description": "Environment name (e.g., 'production')",
"minLength": 1,
},
"name": {
"anyOf": [{"type": "string"}, {"type": "null"}],
"description": "Database name",
},
"description": {
"anyOf": [{"type": "string"}, {"type": "null"}],
"description": "Database description",
},
"instant_deploy": {
"type": "boolean",
"description": "Deploy immediately after creation",
"default": False,
},
},
"required": ["project_uuid", "server_uuid", "environment_name"],
},
"scope": "write",
}
def get_tool_specifications() -> list[dict[str, Any]]:
"""Return tool specifications for ToolGenerator."""
specs = [
{
"name": "list_databases",
"category": "read",
"method_name": "list_databases",
"description": "List all Coolify databases.",
"schema": {
"type": "object",
"properties": {},
},
"scope": "read",
},
{
"name": "get_database",
"category": "read_sensitive",
"sensitivity": "sensitive",
"method_name": "get_database",
"description": "Get details of a specific Coolify database by UUID.",
"schema": {
"type": "object",
"properties": {
"uuid": {
"type": "string",
"description": "Database UUID",
"minLength": 1,
},
},
"required": ["uuid"],
},
"scope": "read",
},
{
"name": "update_database",
"category": "crud",
"method_name": "update_database",
"description": "Update a Coolify database settings.",
"schema": {
"type": "object",
"properties": {
"uuid": {
"type": "string",
"description": "Database UUID",
"minLength": 1,
},
"name": {
"anyOf": [{"type": "string"}, {"type": "null"}],
"description": "Database name",
},
"description": {
"anyOf": [{"type": "string"}, {"type": "null"}],
"description": "Database description",
},
"image": {
"anyOf": [{"type": "string"}, {"type": "null"}],
"description": "Docker image",
},
"is_public": {
"anyOf": [{"type": "boolean"}, {"type": "null"}],
"description": "Make database publicly accessible",
},
"public_port": {
"anyOf": [{"type": "integer"}, {"type": "null"}],
"description": "Public port number",
},
},
"required": ["uuid"],
},
"scope": "write",
},
{
"name": "delete_database",
"category": "system",
"method_name": "delete_database",
"description": (
"Delete a Coolify database permanently. "
"Optionally clean up volumes and Docker resources."
),
"schema": {
"type": "object",
"properties": {
"uuid": {
"type": "string",
"description": "Database UUID",
"minLength": 1,
},
"delete_configurations": {
"type": "boolean",
"description": "Delete configurations",
"default": True,
},
"delete_volumes": {
"type": "boolean",
"description": "Delete volumes",
"default": True,
},
"docker_cleanup": {
"type": "boolean",
"description": "Run Docker cleanup",
"default": True,
},
},
"required": ["uuid"],
},
"scope": "admin",
},
{
"name": "start_database",
"category": "lifecycle",
"method_name": "start_database",
"description": "Start a Coolify database.",
"schema": {
"type": "object",
"properties": {
"uuid": {
"type": "string",
"description": "Database UUID",
"minLength": 1,
},
},
"required": ["uuid"],
},
"scope": "write",
},
{
"name": "stop_database",
"category": "lifecycle",
"method_name": "stop_database",
"description": "Stop a running Coolify database.",
"schema": {
"type": "object",
"properties": {
"uuid": {
"type": "string",
"description": "Database UUID",
"minLength": 1,
},
},
"required": ["uuid"],
},
"scope": "write",
},
{
"name": "restart_database",
"category": "lifecycle",
"method_name": "restart_database",
"description": "Restart a Coolify database.",
"schema": {
"type": "object",
"properties": {
"uuid": {
"type": "string",
"description": "Database UUID",
"minLength": 1,
},
},
"required": ["uuid"],
},
"scope": "write",
},
]
# Add create specs for each database type
db_types = [
("postgresql", "Create a PostgreSQL database on Coolify."),
("mysql", "Create a MySQL database on Coolify."),
("mariadb", "Create a MariaDB database on Coolify."),
("mongodb", "Create a MongoDB database on Coolify."),
("redis", "Create a Redis database on Coolify."),
("clickhouse", "Create a ClickHouse database on Coolify."),
]
for db_type, desc in db_types:
specs.append(_create_db_spec(db_type, desc))
# Backup tools
specs.extend(
[
{
"name": "get_database_backups",
"category": "backup",
"sensitivity": "sensitive",
"method_name": "get_database_backups",
"description": "Get backup configuration and history for a Coolify database.",
"schema": {
"type": "object",
"properties": {
"uuid": {
"type": "string",
"description": "Database UUID",
"minLength": 1,
},
},
"required": ["uuid"],
},
"scope": "read",
},
{
"name": "create_database_backup",
"category": "backup",
"sensitivity": "sensitive",
"method_name": "create_database_backup",
"description": "Create a manual backup of a Coolify database.",
"schema": {
"type": "object",
"properties": {
"uuid": {
"type": "string",
"description": "Database UUID",
"minLength": 1,
},
},
"required": ["uuid"],
},
"scope": "write",
},
{
"name": "list_backup_executions",
"category": "backup",
"sensitivity": "sensitive",
"method_name": "list_backup_executions",
"description": "List all backup executions across all databases.",
"schema": {
"type": "object",
"properties": {},
},
"scope": "read",
},
]
)
return specs
# --- Handler Functions ---
async def list_databases(client: CoolifyClient) -> str:
"""List all databases."""
databases = await client.list_databases()
result = {"success": True, "count": len(databases), "databases": databases}
return json.dumps(result, indent=2, ensure_ascii=False)
async def get_database(client: CoolifyClient, uuid: str) -> str:
"""Get database details."""
db = await client.get_database(uuid)
result = {"success": True, "database": db}
return json.dumps(result, indent=2, ensure_ascii=False)
async def update_database(client: CoolifyClient, uuid: str, **kwargs) -> str:
"""Update database settings."""
data = {k: v for k, v in kwargs.items() if v is not None and k != "uuid"}
db = await client.update_database(uuid, data)
result = {
"success": True,
"message": f"Database '{uuid}' updated successfully",
"database": db,
}
return json.dumps(result, indent=2, ensure_ascii=False)
async def delete_database(
client: CoolifyClient,
uuid: str,
delete_configurations: bool = True,
delete_volumes: bool = True,
docker_cleanup: bool = True,
) -> str:
"""Delete a database."""
result_data = await client.delete_database(
uuid,
delete_configurations=delete_configurations,
delete_volumes=delete_volumes,
docker_cleanup=docker_cleanup,
)
result = {"success": True, "message": f"Database '{uuid}' deleted", "data": result_data}
return json.dumps(result, indent=2, ensure_ascii=False)
async def start_database(client: CoolifyClient, uuid: str) -> str:
"""Start a database."""
result_data = await client.start_database(uuid)
result = {"success": True, "message": f"Database '{uuid}' starting", "data": result_data}
return json.dumps(result, indent=2, ensure_ascii=False)
async def stop_database(client: CoolifyClient, uuid: str) -> str:
"""Stop a database."""
result_data = await client.stop_database(uuid)
result = {"success": True, "message": f"Database '{uuid}' stopping", "data": result_data}
return json.dumps(result, indent=2, ensure_ascii=False)
async def restart_database(client: CoolifyClient, uuid: str) -> str:
"""Restart a database."""
result_data = await client.restart_database(uuid)
result = {"success": True, "message": f"Database '{uuid}' restarting", "data": result_data}
return json.dumps(result, indent=2, ensure_ascii=False)
async def _create_database(client: CoolifyClient, db_type: str, **kwargs) -> str:
"""Create a database of given type."""
data = {k: v for k, v in kwargs.items() if v is not None}
db = await client.create_database(db_type, data)
result = {
"success": True,
"message": f"{db_type.title()} database created successfully",
"database": db,
}
return json.dumps(result, indent=2, ensure_ascii=False)
async def create_postgresql(client: CoolifyClient, **kwargs) -> str:
"""Create a PostgreSQL database."""
return await _create_database(client, "postgresql", **kwargs)
async def create_mysql(client: CoolifyClient, **kwargs) -> str:
"""Create a MySQL database."""
return await _create_database(client, "mysql", **kwargs)
async def create_mariadb(client: CoolifyClient, **kwargs) -> str:
"""Create a MariaDB database."""
return await _create_database(client, "mariadb", **kwargs)
async def create_mongodb(client: CoolifyClient, **kwargs) -> str:
"""Create a MongoDB database."""
return await _create_database(client, "mongodb", **kwargs)
async def create_redis(client: CoolifyClient, **kwargs) -> str:
"""Create a Redis database."""
return await _create_database(client, "redis", **kwargs)
async def create_clickhouse(client: CoolifyClient, **kwargs) -> str:
"""Create a ClickHouse database."""
return await _create_database(client, "clickhouse", **kwargs)
async def get_database_backups(client: CoolifyClient, uuid: str) -> str:
"""Get database backups."""
backups = await client.get_database_backups(uuid)
result = {"success": True, "backups": backups}
return json.dumps(result, indent=2, ensure_ascii=False)
async def create_database_backup(client: CoolifyClient, uuid: str) -> str:
"""Create a manual database backup."""
result_data = await client.create_database_backup(uuid)
result = {
"success": True,
"message": f"Backup created for database '{uuid}'",
"data": result_data,
}
return json.dumps(result, indent=2, ensure_ascii=False)
async def list_backup_executions(client: CoolifyClient) -> str:
"""List all backup executions."""
executions = await client.list_backup_executions()
result = {"success": True, "count": len(executions), "executions": executions}
return json.dumps(result, indent=2, ensure_ascii=False)

View File

@@ -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": {

View File

@@ -0,0 +1,273 @@
"""Project & Environment Handler — manages Coolify projects and environments."""
import json
from typing import Any
from plugins.coolify.client import CoolifyClient
def get_tool_specifications() -> list[dict[str, Any]]:
"""Return tool specifications for ToolGenerator."""
return [
{
"name": "list_projects",
"category": "read",
"method_name": "list_projects",
"description": "List all Coolify projects.",
"schema": {
"type": "object",
"properties": {},
},
"scope": "read",
},
{
"name": "get_project",
"category": "read",
"method_name": "get_project",
"description": "Get details of a specific Coolify project by UUID.",
"schema": {
"type": "object",
"properties": {
"uuid": {
"type": "string",
"description": "Project UUID",
"minLength": 1,
},
},
"required": ["uuid"],
},
"scope": "read",
},
{
"name": "create_project",
"category": "crud",
"method_name": "create_project",
"description": (
"Create a new Coolify project. "
"Projects group applications, databases, and services."
),
"schema": {
"type": "object",
"properties": {
"name": {
"type": "string",
"description": "Project name",
"minLength": 1,
},
"description": {
"anyOf": [{"type": "string"}, {"type": "null"}],
"description": "Project description",
},
},
"required": ["name"],
},
"scope": "write",
},
{
"name": "update_project",
"category": "crud",
"method_name": "update_project",
"description": "Update a Coolify project name or description.",
"schema": {
"type": "object",
"properties": {
"uuid": {
"type": "string",
"description": "Project UUID",
"minLength": 1,
},
"name": {
"anyOf": [{"type": "string"}, {"type": "null"}],
"description": "New project name",
},
"description": {
"anyOf": [{"type": "string"}, {"type": "null"}],
"description": "New project description",
},
},
"required": ["uuid"],
},
"scope": "write",
},
{
"name": "delete_project",
"category": "system",
"method_name": "delete_project",
"description": (
"Delete a Coolify project permanently. "
"All resources in the project will be removed."
),
"schema": {
"type": "object",
"properties": {
"uuid": {
"type": "string",
"description": "Project UUID",
"minLength": 1,
},
},
"required": ["uuid"],
},
"scope": "admin",
},
{
"name": "list_environments",
"category": "read",
"method_name": "list_environments",
"description": "List all environments in a Coolify project.",
"schema": {
"type": "object",
"properties": {
"project_uuid": {
"type": "string",
"description": "Project UUID",
"minLength": 1,
},
},
"required": ["project_uuid"],
},
"scope": "read",
},
{
"name": "get_environment",
"category": "read",
"method_name": "get_environment",
"description": ("Get details of a specific environment in a Coolify project by name."),
"schema": {
"type": "object",
"properties": {
"project_uuid": {
"type": "string",
"description": "Project UUID",
"minLength": 1,
},
"environment_name": {
"type": "string",
"description": "Environment name (e.g., 'production')",
"minLength": 1,
},
},
"required": ["project_uuid", "environment_name"],
},
"scope": "read",
},
{
"name": "create_environment",
"category": "crud",
"method_name": "create_environment",
"description": "Create a new environment in a Coolify project.",
"schema": {
"type": "object",
"properties": {
"project_uuid": {
"type": "string",
"description": "Project UUID",
"minLength": 1,
},
"name": {
"type": "string",
"description": "Environment name",
"minLength": 1,
},
"description": {
"anyOf": [{"type": "string"}, {"type": "null"}],
"description": "Environment description",
},
},
"required": ["project_uuid", "name"],
},
"scope": "write",
},
]
# --- Handler Functions ---
async def list_projects(client: CoolifyClient) -> str:
"""List all projects."""
projects = await client.list_projects()
result = {"success": True, "count": len(projects), "projects": projects}
return json.dumps(result, indent=2, ensure_ascii=False)
async def get_project(client: CoolifyClient, uuid: str) -> str:
"""Get project details."""
project = await client.get_project(uuid)
result = {"success": True, "project": project}
return json.dumps(result, indent=2, ensure_ascii=False)
async def create_project(client: CoolifyClient, name: str, description: str | None = None) -> str:
"""Create a new project."""
data = {"name": name}
if description is not None:
data["description"] = description
project = await client.create_project(data)
result = {
"success": True,
"message": "Project created successfully",
"project": project,
}
return json.dumps(result, indent=2, ensure_ascii=False)
async def update_project(
client: CoolifyClient,
uuid: str,
name: str | None = None,
description: str | None = None,
) -> str:
"""Update project settings."""
data = {}
if name is not None:
data["name"] = name
if description is not None:
data["description"] = description
project = await client.update_project(uuid, data)
result = {
"success": True,
"message": f"Project '{uuid}' updated successfully",
"project": project,
}
return json.dumps(result, indent=2, ensure_ascii=False)
async def delete_project(client: CoolifyClient, uuid: str) -> str:
"""Delete a project."""
result_data = await client.delete_project(uuid)
result = {"success": True, "message": f"Project '{uuid}' deleted", "data": result_data}
return json.dumps(result, indent=2, ensure_ascii=False)
async def list_environments(client: CoolifyClient, project_uuid: str) -> str:
"""List environments in a project."""
envs = await client.list_environments(project_uuid)
result = {"success": True, "count": len(envs), "environments": envs}
return json.dumps(result, indent=2, ensure_ascii=False)
async def get_environment(client: CoolifyClient, project_uuid: str, environment_name: str) -> str:
"""Get environment details."""
env = await client.get_environment(project_uuid, environment_name)
result = {"success": True, "environment": env}
return json.dumps(result, indent=2, ensure_ascii=False)
async def create_environment(
client: CoolifyClient,
project_uuid: str,
name: str,
description: str | None = None,
) -> str:
"""Create a new environment in a project."""
data = {"name": name}
if description is not None:
data["description"] = description
env = await client.create_environment(project_uuid, data)
result = {
"success": True,
"message": f"Environment '{name}' created",
"environment": env,
}
return json.dumps(result, indent=2, ensure_ascii=False)

View File

@@ -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": {

View File

@@ -0,0 +1,501 @@
"""Service Handler — manages Coolify services (Docker Compose), lifecycle, and env vars."""
import json
from typing import Any
from plugins.coolify.client import CoolifyClient
def get_tool_specifications() -> list[dict[str, Any]]:
"""Return tool specifications for ToolGenerator."""
return [
{
"name": "list_services",
"category": "read",
"method_name": "list_services",
"description": "List all Coolify services.",
"schema": {
"type": "object",
"properties": {},
},
"scope": "read",
},
{
"name": "get_service",
"category": "read",
"method_name": "get_service",
"description": "Get details of a specific Coolify service by UUID.",
"schema": {
"type": "object",
"properties": {
"uuid": {
"type": "string",
"description": "Service UUID",
"minLength": 1,
},
},
"required": ["uuid"],
},
"scope": "read",
},
{
"name": "create_service",
"category": "crud",
"method_name": "create_service",
"description": (
"Create a Coolify service from a predefined template. "
"Requires project_uuid, server_uuid, environment, and service type."
),
"schema": {
"type": "object",
"properties": {
"project_uuid": {
"type": "string",
"description": "Project UUID",
"minLength": 1,
},
"server_uuid": {
"type": "string",
"description": "Server UUID",
"minLength": 1,
},
"environment_name": {
"type": "string",
"description": "Environment name (e.g., 'production')",
"minLength": 1,
},
"type": {
"type": "string",
"description": (
"Service type from Coolify templates "
"(e.g., 'plausible-analytics', 'minio', 'grafana')"
),
"minLength": 1,
},
"name": {
"anyOf": [{"type": "string"}, {"type": "null"}],
"description": "Service name",
},
"description": {
"anyOf": [{"type": "string"}, {"type": "null"}],
"description": "Service description",
},
"instant_deploy": {
"type": "boolean",
"description": "Deploy immediately after creation",
"default": False,
},
},
"required": [
"project_uuid",
"server_uuid",
"environment_name",
"type",
],
},
"scope": "write",
},
{
"name": "update_service",
"category": "crud",
"method_name": "update_service",
"description": "Update a Coolify service settings.",
"schema": {
"type": "object",
"properties": {
"uuid": {
"type": "string",
"description": "Service UUID",
"minLength": 1,
},
"name": {
"anyOf": [{"type": "string"}, {"type": "null"}],
"description": "Service name",
},
"description": {
"anyOf": [{"type": "string"}, {"type": "null"}],
"description": "Service description",
},
"docker_compose_raw": {
"anyOf": [{"type": "string"}, {"type": "null"}],
"description": "Raw Docker Compose YAML content",
},
},
"required": ["uuid"],
},
"scope": "write",
},
{
"name": "delete_service",
"category": "system",
"method_name": "delete_service",
"description": (
"Delete a Coolify service permanently. "
"Optionally clean up volumes and Docker resources."
),
"schema": {
"type": "object",
"properties": {
"uuid": {
"type": "string",
"description": "Service UUID",
"minLength": 1,
},
"delete_configurations": {
"type": "boolean",
"description": "Delete configurations",
"default": True,
},
"delete_volumes": {
"type": "boolean",
"description": "Delete volumes",
"default": True,
},
"docker_cleanup": {
"type": "boolean",
"description": "Run Docker cleanup",
"default": True,
},
},
"required": ["uuid"],
},
"scope": "admin",
},
{
"name": "start_service",
"category": "lifecycle",
"method_name": "start_service",
"description": "Start a Coolify service.",
"schema": {
"type": "object",
"properties": {
"uuid": {
"type": "string",
"description": "Service UUID",
"minLength": 1,
},
},
"required": ["uuid"],
},
"scope": "write",
},
{
"name": "stop_service",
"category": "lifecycle",
"method_name": "stop_service",
"description": "Stop a running Coolify service.",
"schema": {
"type": "object",
"properties": {
"uuid": {
"type": "string",
"description": "Service UUID",
"minLength": 1,
},
},
"required": ["uuid"],
},
"scope": "write",
},
{
"name": "restart_service",
"category": "lifecycle",
"method_name": "restart_service",
"description": "Restart a Coolify service.",
"schema": {
"type": "object",
"properties": {
"uuid": {
"type": "string",
"description": "Service UUID",
"minLength": 1,
},
},
"required": ["uuid"],
},
"scope": "write",
},
{
"name": "list_service_envs",
"category": "read_sensitive",
"sensitivity": "sensitive",
"method_name": "list_service_envs",
"description": "List environment variables for a Coolify service.",
"schema": {
"type": "object",
"properties": {
"uuid": {
"type": "string",
"description": "Service UUID",
"minLength": 1,
},
},
"required": ["uuid"],
},
"scope": "read",
},
{
"name": "create_service_env",
"category": "env",
"sensitivity": "sensitive",
"method_name": "create_service_env",
"description": "Create an environment variable for a Coolify service.",
"schema": {
"type": "object",
"properties": {
"uuid": {
"type": "string",
"description": "Service UUID",
"minLength": 1,
},
"key": {
"type": "string",
"description": "Environment variable key",
"minLength": 1,
},
"value": {
"type": "string",
"description": "Environment variable value",
},
"is_preview": {
"type": "boolean",
"description": "Preview deployment only",
"default": False,
},
},
"required": ["uuid", "key", "value"],
},
"scope": "write",
},
{
"name": "update_service_env",
"category": "env",
"sensitivity": "sensitive",
"method_name": "update_service_env",
"description": "Update an environment variable for a Coolify service.",
"schema": {
"type": "object",
"properties": {
"uuid": {
"type": "string",
"description": "Service UUID",
"minLength": 1,
},
"key": {
"type": "string",
"description": "Environment variable key",
"minLength": 1,
},
"value": {
"type": "string",
"description": "New value",
},
"is_preview": {
"anyOf": [{"type": "boolean"}, {"type": "null"}],
"description": "Preview deployment only",
},
},
"required": ["uuid", "key", "value"],
},
"scope": "write",
},
{
"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": {
"type": "object",
"properties": {
"uuid": {
"type": "string",
"description": "Service UUID",
"minLength": 1,
},
"data": {
"type": "array",
"description": "Array of env var objects with key and value",
"items": {
"type": "object",
"properties": {
"key": {"type": "string"},
"value": {"type": "string"},
"is_preview": {"type": "boolean"},
},
"required": ["key", "value"],
},
},
},
"required": ["uuid", "data"],
},
"scope": "write",
},
{
"name": "delete_service_env",
"category": "env",
"sensitivity": "sensitive",
"method_name": "delete_service_env",
"description": "Delete an environment variable from a Coolify service.",
"schema": {
"type": "object",
"properties": {
"uuid": {
"type": "string",
"description": "Service UUID",
"minLength": 1,
},
"env_uuid": {
"type": "string",
"description": "Environment variable UUID",
"minLength": 1,
},
},
"required": ["uuid", "env_uuid"],
},
"scope": "write",
},
]
# --- Handler Functions ---
async def list_services(client: CoolifyClient) -> str:
"""List all services."""
services = await client.list_services()
result = {"success": True, "count": len(services), "services": services}
return json.dumps(result, indent=2, ensure_ascii=False)
async def get_service(client: CoolifyClient, uuid: str) -> str:
"""Get service details."""
service = await client.get_service(uuid)
result = {"success": True, "service": service}
return json.dumps(result, indent=2, ensure_ascii=False)
async def create_service(client: CoolifyClient, **kwargs) -> str:
"""Create a service from template."""
data = {k: v for k, v in kwargs.items() if v is not None}
service = await client.create_service(data)
result = {
"success": True,
"message": "Service created successfully",
"service": service,
}
return json.dumps(result, indent=2, ensure_ascii=False)
async def update_service(client: CoolifyClient, uuid: str, **kwargs) -> str:
"""Update service settings."""
data = {k: v for k, v in kwargs.items() if v is not None and k != "uuid"}
service = await client.update_service(uuid, data)
result = {
"success": True,
"message": f"Service '{uuid}' updated successfully",
"service": service,
}
return json.dumps(result, indent=2, ensure_ascii=False)
async def delete_service(
client: CoolifyClient,
uuid: str,
delete_configurations: bool = True,
delete_volumes: bool = True,
docker_cleanup: bool = True,
) -> str:
"""Delete a service."""
result_data = await client.delete_service(
uuid,
delete_configurations=delete_configurations,
delete_volumes=delete_volumes,
docker_cleanup=docker_cleanup,
)
result = {"success": True, "message": f"Service '{uuid}' deleted", "data": result_data}
return json.dumps(result, indent=2, ensure_ascii=False)
async def start_service(client: CoolifyClient, uuid: str) -> str:
"""Start a service."""
result_data = await client.start_service(uuid)
result = {"success": True, "message": f"Service '{uuid}' starting", "data": result_data}
return json.dumps(result, indent=2, ensure_ascii=False)
async def stop_service(client: CoolifyClient, uuid: str) -> str:
"""Stop a service."""
result_data = await client.stop_service(uuid)
result = {"success": True, "message": f"Service '{uuid}' stopping", "data": result_data}
return json.dumps(result, indent=2, ensure_ascii=False)
async def restart_service(client: CoolifyClient, uuid: str) -> str:
"""Restart a service."""
result_data = await client.restart_service(uuid)
result = {"success": True, "message": f"Service '{uuid}' restarting", "data": result_data}
return json.dumps(result, indent=2, ensure_ascii=False)
async def list_service_envs(client: CoolifyClient, uuid: str) -> str:
"""List service environment variables."""
envs = await client.list_service_envs(uuid)
result = {"success": True, "count": len(envs), "envs": envs}
return json.dumps(result, indent=2, ensure_ascii=False)
async def create_service_env(
client: CoolifyClient,
uuid: str,
key: str,
value: str,
is_preview: bool = False,
) -> str:
"""Create service environment variable."""
data = {"key": key, "value": value, "is_preview": is_preview}
result_data = await client.create_service_env(uuid, data)
result = {
"success": True,
"message": f"Env var '{key}' created",
"data": result_data,
}
return json.dumps(result, indent=2, ensure_ascii=False)
async def update_service_env(
client: CoolifyClient,
uuid: str,
key: str,
value: str,
is_preview: bool | None = None,
) -> str:
"""Update service environment variable."""
data = {"key": key, "value": value}
if is_preview is not None:
data["is_preview"] = is_preview
result_data = await client.update_service_env(uuid, data)
result = {
"success": True,
"message": f"Env var '{key}' updated",
"data": result_data,
}
return json.dumps(result, indent=2, ensure_ascii=False)
async def update_service_envs_bulk(client: CoolifyClient, uuid: str, data: list[dict]) -> str:
"""Bulk update service environment variables."""
result_data = await client.update_service_envs_bulk(uuid, data)
result = {
"success": True,
"message": f"Bulk update {len(data)} env vars",
"data": result_data,
}
return json.dumps(result, indent=2, ensure_ascii=False)
async def delete_service_env(client: CoolifyClient, uuid: str, env_uuid: str) -> str:
"""Delete service environment variable."""
await client.delete_service_env(uuid, env_uuid)
result = {"success": True, "message": f"Env var '{env_uuid}' deleted"}
return json.dumps(result, indent=2, ensure_ascii=False)

View File

@@ -18,8 +18,11 @@ class CoolifyPlugin(BasePlugin):
Provides Coolify deployment management capabilities including:
- Application management (CRUD, lifecycle, env vars, logs)
- Database management (PostgreSQL, MySQL, MariaDB, MongoDB, Redis, ClickHouse, backups)
- Deployment control (list, cancel, deploy by tag/UUID)
- Project & environment management (CRUD)
- Server management (CRUD, resources, domains, validation)
- Service management (CRUD, lifecycle, env vars)
"""
@staticmethod
@@ -60,8 +63,11 @@ class CoolifyPlugin(BasePlugin):
specs = []
specs.extend(handlers.applications.get_tool_specifications())
specs.extend(handlers.databases.get_tool_specifications())
specs.extend(handlers.deployments.get_tool_specifications())
specs.extend(handlers.projects.get_tool_specifications())
specs.extend(handlers.servers.get_tool_specifications())
specs.extend(handlers.services.get_tool_specifications())
return specs
@@ -77,8 +83,11 @@ class CoolifyPlugin(BasePlugin):
"""
handler_modules = [
handlers.applications,
handlers.databases,
handlers.deployments,
handlers.projects,
handlers.servers,
handlers.services,
]
for module in handler_modules:

View File

@@ -1,6 +1,6 @@
[project]
name = "mcphub-server"
version = "3.7.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"}

View File

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

View File

@@ -75,7 +75,7 @@ class TestCoolifyPlugin:
def test_tool_specifications(self):
"""Test that tool specifications are returned."""
specs = CoolifyPlugin.get_tool_specifications()
assert len(specs) == 30 # 17 apps + 5 deployments + 8 servers
assert len(specs) == 67 # 17 apps + 16 dbs + 5 deploys + 8 projects + 8 servers + 13 svcs
# Check all specs have required fields
for spec in specs:

View File

@@ -0,0 +1,237 @@
"""
Tests for Coolify Databases Handler
Unit tests with mocked HTTP responses.
"""
import json
from unittest.mock import AsyncMock
import pytest
from plugins.coolify.client import CoolifyClient
from plugins.coolify.handlers import databases
@pytest.fixture
def client():
"""Create a CoolifyClient instance for testing."""
return CoolifyClient(site_url="https://coolify.test.com", token="test-token-123")
class TestDatabaseToolSpecs:
"""Tests for database tool specifications."""
def test_database_tool_count(self):
"""Test database tool specification count."""
specs = databases.get_tool_specifications()
assert len(specs) == 16
def test_tool_specs_have_required_fields(self):
"""Test all specs have required fields."""
for spec in databases.get_tool_specifications():
assert "name" in spec
assert "method_name" in spec
assert "description" in spec
assert "schema" in spec
assert "scope" in spec
assert spec["scope"] in ("read", "write", "admin")
def test_tool_names_unique(self):
"""Test that all tool names are unique."""
specs = databases.get_tool_specifications()
names = [s["name"] for s in specs]
assert len(names) == len(set(names))
def test_all_db_types_present(self):
"""Test all 6 database creation tools exist."""
specs = databases.get_tool_specifications()
names = [s["name"] for s in specs]
for db_type in ["postgresql", "mysql", "mariadb", "mongodb", "redis", "clickhouse"]:
assert f"create_{db_type}" in names
def test_scope_distribution(self):
"""Test correct scope assignments."""
specs = databases.get_tool_specifications()
by_scope = {}
for s in specs:
by_scope.setdefault(s["scope"], []).append(s["name"])
assert "list_databases" in by_scope["read"]
assert "delete_database" in by_scope["admin"]
assert "start_database" in by_scope["write"]
class TestDatabaseHandlers:
"""Tests for database handler functions."""
@pytest.mark.asyncio
async def test_list_databases(self, client):
"""Test list_databases handler."""
mock_dbs = [
{"uuid": "db-1", "name": "postgres-main", "type": "postgresql", "status": "running"},
{"uuid": "db-2", "name": "redis-cache", "type": "redis", "status": "running"},
]
client.list_databases = AsyncMock(return_value=mock_dbs)
result = await databases.list_databases(client)
data = json.loads(result)
assert data["success"] is True
assert data["count"] == 2
client.list_databases.assert_called_once()
@pytest.mark.asyncio
async def test_get_database(self, client):
"""Test get_database handler."""
mock_db = {"uuid": "db-1", "name": "postgres-main", "type": "postgresql"}
client.get_database = AsyncMock(return_value=mock_db)
result = await databases.get_database(client, uuid="db-1")
data = json.loads(result)
assert data["success"] is True
assert data["database"]["name"] == "postgres-main"
client.get_database.assert_called_once_with("db-1")
@pytest.mark.asyncio
async def test_update_database(self, client):
"""Test update_database handler."""
mock_response = {"uuid": "db-1"}
client.update_database = AsyncMock(return_value=mock_response)
result = await databases.update_database(client, uuid="db-1", name="renamed-db")
data = json.loads(result)
assert data["success"] is True
assert "updated" in data["message"].lower()
client.update_database.assert_called_once_with("db-1", {"name": "renamed-db"})
@pytest.mark.asyncio
async def test_delete_database(self, client):
"""Test delete_database handler."""
mock_response = {"message": "Database deleted."}
client.delete_database = AsyncMock(return_value=mock_response)
result = await databases.delete_database(client, uuid="db-1")
data = json.loads(result)
assert data["success"] is True
assert "deleted" in data["message"].lower()
@pytest.mark.asyncio
async def test_start_database(self, client):
"""Test start_database handler."""
mock_response = {"message": "Starting."}
client.start_database = AsyncMock(return_value=mock_response)
result = await databases.start_database(client, uuid="db-1")
data = json.loads(result)
assert data["success"] is True
client.start_database.assert_called_once_with("db-1")
@pytest.mark.asyncio
async def test_stop_database(self, client):
"""Test stop_database handler."""
mock_response = {"message": "Stopping."}
client.stop_database = AsyncMock(return_value=mock_response)
result = await databases.stop_database(client, uuid="db-1")
data = json.loads(result)
assert data["success"] is True
client.stop_database.assert_called_once_with("db-1")
@pytest.mark.asyncio
async def test_restart_database(self, client):
"""Test restart_database handler."""
mock_response = {"message": "Restarting."}
client.restart_database = AsyncMock(return_value=mock_response)
result = await databases.restart_database(client, uuid="db-1")
data = json.loads(result)
assert data["success"] is True
client.restart_database.assert_called_once_with("db-1")
@pytest.mark.asyncio
async def test_create_postgresql(self, client):
"""Test create_postgresql handler."""
mock_response = {"uuid": "db-new", "type": "postgresql"}
client.create_database = AsyncMock(return_value=mock_response)
result = await databases.create_postgresql(
client,
project_uuid="proj-1",
server_uuid="srv-1",
environment_name="production",
)
data = json.loads(result)
assert data["success"] is True
assert "postgresql" in data["message"].lower()
client.create_database.assert_called_once_with(
"postgresql",
{
"project_uuid": "proj-1",
"server_uuid": "srv-1",
"environment_name": "production",
},
)
@pytest.mark.asyncio
async def test_create_redis(self, client):
"""Test create_redis handler."""
mock_response = {"uuid": "db-new", "type": "redis"}
client.create_database = AsyncMock(return_value=mock_response)
result = await databases.create_redis(
client,
project_uuid="proj-1",
server_uuid="srv-1",
environment_name="production",
name="my-cache",
)
data = json.loads(result)
assert data["success"] is True
assert "redis" in data["message"].lower()
@pytest.mark.asyncio
async def test_get_database_backups(self, client):
"""Test get_database_backups handler."""
mock_backups = {"enabled": True, "frequency": "0 0 * * *", "backups": []}
client.get_database_backups = AsyncMock(return_value=mock_backups)
result = await databases.get_database_backups(client, uuid="db-1")
data = json.loads(result)
assert data["success"] is True
client.get_database_backups.assert_called_once_with("db-1")
@pytest.mark.asyncio
async def test_create_database_backup(self, client):
"""Test create_database_backup handler."""
mock_response = {"message": "Backup started."}
client.create_database_backup = AsyncMock(return_value=mock_response)
result = await databases.create_database_backup(client, uuid="db-1")
data = json.loads(result)
assert data["success"] is True
assert "backup" in data["message"].lower()
@pytest.mark.asyncio
async def test_list_backup_executions(self, client):
"""Test list_backup_executions handler."""
mock_executions = [
{"id": 1, "database_uuid": "db-1", "status": "success"},
{"id": 2, "database_uuid": "db-1", "status": "failed"},
]
client.list_backup_executions = AsyncMock(return_value=mock_executions)
result = await databases.list_backup_executions(client)
data = json.loads(result)
assert data["success"] is True
assert data["count"] == 2

View File

@@ -0,0 +1,214 @@
"""
Tests for Coolify Projects & Environments Handler
Unit tests with mocked HTTP responses.
"""
import json
from unittest.mock import AsyncMock
import pytest
from plugins.coolify.client import CoolifyClient
from plugins.coolify.handlers import projects
@pytest.fixture
def client():
"""Create a CoolifyClient instance for testing."""
return CoolifyClient(site_url="https://coolify.test.com", token="test-token-123")
class TestProjectToolSpecs:
"""Tests for project tool specifications."""
def test_project_tool_count(self):
"""Test project tool specification count."""
specs = projects.get_tool_specifications()
assert len(specs) == 8
def test_tool_specs_have_required_fields(self):
"""Test all specs have required fields."""
for spec in projects.get_tool_specifications():
assert "name" in spec
assert "method_name" in spec
assert "description" in spec
assert "schema" in spec
assert "scope" in spec
assert spec["scope"] in ("read", "write", "admin")
def test_tool_names_unique(self):
"""Test that all tool names are unique."""
specs = projects.get_tool_specifications()
names = [s["name"] for s in specs]
assert len(names) == len(set(names))
def test_scope_distribution(self):
"""Test correct scope assignments."""
specs = projects.get_tool_specifications()
by_scope = {}
for s in specs:
by_scope.setdefault(s["scope"], []).append(s["name"])
assert "list_projects" in by_scope["read"]
assert "get_project" in by_scope["read"]
assert "create_project" in by_scope["write"]
assert "delete_project" in by_scope["admin"]
class TestProjectHandlers:
"""Tests for project handler functions."""
@pytest.mark.asyncio
async def test_list_projects(self, client):
"""Test list_projects handler."""
mock_projects = [
{"uuid": "proj-1", "name": "mcphub", "description": "MCP Hub project"},
{"uuid": "proj-2", "name": "blog", "description": "Blog project"},
]
client.list_projects = AsyncMock(return_value=mock_projects)
result = await projects.list_projects(client)
data = json.loads(result)
assert data["success"] is True
assert data["count"] == 2
assert len(data["projects"]) == 2
client.list_projects.assert_called_once()
@pytest.mark.asyncio
async def test_get_project(self, client):
"""Test get_project handler."""
mock_project = {
"uuid": "proj-1",
"name": "mcphub",
"description": "MCP Hub project",
"environments": [{"name": "production"}],
}
client.get_project = AsyncMock(return_value=mock_project)
result = await projects.get_project(client, uuid="proj-1")
data = json.loads(result)
assert data["success"] is True
assert data["project"]["name"] == "mcphub"
client.get_project.assert_called_once_with("proj-1")
@pytest.mark.asyncio
async def test_create_project(self, client):
"""Test create_project handler."""
mock_response = {"uuid": "proj-new", "name": "new-project"}
client.create_project = AsyncMock(return_value=mock_response)
result = await projects.create_project(
client, name="new-project", description="A test project"
)
data = json.loads(result)
assert data["success"] is True
assert "created" in data["message"].lower()
client.create_project.assert_called_once_with(
{"name": "new-project", "description": "A test project"}
)
@pytest.mark.asyncio
async def test_create_project_no_description(self, client):
"""Test create_project without description."""
mock_response = {"uuid": "proj-new"}
client.create_project = AsyncMock(return_value=mock_response)
result = await projects.create_project(client, name="minimal")
data = json.loads(result)
assert data["success"] is True
client.create_project.assert_called_once_with({"name": "minimal"})
@pytest.mark.asyncio
async def test_update_project(self, client):
"""Test update_project handler."""
mock_response = {"uuid": "proj-1"}
client.update_project = AsyncMock(return_value=mock_response)
result = await projects.update_project(client, uuid="proj-1", name="renamed")
data = json.loads(result)
assert data["success"] is True
assert "updated" in data["message"].lower()
client.update_project.assert_called_once_with("proj-1", {"name": "renamed"})
@pytest.mark.asyncio
async def test_delete_project(self, client):
"""Test delete_project handler."""
mock_response = {"message": "Project deleted."}
client.delete_project = AsyncMock(return_value=mock_response)
result = await projects.delete_project(client, uuid="proj-1")
data = json.loads(result)
assert data["success"] is True
assert "deleted" in data["message"].lower()
client.delete_project.assert_called_once_with("proj-1")
class TestEnvironmentHandlers:
"""Tests for environment handler functions."""
@pytest.mark.asyncio
async def test_list_environments(self, client):
"""Test list_environments handler."""
mock_envs = [
{"name": "production", "id": 1},
{"name": "staging", "id": 2},
]
client.list_environments = AsyncMock(return_value=mock_envs)
result = await projects.list_environments(client, project_uuid="proj-1")
data = json.loads(result)
assert data["success"] is True
assert data["count"] == 2
assert len(data["environments"]) == 2
client.list_environments.assert_called_once_with("proj-1")
@pytest.mark.asyncio
async def test_get_environment(self, client):
"""Test get_environment handler."""
mock_env = {"name": "production", "id": 1, "project_id": 5}
client.get_environment = AsyncMock(return_value=mock_env)
result = await projects.get_environment(
client, project_uuid="proj-1", environment_name="production"
)
data = json.loads(result)
assert data["success"] is True
assert data["environment"]["name"] == "production"
client.get_environment.assert_called_once_with("proj-1", "production")
@pytest.mark.asyncio
async def test_create_environment(self, client):
"""Test create_environment handler."""
mock_response = {"name": "staging", "id": 3}
client.create_environment = AsyncMock(return_value=mock_response)
result = await projects.create_environment(
client, project_uuid="proj-1", name="staging", description="Staging env"
)
data = json.loads(result)
assert data["success"] is True
assert "staging" in data["message"]
client.create_environment.assert_called_once_with(
"proj-1", {"name": "staging", "description": "Staging env"}
)
@pytest.mark.asyncio
async def test_create_environment_no_description(self, client):
"""Test create_environment without description."""
mock_response = {"name": "test"}
client.create_environment = AsyncMock(return_value=mock_response)
result = await projects.create_environment(client, project_uuid="proj-1", name="test")
data = json.loads(result)
assert data["success"] is True
client.create_environment.assert_called_once_with("proj-1", {"name": "test"})

View File

@@ -0,0 +1,240 @@
"""
Tests for Coolify Services Handler
Unit tests with mocked HTTP responses.
"""
import json
from unittest.mock import AsyncMock
import pytest
from plugins.coolify.client import CoolifyClient
from plugins.coolify.handlers import services
@pytest.fixture
def client():
"""Create a CoolifyClient instance for testing."""
return CoolifyClient(site_url="https://coolify.test.com", token="test-token-123")
class TestServiceToolSpecs:
"""Tests for service tool specifications."""
def test_service_tool_count(self):
"""Test service tool specification count."""
specs = services.get_tool_specifications()
assert len(specs) == 13
def test_tool_specs_have_required_fields(self):
"""Test all specs have required fields."""
for spec in services.get_tool_specifications():
assert "name" in spec
assert "method_name" in spec
assert "description" in spec
assert "schema" in spec
assert "scope" in spec
assert spec["scope"] in ("read", "write", "admin")
def test_tool_names_unique(self):
"""Test that all tool names are unique."""
specs = services.get_tool_specifications()
names = [s["name"] for s in specs]
assert len(names) == len(set(names))
def test_scope_distribution(self):
"""Test correct scope assignments."""
specs = services.get_tool_specifications()
by_scope = {}
for s in specs:
by_scope.setdefault(s["scope"], []).append(s["name"])
assert "list_services" in by_scope["read"]
assert "list_service_envs" in by_scope["read"]
assert "delete_service" in by_scope["admin"]
assert "start_service" in by_scope["write"]
assert "create_service_env" in by_scope["write"]
class TestServiceHandlers:
"""Tests for service handler functions."""
@pytest.mark.asyncio
async def test_list_services(self, client):
"""Test list_services handler."""
mock_services = [
{"uuid": "svc-1", "name": "plausible", "status": "running"},
{"uuid": "svc-2", "name": "minio", "status": "stopped"},
]
client.list_services = AsyncMock(return_value=mock_services)
result = await services.list_services(client)
data = json.loads(result)
assert data["success"] is True
assert data["count"] == 2
client.list_services.assert_called_once()
@pytest.mark.asyncio
async def test_get_service(self, client):
"""Test get_service handler."""
mock_service = {"uuid": "svc-1", "name": "plausible", "status": "running"}
client.get_service = AsyncMock(return_value=mock_service)
result = await services.get_service(client, uuid="svc-1")
data = json.loads(result)
assert data["success"] is True
assert data["service"]["name"] == "plausible"
client.get_service.assert_called_once_with("svc-1")
@pytest.mark.asyncio
async def test_create_service(self, client):
"""Test create_service handler."""
mock_response = {"uuid": "svc-new"}
client.create_service = AsyncMock(return_value=mock_response)
result = await services.create_service(
client,
project_uuid="proj-1",
server_uuid="srv-1",
environment_name="production",
type="plausible-analytics",
name="my-plausible",
)
data = json.loads(result)
assert data["success"] is True
assert "created" in data["message"].lower()
@pytest.mark.asyncio
async def test_update_service(self, client):
"""Test update_service handler."""
mock_response = {"uuid": "svc-1"}
client.update_service = AsyncMock(return_value=mock_response)
result = await services.update_service(client, uuid="svc-1", name="renamed")
data = json.loads(result)
assert data["success"] is True
assert "updated" in data["message"].lower()
client.update_service.assert_called_once_with("svc-1", {"name": "renamed"})
@pytest.mark.asyncio
async def test_delete_service(self, client):
"""Test delete_service handler."""
mock_response = {"message": "Service deleted."}
client.delete_service = AsyncMock(return_value=mock_response)
result = await services.delete_service(client, uuid="svc-1")
data = json.loads(result)
assert data["success"] is True
assert "deleted" in data["message"].lower()
@pytest.mark.asyncio
async def test_start_service(self, client):
"""Test start_service handler."""
mock_response = {"message": "Starting."}
client.start_service = AsyncMock(return_value=mock_response)
result = await services.start_service(client, uuid="svc-1")
data = json.loads(result)
assert data["success"] is True
client.start_service.assert_called_once_with("svc-1")
@pytest.mark.asyncio
async def test_stop_service(self, client):
"""Test stop_service handler."""
mock_response = {"message": "Stopping."}
client.stop_service = AsyncMock(return_value=mock_response)
result = await services.stop_service(client, uuid="svc-1")
data = json.loads(result)
assert data["success"] is True
client.stop_service.assert_called_once_with("svc-1")
@pytest.mark.asyncio
async def test_restart_service(self, client):
"""Test restart_service handler."""
mock_response = {"message": "Restarting."}
client.restart_service = AsyncMock(return_value=mock_response)
result = await services.restart_service(client, uuid="svc-1")
data = json.loads(result)
assert data["success"] is True
client.restart_service.assert_called_once_with("svc-1")
class TestServiceEnvHandlers:
"""Tests for service environment variable handlers."""
@pytest.mark.asyncio
async def test_list_service_envs(self, client):
"""Test list_service_envs handler."""
mock_envs = [
{"key": "DATABASE_URL", "value": "postgres://..."},
{"key": "SECRET", "value": "***"},
]
client.list_service_envs = AsyncMock(return_value=mock_envs)
result = await services.list_service_envs(client, uuid="svc-1")
data = json.loads(result)
assert data["success"] is True
assert data["count"] == 2
@pytest.mark.asyncio
async def test_create_service_env(self, client):
"""Test create_service_env handler."""
mock_response = {"uuid": "env-123"}
client.create_service_env = AsyncMock(return_value=mock_response)
result = await services.create_service_env(
client, uuid="svc-1", key="NEW_VAR", value="test"
)
data = json.loads(result)
assert data["success"] is True
assert "NEW_VAR" in data["message"]
@pytest.mark.asyncio
async def test_update_service_env(self, client):
"""Test update_service_env handler."""
mock_response = {"uuid": "env-123"}
client.update_service_env = AsyncMock(return_value=mock_response)
result = await services.update_service_env(
client, uuid="svc-1", key="EXISTING_VAR", value="new_val"
)
data = json.loads(result)
assert data["success"] is True
assert "EXISTING_VAR" in data["message"]
@pytest.mark.asyncio
async def test_update_service_envs_bulk(self, client):
"""Test update_service_envs_bulk handler."""
mock_response = {"message": "Updated."}
client.update_service_envs_bulk = AsyncMock(return_value=mock_response)
env_data = [{"key": "A", "value": "1"}, {"key": "B", "value": "2"}]
result = await services.update_service_envs_bulk(client, uuid="svc-1", data=env_data)
data = json.loads(result)
assert data["success"] is True
assert "2" in data["message"]
@pytest.mark.asyncio
async def test_delete_service_env(self, client):
"""Test delete_service_env handler."""
client.delete_service_env = AsyncMock(return_value=None)
result = await services.delete_service_env(client, uuid="svc-1", env_uuid="env-123")
data = json.loads(result)
assert data["success"] is True
client.delete_service_env.assert_called_once_with("svc-1", "env-123")

View File

@@ -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"]

View File

@@ -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

View File

@@ -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."""

View File

@@ -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"]

View File

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

364
tests/test_tool_access.py Normal file
View File

@@ -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

View File

@@ -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