4 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
151ba8e6e6 feat: v3.7.0 — Coolify MCP plugin (30 tools)
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
New plugin: Coolify deployment management (applications, deployments, servers).
10 plugins, 596 tools, 686 tests passing.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-02 18:55:01 +02:00
46 changed files with 7608 additions and 69 deletions

View File

@@ -4,7 +4,7 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
## Project Overview ## Project Overview
**MCP Hub** — a Python MCP (Model Context Protocol) server that manages multiple self-hosted services through a unified plugin architecture. Supports 9 plugin types (WordPress, WooCommerce, WordPress Advanced, Gitea, n8n, Supabase, OpenPanel, Appwrite, Directus) with 567 tools total. The tool count stays constant regardless of how many sites are configured. **MCP Hub** — a Python MCP (Model Context Protocol) server that manages multiple self-hosted services through a unified plugin architecture. Supports 10 plugin types (WordPress, WooCommerce, WordPress Advanced, Gitea, n8n, Supabase, OpenPanel, Appwrite, Directus, Coolify) with 597 tools total. The tool count stays constant regardless of how many sites are configured.
## Quick Setup ## Quick Setup
@@ -116,7 +116,7 @@ plugins/{name}/
└── schemas/ # Pydantic models for validation └── schemas/ # Pydantic models for validation
``` ```
**Registered plugins**: wordpress, woocommerce, wordpress_advanced, gitea, n8n, supabase, openpanel, appwrite, directus **Registered plugins**: wordpress, woocommerce, wordpress_advanced, gitea, n8n, supabase, openpanel, appwrite, directus, coolify
**Plugin visibility** (Track F.1): Public users only see plugins listed in `ENABLED_PLUGINS` env var (default: `wordpress,woocommerce,supabase`). Admin sees all. Controlled by `core/plugin_visibility.py`. **Plugin visibility** (Track F.1): Public users only see plugins listed in `ENABLED_PLUGINS` env var (default: `wordpress,woocommerce,supabase`). Admin sees all. Controlled by `core/plugin_visibility.py`.

View File

@@ -160,6 +160,7 @@ DASHBOARD_TRANSLATIONS = {
"admin_login": "Admin Login with API Key", "admin_login": "Admin Login with API Key",
"profile": "Profile", "profile": "Profile",
"admin_badge": "Admin", "admin_badge": "Admin",
"keys": "API Keys",
}, },
"fa": { "fa": {
# Navigation # Navigation
@@ -265,6 +266,7 @@ DASHBOARD_TRANSLATIONS = {
"admin_login": "ورود مدیر با کلید API", "admin_login": "ورود مدیر با کلید API",
"profile": "پروفایل", "profile": "پروفایل",
"admin_badge": "مدیر", "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) # 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) 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: async def api_get_config(request: Request) -> Response:
"""GET /api/config/{alias} — Get config snippets for a site.""" """GET /api/config/{alias} — Get config snippets for a site."""
user_session, redirect = _require_user_session(request) user_session, redirect = _require_user_session(request)
@@ -3471,8 +3776,11 @@ def register_dashboard_routes(mcp):
dashboard_project_detail dashboard_project_detail
) )
# API Keys routes # API Keys routes (unified — /dashboard/keys replaces /dashboard/api-keys and /dashboard/connect)
mcp.custom_route("/dashboard/api-keys", methods=["GET"])(dashboard_api_keys_list) 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 # OAuth Clients routes
mcp.custom_route("/dashboard/oauth-clients", methods=["GET"])(dashboard_oauth_clients_list) 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", methods=["GET"])(dashboard_sites_list)
mcp.custom_route("/dashboard/sites/add", methods=["GET"])(dashboard_sites_add) 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/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) # Service pages (F.3)
mcp.custom_route("/dashboard/services", methods=["GET"])(dashboard_services_list) 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" _DEFAULT_DATA_DIR = "/app/data" if Path("/app").exists() else "./data"
# Schema version — increment when adding migrations # Schema version — increment when adding migrations
SCHEMA_VERSION = 5 SCHEMA_VERSION = 7
# Initial schema DDL # Initial schema DDL
_SCHEMA_SQL = """\ _SCHEMA_SQL = """\
@@ -67,6 +67,7 @@ CREATE TABLE IF NOT EXISTS sites (
status_msg TEXT, status_msg TEXT,
last_health TEXT, last_health TEXT,
last_tested_at TEXT, last_tested_at TEXT,
tool_scope TEXT NOT NULL DEFAULT 'admin',
created_at TEXT NOT NULL, created_at TEXT NOT NULL,
UNIQUE(user_id, alias) UNIQUE(user_id, alias)
); );
@@ -100,6 +101,18 @@ CREATE TABLE IF NOT EXISTS schema_version (
version INTEGER PRIMARY KEY, version INTEGER PRIMARY KEY,
applied_at TEXT NOT NULL 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 # Migration registry: version -> SQL string
@@ -120,6 +133,38 @@ _MIGRATIONS: dict[int, str] = {
" updated_at TEXT NOT NULL DEFAULT (datetime('now'))\n" " updated_at TEXT NOT NULL DEFAULT (datetime('now'))\n"
");\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), (_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 # Module-level helpers

View File

@@ -207,6 +207,15 @@ PLUGIN_CREDENTIAL_FIELDS: dict[str, list[dict[str, Any]]] = {
"hint": "Directus → Settings → User → Static Token", "hint": "Directus → Settings → User → Static Token",
}, },
], ],
"coolify": [
{
"name": "token",
"label": "API Token",
"type": "password",
"required": True,
"hint": "Coolify → Keys & Tokens → API tokens → Create",
},
],
} }
# Plugin display names for UI # Plugin display names for UI
@@ -220,6 +229,7 @@ PLUGIN_DISPLAY_NAMES: dict[str, str] = {
"openpanel": "OpenPanel", "openpanel": "OpenPanel",
"appwrite": "Appwrite", "appwrite": "Appwrite",
"directus": "Directus", "directus": "Directus",
"coolify": "Coolify",
} }
# Health check endpoints per plugin type # Health check endpoints per plugin type
@@ -233,6 +243,7 @@ _HEALTH_ENDPOINTS: dict[str, dict[str, Any]] = {
"openpanel": {"path": "/healthcheck", "method": "GET"}, "openpanel": {"path": "/healthcheck", "method": "GET"},
"appwrite": {"path": "/v1/health", "method": "GET"}, "appwrite": {"path": "/v1/health", "method": "GET"},
"directus": {"path": "/server/health", "method": "GET"}, "directus": {"path": "/server/health", "method": "GET"},
"coolify": {"path": "/api/v1/version", "method": "GET"},
} }
@@ -354,7 +365,7 @@ async def validate_site_connection(
elif plugin_type == "appwrite": elif plugin_type == "appwrite":
headers["X-Appwrite-Project"] = credentials.get("project_id", "") headers["X-Appwrite-Project"] = credentials.get("project_id", "")
headers["X-Appwrite-Key"] = credentials.get("api_key", "") headers["X-Appwrite-Key"] = credentials.get("api_key", "")
elif plugin_type == "directus": elif plugin_type in ("directus", "coolify"):
headers["Authorization"] = f"Bearer {credentials.get('token', '')}" headers["Authorization"] = f"Bearer {credentials.get('token', '')}"
elif plugin_type == "openpanel": elif plugin_type == "openpanel":
headers["openpanel-client-id"] = credentials.get("client_id", "") headers["openpanel-client-id"] = credentials.get("client_id", "")

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', 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'), '/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'), ('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 ('keys', t.get('keys', 'API Keys'), 'M15 7a2 2 0 012 2m4 0a6 6 0 01-7.743 5.743L11 17H9v2H7v2H4a1 1 0
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'), 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 ('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 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'), 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> </div>
</form> </form>
</div> </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 %} {% endblock %}
{% block scripts %} {% block scripts %}
@@ -168,5 +209,162 @@
btn.disabled = false; 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> </script>
{% endblock %} {% endblock %}

View File

@@ -80,6 +80,10 @@
id="test-btn-{{ site.id }}"> id="test-btn-{{ site.id }}">
{{ t.test_connection }} {{ t.test_connection }}
</button> </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 %}" <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"> class="text-sm text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white">
{{ t.edit }} {{ 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"] description = spec["description"]
schema = spec["schema"] schema = spec["schema"]
scope = spec.get("scope", "read") 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 # Create full tool name
tool_name = f"{plugin_type}_{action_name}" tool_name = f"{plugin_type}_{action_name}"
@@ -202,6 +205,8 @@ class ToolGenerator:
handler=handler, handler=handler,
required_scope=scope, required_scope=scope,
plugin_type=plugin_type, plugin_type=plugin_type,
category=category,
sensitivity=sensitivity,
) )
def _add_site_parameter( def _add_site_parameter(

View File

@@ -27,6 +27,10 @@ class ToolDefinition(BaseModel):
handler: Async function that executes the tool handler: Async function that executes the tool
required_scope: Required API key scope ("read", "write", "admin") required_scope: Required API key scope ("read", "write", "admin")
plugin_type: Plugin type this tool belongs to (e.g., "wordpress") 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") name: str = Field(..., description="Unique tool identifier")
@@ -40,6 +44,14 @@ class ToolDefinition(BaseModel):
default="read", description="Required API key scope (read/write/admin)" default="read", description="Required API key scope (read/write/admin)"
) )
plugin_type: str = Field(..., description="Plugin type (wordpress, gitea, etc)") 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 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.requests import Request
from starlette.responses import JSONResponse, Response from starlette.responses import JSONResponse, Response
from core.tool_registry import ToolDefinition
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
# Per-user rate limiting defaults # 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 # In-memory rate limit tracking: user_id -> list of timestamps
_rate_limits: dict[str, list[float]] = {} _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]: def _check_user_rate_limit(user_id: str) -> tuple[bool, str]:
"""Check per-user rate limits. """Check per-user rate limits.
@@ -71,24 +70,15 @@ def _check_user_rate_limit(user_id: str) -> tuple[bool, str]:
return True, "" return True, ""
def _get_tools_for_plugin(plugin_type: str) -> list[dict[str, Any]]: def _tools_to_mcp_schema(tools: list[ToolDefinition]) -> list[dict[str, Any]]:
"""Get MCP tool definitions for a plugin type (cached). """Convert ToolDefinition objects into MCP ``tools/list`` response shape.
Returns tool schemas with the ``site`` parameter removed Strips the auto-injected ``site`` parameter, since user endpoints bind a
(auto-injected for user endpoints). 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 = [] result = []
for tool_def in tools: for tool_def in tools:
schema = deepcopy(tool_def.input_schema) schema = deepcopy(tool_def.input_schema)
# Remove 'site' parameter (auto-injected)
if "properties" in schema: if "properties" in schema:
schema["properties"].pop("site", None) schema["properties"].pop("site", None)
if "required" in schema and "site" in schema["required"]: 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, "inputSchema": schema,
} }
) )
_tool_schema_cache[plugin_type] = result
return 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( async def _execute_tool(
tool_name: str, tool_name: str,
arguments: dict[str, Any], arguments: dict[str, Any],
@@ -353,7 +356,7 @@ async def user_mcp_handler(request: Request) -> Response:
return Response(status_code=204) return Response(status_code=204)
elif method == "tools/list": 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})) return JSONResponse(_jsonrpc_result(req_id, {"tools": tools}))
elif method == "tools/call": elif method == "tools/call":
@@ -378,19 +381,56 @@ async def user_mcp_handler(request: Request) -> Response:
required_scope = tool_def.required_scope required_scope = tool_def.required_scope
# key_scopes is set during authentication (both mhu_ and JWT paths) # 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} scope_hierarchy = {"read": 1, "write": 2, "admin": 3}
required_level = scope_hierarchy.get(required_scope, 0) required_level = scope_hierarchy.get(required_scope, 0)
key_level = max([scope_hierarchy.get(s, 0) for s in key_scopes] + [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( return JSONResponse(
_jsonrpc_error( _jsonrpc_error(
req_id, req_id,
-32600, -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 # Decrypt credentials
try: try:
from core.encryption import get_credential_encryption from core.encryption import get_credential_encryption

View File

@@ -0,0 +1,95 @@
# Next Session — F.17 Coolify MCP Plugin (MVP)
> 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, branch Phase-1) ← فاز F.17
- CLAUDE.md (mcphub-internal)
- plugins/gitea/ (mcphub-internal) ← الگوی مرجع (آخرین پلاگین ساخته‌شده)
- plugins/base.py (mcphub-internal) ← BasePlugin interface
## وضعیت فعلی
- MCPHub: v3.6.0 — 566 ابزار، 5 پلاگین عمومی
- FastMCP: >=3.0.0,<4.0.0
- CI سبز
- طرح Coolify: نوشته شده — ~68 ابزار در 6 handler
## ریپازیتوری
- MCPHub (internal): `/config/workspace/mcphub-internal` (branch Phase-1)
## هدف session: F.17 Phase 1 — Coolify MVP
### پیش‌نیاز اول: Coolify API Token
- [ ] در داشبورد Coolify، بخش Keys & Tokens، یک API token بساز
- [ ] تست اتصال: `curl -s https://COOLIFY_URL/api/v1/version -H "Authorization: Bearer TOKEN"`
- [ ] اگر URL و TOKEN مشخص نبود، از کاربر بپرس
### مرحله ۱: ساختار اولیه پلاگین
- [ ] `plugins/coolify/__init__.py` (خالی)
- [ ] `plugins/coolify/client.py` — CoolifyClient با Bearer Token auth
- الگو از `plugins/gitea/client.py` بگیر
- متدها: `request()`, `get()`, `post()`, `patch()`, `delete()`
- Error handling + retry مشابه Gitea client
- [ ] `plugins/coolify/plugin.py` — CoolifyPlugin(BasePlugin)
- الگو از `plugins/gitea/plugin.py` بگیر
- `get_tool_specifications()` باید ابزارهای handler ها رو جمع کنه
- [ ] `plugins/coolify/handlers/__init__.py`
- [ ] `plugins/coolify/schemas/__init__.py`
- [ ] `plugins/coolify/schemas/common.py` — مدل‌های مشترک (UUID, pagination)
### مرحله ۲: Handler — Applications (18 ابزار)
- [ ] `plugins/coolify/handlers/applications.py`
- [ ] ابزارها (از طرح):
- list_applications, get_application
- create_application_public, create_application_dockerfile, create_application_docker_image, create_application_compose
- update_application, delete_application
- start_application, stop_application, restart_application
- get_application_logs
- list_application_envs, create_application_env, update_application_env, update_application_envs_bulk, delete_application_env
- [ ] schemas/application.py — Pydantic models
### مرحله ۳: Handler — Deployments (5 ابزار)
- [ ] `plugins/coolify/handlers/deployments.py`
- [ ] ابزارها: list_deployments, get_deployment, cancel_deployment, deploy, list_app_deployments
### مرحله ۴: Handler — Servers (8 ابزار)
- [ ] `plugins/coolify/handlers/servers.py`
- [ ] ابزارها: list_servers, get_server, create_server, update_server, delete_server, get_server_resources, get_server_domains, validate_server
- [ ] schemas/server.py
### مرحله ۵: ثبت پلاگین و تنظیمات
- [ ] در `plugins/__init__.py` اضافه کن: `from plugins.coolify.plugin import CoolifyPlugin` + `registry.register("coolify", CoolifyPlugin)`
- [ ] در `env.example` اضافه کن: `COOLIFY_URL`, `COOLIFY_TOKEN`
- [ ] پلاگین فعلا admin-only باشد (به ENABLED_PLUGINS اضافه نشود)
### مرحله ۶: تست
- [ ] `tests/test_coolify.py` — Unit tests با mocked HTTP
- الگو از `tests/test_gitea*.py` بگیر
- حداقل: test_list_applications, test_get_application, test_deploy, test_list_servers
- [ ] `pytest tests/test_coolify.py -v`
- [ ] اگر API Token موجود بود: integration test با Coolify واقعی
### مرحله ۷: تست نهایی و commit
- [ ] `uvx --python 3.12 black .`
- [ ] `uvx ruff check --fix .`
- [ ] `pytest` (همه تست‌ها سبز)
- [ ] Commit: `feat(F.17): add Coolify MCP plugin — Phase 1 MVP (~31 tools)`
- [ ] Push to Phase-1
## قوانین
- اول پلن بده، بدون تایید شروع نکن
- از Gitea plugin به عنوان الگوی اصلی استفاده کن (آخرین و تمیزترین پلاگین)
- هر مرحله commit شود
- tool specifications باید دقیقا مطابق فرمت BasePlugin باشند (name, method_name, description, schema, scope)
- scope ها: read, write, admin — مطابق جدول در طرح
- حافظه آپدیت شود بعد از اتمام
- ایمیل git داخلی: mcphub.dev@gmail.com
```

View File

@@ -0,0 +1,77 @@
# Next Session — Coolify MCP Plugin Testing & Next Steps
> Session prompt. Copy and paste into a new Claude Code conversation.
---
## Prompt:
```
از مهارت project-ops برای آشنایی با محیط استفاده کن. حافظه و فایل‌های مرجع را بررسی کن.
## هدف: تست پلاگین Coolify MCP و مراحل بعدی
### پیش‌نیاز
- MCPHub redeploy شده و سایت Coolify اضافه شده
- MCP endpoint `mcphub-coolify` در `.claude.json` تنظیم شده
- پلاگین 30 ابزار دارد (17 application + 5 deployment + 8 server)
### مرحله ۱: تأیید اتصال MCP
- [ ] لیست ابزارهای coolify را از MCP بگیر (باید 30 ابزار باشد)
- [ ] اگر ابزار coolify در لیست نبود، `.claude.json` و `settings.json` را بررسی کن
### مرحله ۲: تست ابزارهای read
- [ ] `coolify_list_servers` — لیست سرورها
- [ ] `coolify_list_applications` — لیست اپلیکیشن‌ها
- [ ] `coolify_list_deployments` — لیست دیپلویمنت‌های در حال اجرا
- [ ] `coolify_get_server_resources(uuid=SERVER_UUID)` — منابع سرور
- [ ] `coolify_get_server_domains(uuid=SERVER_UUID)` — دامنه‌های سرور
- [ ] یک اپلیکیشن انتخاب کن و:
- [ ] `coolify_get_application(uuid=APP_UUID)` — جزئیات
- [ ] `coolify_get_application_logs(uuid=APP_UUID, lines=50)` — لاگ‌ها
- [ ] `coolify_list_application_envs(uuid=APP_UUID)` — متغیرهای محیطی
- [ ] `coolify_list_app_deployments(uuid=APP_UUID)` — تاریخچه دیپلوی
### مرحله ۳: تست ابزارهای write (با احتیاط)
- [ ] از کاربر بپرس آیا مجاز است یک env var تست ایجاد/حذف کند
- [ ] اگر بله:
- [ ] `coolify_create_application_env(uuid=APP_UUID, key="TEST_VAR", value="test123")`
- [ ] `coolify_delete_application_env(uuid=APP_UUID, env_uuid=...)`
### مرحله ۴: گزارش نتایج
- [ ] خلاصه نتایج تست (چند ابزار کار کرد، مشکلات)
- [ ] مقایسه با ابزارهای Gitea و Supabase MCP (کیفیت پاسخ‌ها)
### مرحله ۵: اگر تست موفق بود — Sync به نسخه عمومی
- [ ] `python3.11 scripts/community-build/sync.py --output ../mcphub/`
- [ ] `cd /config/workspace/mcphub && uvx --python 3.12 black . && uvx ruff check --fix .`
- [ ] تست‌ها: `python3.11 -m pytest tests/ -q`
- [ ] Commit و push نسخه عمومی
### مرحله ۶: آپدیت‌ها
- [ ] mcp-skills/skills/coolify/SKILL.md — از planned به active تغییر کرده (بررسی شود)
- [ ] حافظه آپدیت شود
## پیشنهاد مراحل بعدی (بعد از تست)
### فوری
1. **F.17 Phase 2**: اضافه کردن databases (16 ابزار) + services (13 ابزار) → ~59 ابزار کل
2. **F.17 Phase 3**: projects (8 ابزار) → ~67 ابزار کل — تکمیل طرح اصلی
### میان‌مدت
3. **Coolify Workflow Skill**: مهارت اختصاصی برای عملیات متداول (deploy all, backup all, health check all)
4. **Integration Test**: تست خودکار با Coolify واقعی (pytest mark integration)
5. **F.14a**: ثبت MCPHub در Smithery + Official MCP Registry
### بلندمدت
6. **Blog Post**: نوشتن مقاله درباره "Self-hosted MCP Hub with Coolify Integration"
7. **F.5a**: Base64 media upload برای WordPress
8. **F.6**: Claude Code skills بومی
## قوانین
- اول ابزارهای read تست شوند، بعد write
- قبل از هر عملیات write از کاربر تأیید بگیر
- نتایج تست دقیق گزارش شود (UUID ها، خطاها، زمان پاسخ)
- حافظه آپدیت شود بعد از اتمام
- ایمیل git داخلی: mcphub.dev@gmail.com
```

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

@@ -0,0 +1,93 @@
# Next Session — Registry + MCP Skills + Infrastructure
> Session prompt. Copy and paste into a new Claude Code conversation.
---
## Prompt:
```
از مهارت project-ops برای آشنایی با محیط استفاده کن. حافظه و فایل‌های مرجع را بررسی کن.
## فایل‌های مرجع
- docs/plans/2026-03-25-v4-development-cycle.md (mcphub-internal, branch Phase-1)
- CLAUDE.md (mcphub-internal)
- CHANGELOG.md (mcphub-internal)
## وضعیت فعلی
- MCPHub: v3.6.0 — 567 ابزار، 5 پلاگین عمومی (WordPress, WooCommerce, Supabase, OpenPanel, Gitea)
- FastMCP: >=3.0.0,<4.0.0
- CI سبز
## MCP Endpoints فعال
- mcphub-supabase: 70 ابزار Supabase (DB, auth, storage)
- mcphub-gitea: 58 ابزار Gitea (repos, issues, PRs, webhooks)
## ریپازیتوری‌ها
### GitHub (airano-ir)
- mcphub (public) → /config/workspace/mcphub
- mcphub (private, Phase-1) → /config/workspace/mcphub-internal
- mcp-skills (private, خالی) → /config/workspace/mcp-skills
- skillhub-internal → /config/workspace/skillhub-internal
- skillhub (public) → /config/workspace/skillhub
### Gitea (atlatl @ gitea.example.com)
- polymarket (private) → /config/workspace/polymarket
- polymarket-skill (private) → /config/workspace/polymarket-skill
- project-ops (private) → مهارت در /config/.claude/skills/project-ops/
## اهداف این session (به ترتیب اولویت)
### 1. Registry Submissions (F.14a)
وضعیت فعلی:
- Glama: ثبت شده (Score: A)، glama.json اضافه شده
- awesome-mcp-servers: PR #2147 باز — badge SVG + tool count اصلاح شده، منتظر merge
- Smithery.ai: ثبت نشده → از smithery.ai/new ثبت کن (نیاز به public HTTPS URL: mcp.example.com)
- Official MCP Registry: ثبت نشده → بررسی `mcp-publisher` CLI
کارها:
- [ ] وضعیت PR #2147 چک شود — اگر feedback جدید دارد رفع شود
- [ ] ثبت در Smithery.ai
- [ ] بررسی Official MCP Registry submission process
### 2. MCP Skills — اولین مهارت‌ها (github:airano-ir/mcp-skills)
ریپازیتوری خالی ساخته شده. ساختار:
```
mcp-skills/skills/
├── wordpress/ ← اولین مهارت
├── supabase/
├── gitea/
├── woocommerce/
├── openpanel/
└── coolify/ ← آینده
```
کارها:
- [ ] از SkillHub بهترین مهارت‌های مرتبط را جستجو کن: `npx skillhub search "wordpress mcp" --sort aiScore`
- [ ] اگر مهارت با کیفیت بالا (aiScore > 70) پیدا نشد، با skill-creator مهارت جدید بساز
- [ ] اولین مهارت: WordPress content workflow (استفاده از MCP endpoint مستقیم)
- [ ] هر مهارت باید SKILL.md + اسکریپت‌های عملی داشته باشد
- [ ] بعد از تست، commit و push به github:airano-ir/mcp-skills
### 3. Project-Ops Sync با Gitea
- [ ] محتوای `/config/.claude/skills/project-ops/SKILL.md` را به `gitea:atlatl/project-ops` push کن
- [ ] مطمئن شو backup در Gitea up-to-date است
### 4. Blog Workspace Setup
- [ ] WordPress MCP endpoint اضافه کن از داشبورد mcp.example.com (blog.example.com)
- [ ] یک پست تست با MCP WordPress tools بنویس
- [ ] اگر API مشکلی داشت، در mcphub-internal fix کن
### 5. بررسی Coolify MCP (F.17)
- [ ] API documentation کولیفای را بررسی کن
- [ ] بررسی آیا Coolify API در دسترس است از این محیط
- [ ] یک طرح اولیه از tools مورد نیاز بنویس
- [ ] نتیجه را در docs/plans/ ذخیره کن
## قوانین
- اول پلن بده، بدون تایید شروع نکن
- هر مرحله commit شود
- حافظه و project-ops در صورت نیاز آپدیت شود
- از SkillHub برای پیدا کردن بهترین مهارت‌ها استفاده کن (aiScore بالاترین)
- اگر مهارتی بررسی نشده، با auto-review بررسی کن
- ایمیل git عمومی: hi.airano@gmail.com
- ایمیل git داخلی: mcphub.dev@gmail.com
```

View File

@@ -89,6 +89,14 @@ DASHBOARD_SESSION_SECRET=
# USER_RATE_LIMIT_PER_MIN=30 # USER_RATE_LIMIT_PER_MIN=30
# USER_RATE_LIMIT_PER_HR=500 # USER_RATE_LIMIT_PER_HR=500
# ============================================
# COOLIFY (admin-only — F.17)
# ============================================
# Coolify instance for AI-driven deployment management.
# Create an API token in Coolify: Keys & Tokens → API tokens
# COOLIFY_URL=https://coolify.example.com
# COOLIFY_TOKEN=your-api-token-here
# ============================================ # ============================================
# ADVANCED (optional — defaults are fine) # ADVANCED (optional — defaults are fine)
# ============================================ # ============================================

View File

@@ -11,6 +11,7 @@ v2.3.0 (Phase G): Supabase Self-Hosted Plugin added
from plugins.appwrite.plugin import AppwritePlugin from plugins.appwrite.plugin import AppwritePlugin
from plugins.base import BasePlugin, PluginRegistry from plugins.base import BasePlugin, PluginRegistry
from plugins.coolify.plugin import CoolifyPlugin
from plugins.directus.plugin import DirectusPlugin from plugins.directus.plugin import DirectusPlugin
from plugins.gitea.plugin import GiteaPlugin from plugins.gitea.plugin import GiteaPlugin
from plugins.n8n.plugin import N8nPlugin from plugins.n8n.plugin import N8nPlugin
@@ -33,6 +34,7 @@ registry.register("supabase", SupabasePlugin)
registry.register("openpanel", OpenPanelPlugin) registry.register("openpanel", OpenPanelPlugin)
registry.register("appwrite", AppwritePlugin) registry.register("appwrite", AppwritePlugin)
registry.register("directus", DirectusPlugin) registry.register("directus", DirectusPlugin)
registry.register("coolify", CoolifyPlugin)
__all__ = [ __all__ = [
"BasePlugin", "BasePlugin",
@@ -47,4 +49,5 @@ __all__ = [
"OpenPanelPlugin", "OpenPanelPlugin",
"AppwritePlugin", "AppwritePlugin",
"DirectusPlugin", "DirectusPlugin",
"CoolifyPlugin",
] ]

View File

@@ -0,0 +1,5 @@
"""
Coolify Plugin — AI-driven deployment management for Coolify instances.
F.17: Phase 1 MVP — Applications, Deployments, Servers (~30 tools)
"""

429
plugins/coolify/client.py Normal file
View File

@@ -0,0 +1,429 @@
"""
Coolify REST API Client
Handles all HTTP communication with Coolify REST API.
Separates API communication from business logic.
"""
import logging
from typing import Any
import aiohttp
class CoolifyClient:
"""
Coolify REST API client for HTTP communication.
Handles Bearer token authentication, request formatting,
and error handling for all Coolify API v1 endpoints.
"""
def __init__(self, site_url: str, token: str):
"""
Initialize Coolify API client.
Args:
site_url: Coolify instance URL (e.g., https://coolify.example.com)
token: API token for Bearer authentication
"""
self.site_url = site_url.rstrip("/")
self.api_base = f"{self.site_url}/api/v1"
self.token = token
self.logger = logging.getLogger(f"CoolifyClient.{site_url}")
def _get_headers(self) -> dict[str, str]:
"""Get request headers with Bearer authentication."""
return {
"Authorization": f"Bearer {self.token}",
"Content-Type": "application/json",
"Accept": "application/json",
}
async def request(
self,
method: str,
endpoint: str,
params: dict | None = None,
json_data: dict | None = None,
) -> Any:
"""
Make authenticated request to Coolify REST API.
Args:
method: HTTP method (GET, POST, PATCH, DELETE)
endpoint: API endpoint (without base URL)
params: Query parameters
json_data: JSON body data
Returns:
API response (dict, list, or None)
Raises:
Exception: On API errors with status code and message
"""
url = f"{self.api_base}/{endpoint.lstrip('/')}"
if params:
params = {k: v for k, v in params.items() if v is not None}
if json_data:
json_data = {k: v for k, v in json_data.items() if v is not None}
self.logger.debug(f"{method} {url}")
async with (
aiohttp.ClientSession() as session,
session.request(
method=method,
url=url,
params=params,
json=json_data,
headers=self._get_headers(),
) as response,
):
self.logger.debug(f"Response status: {response.status}")
if response.status == 204:
return {"success": True, "message": "Operation completed successfully"}
try:
response_data = await response.json()
except Exception:
response_text = await response.text()
if response.status >= 400:
raise Exception(
f"Coolify API error (status {response.status}): {response_text}"
)
return {"success": True, "message": response_text}
if response.status >= 400:
error_msg = response_data.get("message", "Unknown error")
raise Exception(f"Coolify API error (status {response.status}): {error_msg}")
return response_data
# --- Applications ---
async def list_applications(self, tag: str | None = None) -> list[dict]:
"""List all applications."""
params = {"tag": tag} if tag else {}
return await self.request("GET", "applications", params=params)
async def get_application(self, uuid: str) -> dict:
"""Get application by UUID."""
return await self.request("GET", f"applications/{uuid}")
async def create_application_public(self, data: dict) -> dict:
"""Create application from public repository."""
return await self.request("POST", "applications/public", json_data=data)
async def create_application_dockerfile(self, data: dict) -> dict:
"""Create application from Dockerfile."""
return await self.request("POST", "applications/dockerfile", json_data=data)
async def create_application_docker_image(self, data: dict) -> dict:
"""Create application from Docker image."""
return await self.request("POST", "applications/dockerimage", json_data=data)
async def create_application_docker_compose(self, data: dict) -> dict:
"""Create application from Docker Compose."""
return await self.request("POST", "applications/dockercompose", json_data=data)
async def update_application(self, uuid: str, data: dict) -> dict:
"""Update application by UUID."""
return await self.request("PATCH", f"applications/{uuid}", json_data=data)
async def delete_application(
self,
uuid: str,
delete_configurations: bool = True,
delete_volumes: bool = True,
docker_cleanup: bool = True,
delete_connected_networks: bool = True,
) -> dict:
"""Delete application by UUID."""
params = {
"delete_configurations": str(delete_configurations).lower(),
"delete_volumes": str(delete_volumes).lower(),
"docker_cleanup": str(docker_cleanup).lower(),
"delete_connected_networks": str(delete_connected_networks).lower(),
}
return await self.request("DELETE", f"applications/{uuid}", params=params)
async def start_application(
self, uuid: str, force: bool = False, instant_deploy: bool = False
) -> dict:
"""Deploy/start application."""
params = {}
if force:
params["force"] = "true"
if instant_deploy:
params["instant_deploy"] = "true"
return await self.request("GET", f"applications/{uuid}/start", params=params)
async def stop_application(self, uuid: str, docker_cleanup: bool = True) -> dict:
"""Stop application."""
params = {"docker_cleanup": str(docker_cleanup).lower()}
return await self.request("GET", f"applications/{uuid}/stop", params=params)
async def restart_application(self, uuid: str) -> dict:
"""Restart application."""
return await self.request("GET", f"applications/{uuid}/restart")
async def get_application_logs(self, uuid: str, lines: int = 100) -> dict:
"""Get application logs."""
return await self.request("GET", f"applications/{uuid}/logs", params={"lines": lines})
# --- Application Environment Variables ---
async def list_application_envs(self, uuid: str) -> list[dict]:
"""List application environment variables."""
return await self.request("GET", f"applications/{uuid}/envs")
async def create_application_env(self, uuid: str, data: dict) -> dict:
"""Create application environment variable."""
return await self.request("POST", f"applications/{uuid}/envs", json_data=data)
async def update_application_env(self, uuid: str, data: dict) -> dict:
"""Update application environment variable."""
return await self.request("PATCH", f"applications/{uuid}/envs", json_data=data)
async def update_application_envs_bulk(self, uuid: str, data: list[dict]) -> dict:
"""Bulk update application environment variables."""
return await self.request(
"PATCH", f"applications/{uuid}/envs/bulk", json_data={"data": data}
)
async def delete_application_env(self, uuid: str, env_uuid: str) -> dict:
"""Delete application environment variable."""
return await self.request("DELETE", f"applications/{uuid}/envs/{env_uuid}")
# --- Deployments ---
async def list_deployments(self) -> list[dict]:
"""List running deployments."""
return await self.request("GET", "deployments")
async def get_deployment(self, uuid: str) -> dict:
"""Get deployment by UUID."""
return await self.request("GET", f"deployments/{uuid}")
async def cancel_deployment(self, uuid: str) -> dict:
"""Cancel a deployment."""
return await self.request("POST", f"deployments/{uuid}/cancel")
async def deploy(
self,
tag: str | None = None,
uuid: str | None = None,
force: bool = False,
) -> dict:
"""Deploy by tag or UUID."""
params = {}
if tag:
params["tag"] = tag
if uuid:
params["uuid"] = uuid
if force:
params["force"] = "true"
return await self.request("GET", "deploy", params=params)
async def list_app_deployments(self, uuid: str, skip: int = 0, take: int = 10) -> list[dict]:
"""List deployments for a specific application."""
params = {"skip": skip, "take": take}
return await self.request("GET", f"deployments/applications/{uuid}", params=params)
# --- Servers ---
async def list_servers(self) -> list[dict]:
"""List all servers."""
return await self.request("GET", "servers")
async def get_server(self, uuid: str) -> dict:
"""Get server by UUID."""
return await self.request("GET", f"servers/{uuid}")
async def create_server(self, data: dict) -> dict:
"""Create a new server."""
return await self.request("POST", "servers", json_data=data)
async def update_server(self, uuid: str, data: dict) -> dict:
"""Update server by UUID."""
return await self.request("PATCH", f"servers/{uuid}", json_data=data)
async def delete_server(self, uuid: str) -> dict:
"""Delete server by UUID."""
return await self.request("DELETE", f"servers/{uuid}")
async def get_server_resources(self, uuid: str) -> list[dict]:
"""Get resources on a server."""
return await self.request("GET", f"servers/{uuid}/resources")
async def get_server_domains(self, uuid: str) -> list[dict]:
"""Get domains configured on a server."""
return await self.request("GET", f"servers/{uuid}/domains")
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

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

View File

@@ -0,0 +1,883 @@
"""Application Handler — manages Coolify applications, 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_applications",
"category": "read",
"method_name": "list_applications",
"description": "List all Coolify applications. Optionally filter by tag name.",
"schema": {
"type": "object",
"properties": {
"tag": {
"anyOf": [{"type": "string"}, {"type": "null"}],
"description": "Filter by tag name",
},
},
},
"scope": "read",
},
{
"name": "get_application",
"category": "read",
"method_name": "get_application",
"description": "Get details of a specific Coolify application by UUID.",
"schema": {
"type": "object",
"properties": {
"uuid": {
"type": "string",
"description": "Application UUID",
"minLength": 1,
},
},
"required": ["uuid"],
},
"scope": "read",
},
{
"name": "create_application_public",
"category": "crud",
"method_name": "create_application_public",
"description": (
"Create a Coolify application from a public Git repository. "
"Requires project_uuid, server_uuid, environment, git_repository, "
"git_branch, build_pack, and ports_exposes."
),
"schema": {
"type": "object",
"properties": {
"project_uuid": {
"type": "string",
"description": "Project UUID",
},
"server_uuid": {
"type": "string",
"description": "Server UUID",
},
"environment_name": {
"type": "string",
"description": "Environment name (e.g., 'production')",
},
"git_repository": {
"type": "string",
"description": "Git repository URL",
},
"git_branch": {
"type": "string",
"description": "Git branch name",
},
"build_pack": {
"type": "string",
"description": "Build pack type",
"enum": ["nixpacks", "static", "dockerfile", "dockercompose"],
},
"ports_exposes": {
"type": "string",
"description": "Exposed ports (e.g., '3000')",
},
"name": {
"anyOf": [{"type": "string"}, {"type": "null"}],
"description": "Application name",
},
"description": {
"anyOf": [{"type": "string"}, {"type": "null"}],
"description": "Application description",
},
"domains": {
"anyOf": [{"type": "string"}, {"type": "null"}],
"description": "Comma-separated domain URLs",
},
"instant_deploy": {
"type": "boolean",
"description": "Deploy immediately after creation",
"default": False,
},
},
"required": [
"project_uuid",
"server_uuid",
"environment_name",
"git_repository",
"git_branch",
"build_pack",
"ports_exposes",
],
},
"scope": "write",
},
{
"name": "create_application_dockerfile",
"category": "crud",
"method_name": "create_application_dockerfile",
"description": (
"Create a Coolify application from a Dockerfile (without git). "
"Requires project_uuid, server_uuid, environment, and dockerfile content."
),
"schema": {
"type": "object",
"properties": {
"project_uuid": {
"type": "string",
"description": "Project UUID",
},
"server_uuid": {
"type": "string",
"description": "Server UUID",
},
"environment_name": {
"type": "string",
"description": "Environment name",
},
"dockerfile": {
"type": "string",
"description": "Dockerfile content",
},
"name": {
"anyOf": [{"type": "string"}, {"type": "null"}],
"description": "Application name",
},
"description": {
"anyOf": [{"type": "string"}, {"type": "null"}],
"description": "Application description",
},
"domains": {
"anyOf": [{"type": "string"}, {"type": "null"}],
"description": "Comma-separated domain URLs",
},
"ports_exposes": {
"anyOf": [{"type": "string"}, {"type": "null"}],
"description": "Exposed ports",
},
"instant_deploy": {
"type": "boolean",
"description": "Deploy immediately",
"default": False,
},
},
"required": [
"project_uuid",
"server_uuid",
"environment_name",
"dockerfile",
],
},
"scope": "write",
},
{
"name": "create_application_docker_image",
"category": "crud",
"method_name": "create_application_docker_image",
"description": (
"Create a Coolify application from a Docker image. "
"Requires project_uuid, server_uuid, environment, "
"docker_registry_image_name, and ports_exposes."
),
"schema": {
"type": "object",
"properties": {
"project_uuid": {
"type": "string",
"description": "Project UUID",
},
"server_uuid": {
"type": "string",
"description": "Server UUID",
},
"environment_name": {
"type": "string",
"description": "Environment name",
},
"docker_registry_image_name": {
"type": "string",
"description": "Docker image name (e.g., 'nginx')",
},
"ports_exposes": {
"type": "string",
"description": "Exposed ports",
},
"docker_registry_image_tag": {
"anyOf": [{"type": "string"}, {"type": "null"}],
"description": "Docker image tag (default: latest)",
},
"name": {
"anyOf": [{"type": "string"}, {"type": "null"}],
"description": "Application name",
},
"description": {
"anyOf": [{"type": "string"}, {"type": "null"}],
"description": "Application description",
},
"domains": {
"anyOf": [{"type": "string"}, {"type": "null"}],
"description": "Comma-separated domain URLs",
},
"instant_deploy": {
"type": "boolean",
"description": "Deploy immediately",
"default": False,
},
},
"required": [
"project_uuid",
"server_uuid",
"environment_name",
"docker_registry_image_name",
"ports_exposes",
],
},
"scope": "write",
},
{
"name": "create_application_compose",
"category": "crud",
"method_name": "create_application_compose",
"description": (
"Create a Coolify application from Docker Compose. "
"Note: Deprecated by Coolify — prefer using services instead."
),
"schema": {
"type": "object",
"properties": {
"project_uuid": {
"type": "string",
"description": "Project UUID",
},
"server_uuid": {
"type": "string",
"description": "Server UUID",
},
"environment_name": {
"type": "string",
"description": "Environment name",
},
"docker_compose_raw": {
"type": "string",
"description": "Raw Docker Compose YAML content",
},
"name": {
"anyOf": [{"type": "string"}, {"type": "null"}],
"description": "Application name",
},
"instant_deploy": {
"type": "boolean",
"description": "Deploy immediately",
"default": False,
},
},
"required": [
"project_uuid",
"server_uuid",
"environment_name",
"docker_compose_raw",
],
},
"scope": "write",
},
{
"name": "update_application",
"category": "crud",
"method_name": "update_application",
"description": (
"Update a Coolify application settings. Supports name, description, "
"domains, build settings, health checks, resource limits, and more."
),
"schema": {
"type": "object",
"properties": {
"uuid": {
"type": "string",
"description": "Application UUID",
"minLength": 1,
},
"name": {
"anyOf": [{"type": "string"}, {"type": "null"}],
"description": "Application name",
},
"description": {
"anyOf": [{"type": "string"}, {"type": "null"}],
"description": "Application description",
},
"domains": {
"anyOf": [{"type": "string"}, {"type": "null"}],
"description": "Comma-separated domain URLs",
},
"git_repository": {
"anyOf": [{"type": "string"}, {"type": "null"}],
"description": "Git repository URL",
},
"git_branch": {
"anyOf": [{"type": "string"}, {"type": "null"}],
"description": "Git branch",
},
"build_pack": {
"anyOf": [
{
"type": "string",
"enum": [
"nixpacks",
"static",
"dockerfile",
"dockercompose",
],
},
{"type": "null"},
],
"description": "Build pack type",
},
"install_command": {
"anyOf": [{"type": "string"}, {"type": "null"}],
"description": "Install command",
},
"build_command": {
"anyOf": [{"type": "string"}, {"type": "null"}],
"description": "Build command",
},
"start_command": {
"anyOf": [{"type": "string"}, {"type": "null"}],
"description": "Start command",
},
"ports_exposes": {
"anyOf": [{"type": "string"}, {"type": "null"}],
"description": "Exposed ports",
},
"is_auto_deploy_enabled": {
"anyOf": [{"type": "boolean"}, {"type": "null"}],
"description": "Enable auto-deploy on push",
},
"instant_deploy": {
"anyOf": [{"type": "boolean"}, {"type": "null"}],
"description": "Deploy instantly",
},
},
"required": ["uuid"],
},
"scope": "write",
},
{
"name": "delete_application",
"category": "system",
"method_name": "delete_application",
"description": (
"Delete a Coolify application permanently. "
"Optionally clean up configs, volumes, and networks."
),
"schema": {
"type": "object",
"properties": {
"uuid": {
"type": "string",
"description": "Application 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,
},
"delete_connected_networks": {
"type": "boolean",
"description": "Delete connected networks",
"default": True,
},
},
"required": ["uuid"],
},
"scope": "admin",
},
{
"name": "start_application",
"category": "lifecycle",
"method_name": "start_application",
"description": "Deploy/start a Coolify application. Triggers a new deployment.",
"schema": {
"type": "object",
"properties": {
"uuid": {
"type": "string",
"description": "Application UUID",
"minLength": 1,
},
"force": {
"type": "boolean",
"description": "Force rebuild without cache",
"default": False,
},
"instant_deploy": {
"type": "boolean",
"description": "Skip deployment queue",
"default": False,
},
},
"required": ["uuid"],
},
"scope": "write",
},
{
"name": "stop_application",
"category": "lifecycle",
"method_name": "stop_application",
"description": "Stop a running Coolify application.",
"schema": {
"type": "object",
"properties": {
"uuid": {
"type": "string",
"description": "Application UUID",
"minLength": 1,
},
"docker_cleanup": {
"type": "boolean",
"description": "Prune networks and volumes",
"default": True,
},
},
"required": ["uuid"],
},
"scope": "write",
},
{
"name": "restart_application",
"category": "lifecycle",
"method_name": "restart_application",
"description": "Restart a Coolify application.",
"schema": {
"type": "object",
"properties": {
"uuid": {
"type": "string",
"description": "Application UUID",
"minLength": 1,
},
},
"required": ["uuid"],
},
"scope": "write",
},
{
"name": "get_application_logs",
"category": "read_sensitive",
"sensitivity": "sensitive",
"method_name": "get_application_logs",
"description": "Get logs for a Coolify application.",
"schema": {
"type": "object",
"properties": {
"uuid": {
"type": "string",
"description": "Application UUID",
"minLength": 1,
},
"lines": {
"type": "integer",
"description": "Number of log lines to retrieve",
"default": 100,
"minimum": 1,
"maximum": 10000,
},
},
"required": ["uuid"],
},
"scope": "read",
},
{
"name": "list_application_envs",
"category": "read_sensitive",
"sensitivity": "sensitive",
"method_name": "list_application_envs",
"description": "List environment variables for a Coolify application.",
"schema": {
"type": "object",
"properties": {
"uuid": {
"type": "string",
"description": "Application UUID",
"minLength": 1,
},
},
"required": ["uuid"],
},
"scope": "read",
},
{
"name": "create_application_env",
"category": "env",
"sensitivity": "sensitive",
"method_name": "create_application_env",
"description": "Create an environment variable for a Coolify application.",
"schema": {
"type": "object",
"properties": {
"uuid": {
"type": "string",
"description": "Application 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,
},
"is_literal": {
"type": "boolean",
"description": "Treat as literal (no variable interpolation)",
"default": False,
},
"is_multiline": {
"type": "boolean",
"description": "Allow multiline value",
"default": False,
},
"is_shown_once": {
"type": "boolean",
"description": "Show value only once",
"default": False,
},
},
"required": ["uuid", "key", "value"],
},
"scope": "write",
},
{
"name": "update_application_env",
"category": "env",
"sensitivity": "sensitive",
"method_name": "update_application_env",
"description": "Update an environment variable for a Coolify application.",
"schema": {
"type": "object",
"properties": {
"uuid": {
"type": "string",
"description": "Application 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",
},
"is_literal": {
"anyOf": [{"type": "boolean"}, {"type": "null"}],
"description": "Treat as literal",
},
"is_multiline": {
"anyOf": [{"type": "boolean"}, {"type": "null"}],
"description": "Allow multiline",
},
"is_shown_once": {
"anyOf": [{"type": "boolean"}, {"type": "null"}],
"description": "Show only once",
},
},
"required": ["uuid", "key", "value"],
},
"scope": "write",
},
{
"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": {
"type": "object",
"properties": {
"uuid": {
"type": "string",
"description": "Application UUID",
"minLength": 1,
},
"data": {
"type": "array",
"description": "Array of env var objects with key, value, and flags",
"items": {
"type": "object",
"properties": {
"key": {"type": "string"},
"value": {"type": "string"},
"is_preview": {"type": "boolean"},
"is_literal": {"type": "boolean"},
"is_multiline": {"type": "boolean"},
"is_shown_once": {"type": "boolean"},
},
"required": ["key", "value"],
},
},
},
"required": ["uuid", "data"],
},
"scope": "write",
},
{
"name": "delete_application_env",
"category": "env",
"sensitivity": "sensitive",
"method_name": "delete_application_env",
"description": "Delete an environment variable from a Coolify application.",
"schema": {
"type": "object",
"properties": {
"uuid": {
"type": "string",
"description": "Application UUID",
"minLength": 1,
},
"env_uuid": {
"type": "string",
"description": "Environment variable UUID",
"minLength": 1,
},
},
"required": ["uuid", "env_uuid"],
},
"scope": "write",
},
]
# --- Handler Functions ---
async def list_applications(client: CoolifyClient, tag: str | None = None) -> str:
"""List all applications."""
apps = await client.list_applications(tag=tag)
result = {"success": True, "count": len(apps), "applications": apps}
return json.dumps(result, indent=2, ensure_ascii=False)
async def get_application(client: CoolifyClient, uuid: str) -> str:
"""Get application details."""
app = await client.get_application(uuid)
result = {"success": True, "application": app}
return json.dumps(result, indent=2, ensure_ascii=False)
async def create_application_public(client: CoolifyClient, **kwargs) -> str:
"""Create application from public repository."""
app = await client.create_application_public(kwargs)
result = {
"success": True,
"message": "Application created successfully",
"application": app,
}
return json.dumps(result, indent=2, ensure_ascii=False)
async def create_application_dockerfile(client: CoolifyClient, **kwargs) -> str:
"""Create application from Dockerfile."""
app = await client.create_application_dockerfile(kwargs)
result = {
"success": True,
"message": "Application created from Dockerfile",
"application": app,
}
return json.dumps(result, indent=2, ensure_ascii=False)
async def create_application_docker_image(client: CoolifyClient, **kwargs) -> str:
"""Create application from Docker image."""
app = await client.create_application_docker_image(kwargs)
result = {
"success": True,
"message": "Application created from Docker image",
"application": app,
}
return json.dumps(result, indent=2, ensure_ascii=False)
async def create_application_compose(client: CoolifyClient, **kwargs) -> str:
"""Create application from Docker Compose."""
app = await client.create_application_docker_compose(kwargs)
result = {
"success": True,
"message": "Application created from Docker Compose",
"application": app,
}
return json.dumps(result, indent=2, ensure_ascii=False)
async def update_application(client: CoolifyClient, uuid: str, **kwargs) -> str:
"""Update application settings."""
data = {k: v for k, v in kwargs.items() if v is not None and k != "uuid"}
app = await client.update_application(uuid, data)
result = {
"success": True,
"message": f"Application '{uuid}' updated successfully",
"application": app,
}
return json.dumps(result, indent=2, ensure_ascii=False)
async def delete_application(
client: CoolifyClient,
uuid: str,
delete_configurations: bool = True,
delete_volumes: bool = True,
docker_cleanup: bool = True,
delete_connected_networks: bool = True,
) -> str:
"""Delete an application."""
result_data = await client.delete_application(
uuid,
delete_configurations=delete_configurations,
delete_volumes=delete_volumes,
docker_cleanup=docker_cleanup,
delete_connected_networks=delete_connected_networks,
)
result = {"success": True, "message": f"Application '{uuid}' deleted", "data": result_data}
return json.dumps(result, indent=2, ensure_ascii=False)
async def start_application(
client: CoolifyClient,
uuid: str,
force: bool = False,
instant_deploy: bool = False,
) -> str:
"""Deploy/start application."""
result_data = await client.start_application(uuid, force=force, instant_deploy=instant_deploy)
result = {
"success": True,
"message": f"Application '{uuid}' deployment queued",
"data": result_data,
}
return json.dumps(result, indent=2, ensure_ascii=False)
async def stop_application(client: CoolifyClient, uuid: str, docker_cleanup: bool = True) -> str:
"""Stop application."""
result_data = await client.stop_application(uuid, docker_cleanup=docker_cleanup)
result = {"success": True, "message": f"Application '{uuid}' stopping", "data": result_data}
return json.dumps(result, indent=2, ensure_ascii=False)
async def restart_application(client: CoolifyClient, uuid: str) -> str:
"""Restart application."""
result_data = await client.restart_application(uuid)
result = {"success": True, "message": f"Application '{uuid}' restarting", "data": result_data}
return json.dumps(result, indent=2, ensure_ascii=False)
async def get_application_logs(client: CoolifyClient, uuid: str, lines: int = 100) -> str:
"""Get application logs."""
logs = await client.get_application_logs(uuid, lines=lines)
result = {"success": True, "logs": logs}
return json.dumps(result, indent=2, ensure_ascii=False)
async def list_application_envs(client: CoolifyClient, uuid: str) -> str:
"""List application environment variables."""
envs = await client.list_application_envs(uuid)
result = {"success": True, "count": len(envs), "envs": envs}
return json.dumps(result, indent=2, ensure_ascii=False)
async def create_application_env(
client: CoolifyClient,
uuid: str,
key: str,
value: str,
is_preview: bool = False,
is_literal: bool = False,
is_multiline: bool = False,
is_shown_once: bool = False,
) -> str:
"""Create application environment variable."""
data = {
"key": key,
"value": value,
"is_preview": is_preview,
"is_literal": is_literal,
"is_multiline": is_multiline,
"is_shown_once": is_shown_once,
}
result_data = await client.create_application_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_application_env(
client: CoolifyClient,
uuid: str,
key: str,
value: str,
is_preview: bool | None = None,
is_literal: bool | None = None,
is_multiline: bool | None = None,
is_shown_once: bool | None = None,
) -> str:
"""Update application environment variable."""
data = {"key": key, "value": value}
if is_preview is not None:
data["is_preview"] = is_preview
if is_literal is not None:
data["is_literal"] = is_literal
if is_multiline is not None:
data["is_multiline"] = is_multiline
if is_shown_once is not None:
data["is_shown_once"] = is_shown_once
result_data = await client.update_application_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_application_envs_bulk(client: CoolifyClient, uuid: str, data: list[dict]) -> str:
"""Bulk update application environment variables."""
result_data = await client.update_application_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_application_env(client: CoolifyClient, uuid: str, env_uuid: str) -> str:
"""Delete application environment variable."""
await client.delete_application_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

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

@@ -0,0 +1,167 @@
"""Deployment Handler — manages Coolify deployments."""
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_deployments",
"category": "read",
"method_name": "list_deployments",
"description": "List all running deployments on the Coolify instance.",
"schema": {
"type": "object",
"properties": {},
},
"scope": "read",
},
{
"name": "get_deployment",
"category": "read",
"method_name": "get_deployment",
"description": "Get details of a specific deployment by UUID.",
"schema": {
"type": "object",
"properties": {
"uuid": {
"type": "string",
"description": "Deployment UUID",
"minLength": 1,
},
},
"required": ["uuid"],
},
"scope": "read",
},
{
"name": "cancel_deployment",
"category": "lifecycle",
"method_name": "cancel_deployment",
"description": "Cancel a running deployment.",
"schema": {
"type": "object",
"properties": {
"uuid": {
"type": "string",
"description": "Deployment UUID",
"minLength": 1,
},
},
"required": ["uuid"],
},
"scope": "write",
},
{
"name": "deploy",
"category": "lifecycle",
"method_name": "deploy",
"description": (
"Trigger deployment by tag name or resource UUID. "
"Can deploy multiple resources at once using comma-separated values."
),
"schema": {
"type": "object",
"properties": {
"tag": {
"anyOf": [{"type": "string"}, {"type": "null"}],
"description": "Tag name(s), comma-separated",
},
"uuid": {
"anyOf": [{"type": "string"}, {"type": "null"}],
"description": "Resource UUID(s), comma-separated",
},
"force": {
"type": "boolean",
"description": "Force rebuild without cache",
"default": False,
},
},
},
"scope": "write",
},
{
"name": "list_app_deployments",
"category": "read",
"method_name": "list_app_deployments",
"description": "List deployment history for a specific application.",
"schema": {
"type": "object",
"properties": {
"uuid": {
"type": "string",
"description": "Application UUID",
"minLength": 1,
},
"skip": {
"type": "integer",
"description": "Number of deployments to skip",
"default": 0,
"minimum": 0,
},
"take": {
"type": "integer",
"description": "Number of deployments to return",
"default": 10,
"minimum": 1,
"maximum": 100,
},
},
"required": ["uuid"],
},
"scope": "read",
},
]
# --- Handler Functions ---
async def list_deployments(client: CoolifyClient) -> str:
"""List running deployments."""
deployments = await client.list_deployments()
result = {"success": True, "count": len(deployments), "deployments": deployments}
return json.dumps(result, indent=2, ensure_ascii=False)
async def get_deployment(client: CoolifyClient, uuid: str) -> str:
"""Get deployment details."""
deployment = await client.get_deployment(uuid)
result = {"success": True, "deployment": deployment}
return json.dumps(result, indent=2, ensure_ascii=False)
async def cancel_deployment(client: CoolifyClient, uuid: str) -> str:
"""Cancel a deployment."""
result_data = await client.cancel_deployment(uuid)
result = {
"success": True,
"message": f"Deployment '{uuid}' cancelled",
"data": result_data,
}
return json.dumps(result, indent=2, ensure_ascii=False)
async def deploy(
client: CoolifyClient,
tag: str | None = None,
uuid: str | None = None,
force: bool = False,
) -> str:
"""Deploy by tag or UUID."""
result_data = await client.deploy(tag=tag, uuid=uuid, force=force)
result = {"success": True, "message": "Deployment triggered", "data": result_data}
return json.dumps(result, indent=2, ensure_ascii=False)
async def list_app_deployments(
client: CoolifyClient, uuid: str, skip: int = 0, take: int = 10
) -> str:
"""List deployment history for an application."""
deployments = await client.list_app_deployments(uuid, skip=skip, take=take)
result = {"success": True, "count": len(deployments), "deployments": deployments}
return json.dumps(result, indent=2, ensure_ascii=False)

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

@@ -0,0 +1,294 @@
"""Server Handler — manages Coolify servers."""
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_servers",
"category": "read",
"method_name": "list_servers",
"description": "List all servers registered in the Coolify instance.",
"schema": {
"type": "object",
"properties": {},
},
"scope": "read",
},
{
"name": "get_server",
"category": "read",
"method_name": "get_server",
"description": "Get details of a specific server by UUID, including settings.",
"schema": {
"type": "object",
"properties": {
"uuid": {
"type": "string",
"description": "Server UUID",
"minLength": 1,
},
},
"required": ["uuid"],
},
"scope": "read",
},
{
"name": "create_server",
"category": "system",
"method_name": "create_server",
"description": (
"Register a new server in Coolify. "
"Requires name, IP, port, user, and a private key UUID for SSH access."
),
"schema": {
"type": "object",
"properties": {
"name": {
"type": "string",
"description": "Server name",
"minLength": 1,
},
"ip": {
"type": "string",
"description": "Server IP address",
"minLength": 1,
},
"port": {
"type": "integer",
"description": "SSH port",
"default": 22,
},
"user": {
"type": "string",
"description": "SSH user",
"default": "root",
},
"private_key_uuid": {
"type": "string",
"description": "Private key UUID for SSH authentication",
"minLength": 1,
},
"description": {
"anyOf": [{"type": "string"}, {"type": "null"}],
"description": "Server description",
},
"is_build_server": {
"type": "boolean",
"description": "Use as build server",
"default": False,
},
"instant_validate": {
"type": "boolean",
"description": "Validate server immediately after creation",
"default": False,
},
"proxy_type": {
"type": "string",
"description": "Proxy type for the server",
"enum": ["traefik", "caddy", "none"],
"default": "traefik",
},
},
"required": ["name", "ip", "private_key_uuid"],
},
"scope": "admin",
},
{
"name": "update_server",
"category": "crud",
"method_name": "update_server",
"description": "Update server configuration.",
"schema": {
"type": "object",
"properties": {
"uuid": {
"type": "string",
"description": "Server UUID",
"minLength": 1,
},
"name": {
"anyOf": [{"type": "string"}, {"type": "null"}],
"description": "Server name",
},
"description": {
"anyOf": [{"type": "string"}, {"type": "null"}],
"description": "Server description",
},
"ip": {
"anyOf": [{"type": "string"}, {"type": "null"}],
"description": "Server IP address",
},
"port": {
"anyOf": [{"type": "integer"}, {"type": "null"}],
"description": "SSH port",
},
"user": {
"anyOf": [{"type": "string"}, {"type": "null"}],
"description": "SSH user",
},
"proxy_type": {
"anyOf": [
{"type": "string", "enum": ["traefik", "caddy", "none"]},
{"type": "null"},
],
"description": "Proxy type",
},
},
"required": ["uuid"],
},
"scope": "write",
},
{
"name": "delete_server",
"category": "system",
"method_name": "delete_server",
"description": "Delete a server from Coolify. This cannot be undone!",
"schema": {
"type": "object",
"properties": {
"uuid": {
"type": "string",
"description": "Server UUID",
"minLength": 1,
},
},
"required": ["uuid"],
},
"scope": "admin",
},
{
"name": "get_server_resources",
"category": "read",
"method_name": "get_server_resources",
"description": (
"Get all resources (applications, databases, services) "
"deployed on a specific server."
),
"schema": {
"type": "object",
"properties": {
"uuid": {
"type": "string",
"description": "Server UUID",
"minLength": 1,
},
},
"required": ["uuid"],
},
"scope": "read",
},
{
"name": "get_server_domains",
"category": "read",
"method_name": "get_server_domains",
"description": "Get all domains configured on a specific server.",
"schema": {
"type": "object",
"properties": {
"uuid": {
"type": "string",
"description": "Server UUID",
"minLength": 1,
},
},
"required": ["uuid"],
},
"scope": "read",
},
{
"name": "validate_server",
"category": "read",
"method_name": "validate_server",
"description": "Validate server connectivity and configuration.",
"schema": {
"type": "object",
"properties": {
"uuid": {
"type": "string",
"description": "Server UUID",
"minLength": 1,
},
},
"required": ["uuid"],
},
"scope": "read",
},
]
# --- Handler Functions ---
async def list_servers(client: CoolifyClient) -> str:
"""List all servers."""
servers = await client.list_servers()
result = {"success": True, "count": len(servers), "servers": servers}
return json.dumps(result, indent=2, ensure_ascii=False)
async def get_server(client: CoolifyClient, uuid: str) -> str:
"""Get server details."""
server = await client.get_server(uuid)
result = {"success": True, "server": server}
return json.dumps(result, indent=2, ensure_ascii=False)
async def create_server(client: CoolifyClient, **kwargs) -> str:
"""Create a new server."""
server = await client.create_server(kwargs)
result = {
"success": True,
"message": "Server created successfully",
"server": server,
}
return json.dumps(result, indent=2, ensure_ascii=False)
async def update_server(client: CoolifyClient, uuid: str, **kwargs) -> str:
"""Update server configuration."""
data = {k: v for k, v in kwargs.items() if v is not None and k != "uuid"}
server = await client.update_server(uuid, data)
result = {
"success": True,
"message": f"Server '{uuid}' updated",
"server": server,
}
return json.dumps(result, indent=2, ensure_ascii=False)
async def delete_server(client: CoolifyClient, uuid: str) -> str:
"""Delete a server."""
await client.delete_server(uuid)
result = {"success": True, "message": f"Server '{uuid}' deleted"}
return json.dumps(result, indent=2, ensure_ascii=False)
async def get_server_resources(client: CoolifyClient, uuid: str) -> str:
"""Get server resources."""
resources = await client.get_server_resources(uuid)
result = {"success": True, "count": len(resources), "resources": resources}
return json.dumps(result, indent=2, ensure_ascii=False)
async def get_server_domains(client: CoolifyClient, uuid: str) -> str:
"""Get server domains."""
domains = await client.get_server_domains(uuid)
result = {"success": True, "domains": domains}
return json.dumps(result, indent=2, ensure_ascii=False)
async def validate_server(client: CoolifyClient, uuid: str) -> str:
"""Validate server."""
result_data = await client.validate_server(uuid)
result = {
"success": True,
"message": "Server validation started",
"data": result_data,
}
return json.dumps(result, indent=2, ensure_ascii=False)

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)

115
plugins/coolify/plugin.py Normal file
View File

@@ -0,0 +1,115 @@
"""
Coolify Plugin — Clean Architecture
AI-driven deployment management for Coolify instances.
Modular handlers for applications, deployments, and servers.
"""
from typing import Any
from plugins.base import BasePlugin
from plugins.coolify import handlers
from plugins.coolify.client import CoolifyClient
class CoolifyPlugin(BasePlugin):
"""
Coolify project plugin — Clean architecture.
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
def get_plugin_name() -> str:
"""Return plugin type identifier."""
return "coolify"
@staticmethod
def get_required_config_keys() -> list[str]:
"""Return required configuration keys."""
return ["url", "token"]
def __init__(self, config: dict[str, Any], project_id: str | None = None):
"""
Initialize Coolify plugin with handlers.
Args:
config: Configuration dictionary containing:
- url: Coolify instance URL
- token: API token for Bearer authentication
project_id: Optional project ID (auto-generated if not provided)
"""
super().__init__(config, project_id=project_id)
self.client = CoolifyClient(
site_url=config["url"],
token=config["token"],
)
@staticmethod
def get_tool_specifications() -> list[dict[str, Any]]:
"""
Return all tool specifications for ToolGenerator.
Returns:
List of tool specification dictionaries
"""
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
def __getattr__(self, name: str):
"""
Dynamically delegate method calls to appropriate handlers.
Args:
name: Method name being called
Returns:
Handler function from the appropriate handler module
"""
handler_modules = [
handlers.applications,
handlers.databases,
handlers.deployments,
handlers.projects,
handlers.servers,
handlers.services,
]
for module in handler_modules:
if hasattr(module, name):
func = getattr(module, name)
async def wrapper(_func=func, **kwargs):
return await _func(self.client, **kwargs)
return wrapper
raise AttributeError(f"'{self.__class__.__name__}' object has no attribute '{name}'")
async def health_check(self) -> dict[str, Any]:
"""
Check if Coolify instance is accessible.
Returns:
Dict containing health check result
"""
try:
await self.client.request("GET", "version")
return {"healthy": True, "message": "Coolify instance is accessible"}
except Exception as e:
return {"healthy": False, "message": f"Coolify health check failed: {str(e)}"}

View File

@@ -0,0 +1 @@
"""Coolify Plugin Schemas"""

View File

@@ -0,0 +1,55 @@
"""
Common Pydantic Schemas for Coolify Plugin
Shared validation schemas used across Coolify handlers.
"""
from datetime import datetime
from typing import Any
from pydantic import BaseModel, ConfigDict, Field
class Site(BaseModel):
"""Coolify site configuration."""
model_config = ConfigDict(extra="forbid")
site_id: str = Field(..., description="Site identifier")
url: str = Field(..., description="Coolify instance URL")
token: str = Field(..., description="API token for Bearer authentication")
alias: str | None = Field(None, description="Site alias")
class PaginationParams(BaseModel):
"""Pagination parameters for list endpoints."""
model_config = ConfigDict(extra="forbid")
skip: int = Field(default=0, ge=0, description="Number of items to skip")
take: int = Field(default=10, ge=1, le=100, description="Number of items to take")
class ErrorResponse(BaseModel):
"""Standard error response."""
error: bool = Field(default=True)
message: str = Field(..., description="Error message")
details: dict[str, Any] | None = Field(None, description="Additional error details")
class SuccessResponse(BaseModel):
"""Standard success response."""
success: bool = Field(default=True)
message: str = Field(..., description="Success message")
data: dict[str, Any] | None = Field(None, description="Response data")
class CoolifyTimestamps(BaseModel):
"""Common timestamp fields."""
model_config = ConfigDict(extra="allow")
created_at: datetime | None = None
updated_at: datetime | None = None

View File

@@ -1,6 +1,6 @@
[project] [project]
name = "mcphub-server" name = "mcphub-server"
version = "3.6.0" version = "3.9.0"
description = "AI-native management hub for WordPress, WooCommerce, and self-hosted services via Model Context Protocol (MCP)" description = "AI-native management hub for WordPress, WooCommerce, and self-hosted services via Model Context Protocol (MCP)"
authors = [ authors = [
{name = "MCP Hub", email = "contact@mcphub.dev"} {name = "MCP Hub", email = "contact@mcphub.dev"}

View File

@@ -58,16 +58,22 @@ from core import (
set_api_key_context, set_api_key_context,
) )
from core.dashboard.routes import ( from core.dashboard.routes import (
# E.3: Site Management routes # F.7b: Per-site tool visibility
api_bulk_toggle_site_tools,
api_create_key, api_create_key,
# E.3: Site Management routes
api_create_site, api_create_site,
api_delete_key, api_delete_key,
api_delete_site, api_delete_site,
api_get_config, api_get_config,
api_list_keys, api_list_keys,
api_list_site_tools,
api_list_sites, api_list_sites,
api_patch_site_tool,
# K.5: Settings routes # K.5: Settings routes
api_save_setting, api_save_setting,
api_scope_presets,
api_set_site_tool_scope,
api_test_site, api_test_site,
api_update_site, api_update_site,
# E.2: OAuth Social Login routes # E.2: OAuth Social Login routes
@@ -80,7 +86,6 @@ from core.dashboard.routes import (
dashboard_api_keys_create, dashboard_api_keys_create,
dashboard_api_keys_delete, dashboard_api_keys_delete,
# K.3: API Keys routes # K.3: API Keys routes
dashboard_api_keys_list,
dashboard_api_keys_revoke, dashboard_api_keys_revoke,
dashboard_api_project_detail, dashboard_api_project_detail,
dashboard_api_projects, dashboard_api_projects,
@@ -88,11 +93,11 @@ from core.dashboard.routes import (
# K.4: Audit Logs routes # K.4: Audit Logs routes
dashboard_audit_logs_list, dashboard_audit_logs_list,
# E.3: Dashboard pages # E.3: Dashboard pages
dashboard_connect_page,
# K.5: Health Monitoring routes
dashboard_health_page, dashboard_health_page,
dashboard_health_projects_partial, dashboard_health_projects_partial,
dashboard_home, dashboard_home,
# F.7b session 2: Unified keys page
dashboard_keys_unified,
dashboard_login_page, dashboard_login_page,
dashboard_login_submit, dashboard_login_submit,
dashboard_logout, dashboard_logout,
@@ -114,6 +119,7 @@ from core.dashboard.routes import (
dashboard_sites_add, dashboard_sites_add,
dashboard_sites_edit, dashboard_sites_edit,
dashboard_sites_list, dashboard_sites_list,
dashboard_sites_view,
# Bug C: User OAuth client routes # Bug C: User OAuth client routes
dashboard_user_oauth_clients_create, dashboard_user_oauth_clients_create,
dashboard_user_oauth_clients_delete, dashboard_user_oauth_clients_delete,
@@ -1885,6 +1891,24 @@ def register_project_tools():
except Exception as e: except Exception as e:
logger.error(f"Failed to generate Directus tools: {e}", exc_info=True) logger.error(f"Failed to generate Directus tools: {e}", exc_info=True)
# Generate tools for Coolify (Phase F.17 - Deployment Management)
logger.info("Generating Coolify tools from plugin specifications...")
try:
from plugins.coolify.plugin import CoolifyPlugin
coolify_tools = tool_generator.generate_tools(CoolifyPlugin, "coolify")
logger.info(f"Generated {len(coolify_tools)} Coolify tools from ToolGenerator")
# Register Coolify tools in ToolRegistry
for tool_def in coolify_tools:
try:
tool_registry.register(tool_def)
except Exception as e:
logger.error(f"Failed to register Coolify tool {tool_def.name}: {e}")
except Exception as e:
logger.error(f"Failed to generate Coolify tools: {e}", exc_info=True)
logger.info(f"Registered {tool_registry.get_count()} tools in ToolRegistry") logger.info(f"Registered {tool_registry.get_count()} tools in ToolRegistry")
# Register tools with FastMCP # Register tools with FastMCP
@@ -4765,6 +4789,7 @@ def create_multi_endpoint_app(transport: str = "streamable-http"):
Route("/dashboard/profile", dashboard_profile_page, methods=["GET"]), Route("/dashboard/profile", dashboard_profile_page, methods=["GET"]),
Route("/dashboard/sites/add", dashboard_sites_add, 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}/edit", dashboard_sites_edit, methods=["GET"]),
Route("/dashboard/sites/{id}", dashboard_sites_view, methods=["GET"]),
Route("/dashboard/sites", dashboard_sites_list, methods=["GET"]), Route("/dashboard/sites", dashboard_sites_list, methods=["GET"]),
# Bug C: User OAuth client routes (must be before /dashboard/connect) # Bug C: User OAuth client routes (must be before /dashboard/connect)
Route( Route(
@@ -4772,7 +4797,12 @@ def create_multi_endpoint_app(transport: str = "streamable-http"):
dashboard_user_oauth_clients_list, dashboard_user_oauth_clients_list,
methods=["GET"], 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) # F.3: Service pages (must be before /dashboard catch-all)
Route("/dashboard/services", dashboard_services_list, methods=["GET"]), Route("/dashboard/services", dashboard_services_list, methods=["GET"]),
Route("/dashboard/services/{plugin_type}", dashboard_service_page, methods=["GET"]), Route("/dashboard/services/{plugin_type}", dashboard_service_page, methods=["GET"]),
@@ -4794,8 +4824,14 @@ def create_multi_endpoint_app(transport: str = "streamable-http"):
dashboard_api_project_detail, dashboard_api_project_detail,
methods=["GET"], methods=["GET"],
), ),
# Dashboard API Keys routes (Phase K.3) # Dashboard API Keys routes (Phase K.3 + F.7b session 2: unified /dashboard/keys)
Route("/dashboard/api-keys", dashboard_api_keys_list, methods=["GET"]), 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/create", dashboard_api_keys_create, methods=["POST"]),
Route( Route(
"/api/dashboard/api-keys/{key_id}/revoke", dashboard_api_keys_revoke, methods=["POST"] "/api/dashboard/api-keys/{key_id}/revoke", dashboard_api_keys_revoke, methods=["POST"]
@@ -4844,6 +4880,24 @@ def create_multi_endpoint_app(transport: str = "streamable-http"):
Route("/api/keys/{id}", api_delete_key, methods=["DELETE"]), Route("/api/keys/{id}", api_delete_key, methods=["DELETE"]),
# Config snippet API (E.3) # Config snippet API (E.3)
Route("/api/config/{alias}", api_get_config, methods=["GET"]), 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 # OAuth endpoints
Route("/.well-known/oauth-authorization-server", oauth_metadata, methods=["GET"]), Route("/.well-known/oauth-authorization-server", oauth_metadata, methods=["GET"]),
# Path-specific OAuth protected resource metadata (must come before root) # Path-specific OAuth protected resource metadata (must come before root)

452
tests/test_coolify.py Normal file
View File

@@ -0,0 +1,452 @@
"""
Tests for Coolify MCP Plugin
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 applications, deployments, servers
from plugins.coolify.plugin import CoolifyPlugin
# === Fixtures ===
@pytest.fixture
def client():
"""Create a CoolifyClient instance for testing."""
return CoolifyClient(site_url="https://coolify.test.com", token="test-token-123")
@pytest.fixture
def plugin():
"""Create a CoolifyPlugin instance for testing."""
config = {"url": "https://coolify.test.com", "token": "test-token-123"}
return CoolifyPlugin(config, project_id="coolify_test")
# === Client Tests ===
class TestCoolifyClient:
"""Tests for CoolifyClient."""
def test_init(self, client):
"""Test client initialization."""
assert client.api_base == "https://coolify.test.com/api/v1"
assert client.token == "test-token-123"
def test_headers(self, client):
"""Test Bearer token authentication headers."""
headers = client._get_headers()
assert headers["Authorization"] == "Bearer test-token-123"
assert headers["Content-Type"] == "application/json"
assert headers["Accept"] == "application/json"
def test_url_trailing_slash(self):
"""Test URL normalization."""
client = CoolifyClient(site_url="https://coolify.test.com/", token="t")
assert client.api_base == "https://coolify.test.com/api/v1"
# === Plugin Tests ===
class TestCoolifyPlugin:
"""Tests for CoolifyPlugin."""
def test_plugin_name(self):
"""Test plugin name."""
assert CoolifyPlugin.get_plugin_name() == "coolify"
def test_required_config_keys(self):
"""Test required config keys."""
assert CoolifyPlugin.get_required_config_keys() == ["url", "token"]
def test_missing_config_raises(self):
"""Test that missing config raises ValueError."""
with pytest.raises(ValueError, match="Missing required"):
CoolifyPlugin({"url": "https://coolify.test.com"})
def test_tool_specifications(self):
"""Test that tool specifications are returned."""
specs = CoolifyPlugin.get_tool_specifications()
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:
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 = CoolifyPlugin.get_tool_specifications()
names = [s["name"] for s in specs]
assert len(names) == len(set(names))
def test_plugin_init(self, plugin):
"""Test plugin initialization."""
assert plugin.client is not None
assert plugin.client.token == "test-token-123"
# === Application Handler Tests ===
class TestApplicationHandlers:
"""Tests for application handler functions."""
def test_app_tool_count(self):
"""Test application tool specification count."""
specs = applications.get_tool_specifications()
assert len(specs) == 17
@pytest.mark.asyncio
async def test_list_applications(self, client):
"""Test list_applications handler."""
mock_apps = [
{"uuid": "app-1", "name": "test-app", "status": "running"},
{"uuid": "app-2", "name": "test-app-2", "status": "stopped"},
]
client.list_applications = AsyncMock(return_value=mock_apps)
result = await applications.list_applications(client)
data = json.loads(result)
assert data["success"] is True
assert data["count"] == 2
assert len(data["applications"]) == 2
client.list_applications.assert_called_once_with(tag=None)
@pytest.mark.asyncio
async def test_get_application(self, client):
"""Test get_application handler."""
mock_app = {"uuid": "app-1", "name": "mcphub", "status": "running", "fqdn": "mcp.test.com"}
client.get_application = AsyncMock(return_value=mock_app)
result = await applications.get_application(client, uuid="app-1")
data = json.loads(result)
assert data["success"] is True
assert data["application"]["name"] == "mcphub"
client.get_application.assert_called_once_with("app-1")
@pytest.mark.asyncio
async def test_start_application(self, client):
"""Test start_application handler."""
mock_response = {"message": "Deployment request queued.", "deployment_uuid": "dep-123"}
client.start_application = AsyncMock(return_value=mock_response)
result = await applications.start_application(client, uuid="app-1", force=True)
data = json.loads(result)
assert data["success"] is True
assert "deployment queued" in data["message"]
client.start_application.assert_called_once_with("app-1", force=True, instant_deploy=False)
@pytest.mark.asyncio
async def test_stop_application(self, client):
"""Test stop_application handler."""
mock_response = {"message": "Application stopping request queued."}
client.stop_application = AsyncMock(return_value=mock_response)
result = await applications.stop_application(client, uuid="app-1")
data = json.loads(result)
assert data["success"] is True
client.stop_application.assert_called_once_with("app-1", docker_cleanup=True)
@pytest.mark.asyncio
async def test_restart_application(self, client):
"""Test restart_application handler."""
mock_response = {"message": "Restart request queued.", "deployment_uuid": "dep-456"}
client.restart_application = AsyncMock(return_value=mock_response)
result = await applications.restart_application(client, uuid="app-1")
data = json.loads(result)
assert data["success"] is True
client.restart_application.assert_called_once_with("app-1")
@pytest.mark.asyncio
async def test_get_application_logs(self, client):
"""Test get_application_logs handler."""
mock_logs = {"logs": "2026-04-02 Starting application..."}
client.get_application_logs = AsyncMock(return_value=mock_logs)
result = await applications.get_application_logs(client, uuid="app-1", lines=50)
data = json.loads(result)
assert data["success"] is True
client.get_application_logs.assert_called_once_with("app-1", lines=50)
@pytest.mark.asyncio
async def test_list_application_envs(self, client):
"""Test list_application_envs handler."""
mock_envs = [
{"key": "DATABASE_URL", "value": "postgres://..."},
{"key": "SECRET_KEY", "value": "***"},
]
client.list_application_envs = AsyncMock(return_value=mock_envs)
result = await applications.list_application_envs(client, uuid="app-1")
data = json.loads(result)
assert data["success"] is True
assert data["count"] == 2
@pytest.mark.asyncio
async def test_create_application_env(self, client):
"""Test create_application_env handler."""
mock_response = {"uuid": "env-123"}
client.create_application_env = AsyncMock(return_value=mock_response)
result = await applications.create_application_env(
client, uuid="app-1", key="NEW_VAR", value="test_value"
)
data = json.loads(result)
assert data["success"] is True
assert "NEW_VAR" in data["message"]
@pytest.mark.asyncio
async def test_delete_application_env(self, client):
"""Test delete_application_env handler."""
client.delete_application_env = AsyncMock(return_value=None)
result = await applications.delete_application_env(client, uuid="app-1", env_uuid="env-123")
data = json.loads(result)
assert data["success"] is True
client.delete_application_env.assert_called_once_with("app-1", "env-123")
@pytest.mark.asyncio
async def test_create_application_public(self, client):
"""Test create_application_public handler."""
mock_response = {"uuid": "new-app-uuid"}
client.create_application_public = AsyncMock(return_value=mock_response)
result = await applications.create_application_public(
client,
project_uuid="proj-1",
server_uuid="srv-1",
environment_name="production",
git_repository="https://github.com/test/repo",
git_branch="main",
build_pack="nixpacks",
ports_exposes="3000",
)
data = json.loads(result)
assert data["success"] is True
assert data["application"]["uuid"] == "new-app-uuid"
@pytest.mark.asyncio
async def test_update_application(self, client):
"""Test update_application handler."""
mock_response = {"uuid": "app-1"}
client.update_application = AsyncMock(return_value=mock_response)
result = await applications.update_application(
client, uuid="app-1", name="new-name", domains="app.test.com"
)
data = json.loads(result)
assert data["success"] is True
client.update_application.assert_called_once_with(
"app-1", {"name": "new-name", "domains": "app.test.com"}
)
@pytest.mark.asyncio
async def test_delete_application(self, client):
"""Test delete_application handler."""
mock_response = {"message": "Application deleted."}
client.delete_application = AsyncMock(return_value=mock_response)
result = await applications.delete_application(client, uuid="app-1")
data = json.loads(result)
assert data["success"] is True
# === Deployment Handler Tests ===
class TestDeploymentHandlers:
"""Tests for deployment handler functions."""
def test_deployment_tool_count(self):
"""Test deployment tool specification count."""
specs = deployments.get_tool_specifications()
assert len(specs) == 5
@pytest.mark.asyncio
async def test_list_deployments(self, client):
"""Test list_deployments handler."""
mock_deps = [
{"deployment_uuid": "dep-1", "status": "in_progress"},
{"deployment_uuid": "dep-2", "status": "queued"},
]
client.list_deployments = AsyncMock(return_value=mock_deps)
result = await deployments.list_deployments(client)
data = json.loads(result)
assert data["success"] is True
assert data["count"] == 2
@pytest.mark.asyncio
async def test_deploy(self, client):
"""Test deploy handler."""
mock_response = {
"deployments": [
{"message": "Deploying", "resource_uuid": "app-1", "deployment_uuid": "dep-1"}
]
}
client.deploy = AsyncMock(return_value=mock_response)
result = await deployments.deploy(client, uuid="app-1", force=True)
data = json.loads(result)
assert data["success"] is True
client.deploy.assert_called_once_with(tag=None, uuid="app-1", force=True)
@pytest.mark.asyncio
async def test_cancel_deployment(self, client):
"""Test cancel_deployment handler."""
mock_response = {"message": "Cancelled", "status": "cancelled-by-user"}
client.cancel_deployment = AsyncMock(return_value=mock_response)
result = await deployments.cancel_deployment(client, uuid="dep-1")
data = json.loads(result)
assert data["success"] is True
client.cancel_deployment.assert_called_once_with("dep-1")
@pytest.mark.asyncio
async def test_list_app_deployments(self, client):
"""Test list_app_deployments handler."""
mock_deps = [{"deployment_uuid": "dep-1", "status": "finished"}]
client.list_app_deployments = AsyncMock(return_value=mock_deps)
result = await deployments.list_app_deployments(client, uuid="app-1", skip=0, take=5)
data = json.loads(result)
assert data["success"] is True
assert data["count"] == 1
client.list_app_deployments.assert_called_once_with("app-1", skip=0, take=5)
# === Server Handler Tests ===
class TestServerHandlers:
"""Tests for server handler functions."""
def test_server_tool_count(self):
"""Test server tool specification count."""
specs = servers.get_tool_specifications()
assert len(specs) == 8
@pytest.mark.asyncio
async def test_list_servers(self, client):
"""Test list_servers handler."""
mock_servers = [
{"uuid": "srv-1", "name": "main-server", "ip": "1.2.3.4"},
]
client.list_servers = AsyncMock(return_value=mock_servers)
result = await servers.list_servers(client)
data = json.loads(result)
assert data["success"] is True
assert data["count"] == 1
assert data["servers"][0]["name"] == "main-server"
@pytest.mark.asyncio
async def test_get_server(self, client):
"""Test get_server handler."""
mock_server = {
"uuid": "srv-1",
"name": "main-server",
"ip": "1.2.3.4",
"settings": {"is_reachable": True},
}
client.get_server = AsyncMock(return_value=mock_server)
result = await servers.get_server(client, uuid="srv-1")
data = json.loads(result)
assert data["success"] is True
assert data["server"]["settings"]["is_reachable"] is True
@pytest.mark.asyncio
async def test_get_server_resources(self, client):
"""Test get_server_resources handler."""
mock_resources = [
{"uuid": "app-1", "name": "mcphub", "type": "application", "status": "running"},
{"uuid": "db-1", "name": "postgres", "type": "database", "status": "running"},
]
client.get_server_resources = AsyncMock(return_value=mock_resources)
result = await servers.get_server_resources(client, uuid="srv-1")
data = json.loads(result)
assert data["success"] is True
assert data["count"] == 2
@pytest.mark.asyncio
async def test_get_server_domains(self, client):
"""Test get_server_domains handler."""
mock_domains = [{"ip": "1.2.3.4", "domains": ["mcp.test.com", "blog.test.com"]}]
client.get_server_domains = AsyncMock(return_value=mock_domains)
result = await servers.get_server_domains(client, uuid="srv-1")
data = json.loads(result)
assert data["success"] is True
assert len(data["domains"]) == 1
@pytest.mark.asyncio
async def test_validate_server(self, client):
"""Test validate_server handler."""
mock_response = {"message": "Validation started."}
client.validate_server = AsyncMock(return_value=mock_response)
result = await servers.validate_server(client, uuid="srv-1")
data = json.loads(result)
assert data["success"] is True
assert "validation started" in data["message"].lower()
# === Health Check Tests ===
class TestHealthCheck:
"""Tests for plugin health check."""
@pytest.mark.asyncio
async def test_health_check_success(self, plugin):
"""Test successful health check."""
plugin.client.request = AsyncMock(return_value={"version": "4.0.0"})
result = await plugin.health_check()
assert result["healthy"] is True
@pytest.mark.asyncio
async def test_health_check_failure(self, plugin):
"""Test failed health check."""
plugin.client.request = AsyncMock(side_effect=Exception("Connection refused"))
result = await plugin.health_check()
assert result["healthy"] is False
assert "Connection refused" in result["message"]

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): 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 server import create_multi_endpoint_app
from starlette.testclient import TestClient from starlette.testclient import TestClient
import core.dashboard.routes
import core.site_api
import core.user_keys
app = create_multi_endpoint_app() app = create_multi_endpoint_app()
client = TestClient(app) client = TestClient(app, follow_redirects=False)
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())
resp = client.get("/dashboard/connect") resp = client.get("/dashboard/connect")
assert resp.status_code == 200 # /dashboard/connect now redirects 301 to /dashboard/keys
assert "Test" in resp.text assert resp.status_code == 301
assert "Key" in resp.text 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: class TestModuleHelpers:
"""Test get_database() and initialize_database() helpers.""" """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) @pytest.fixture(autouse=True)
def _clear_tool_cache(): def _clear_tool_cache():
"""Clear the tool schema cache between tests.""" """No-op: the per-plugin tool schema cache was removed in F.7.
import core.user_endpoints as mod
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 yield
mod._tool_schema_cache.clear()
@pytest.fixture @pytest.fixture