4 Commits

Author SHA1 Message Date
788439e377 feat(F.7+F.17): v3.11.0 — Coolify plugin (67 tools) + tool access overhaul
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
Catch-up sync spanning v3.7.0 → v3.11.0 of the internal repo.

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

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

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

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

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

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-14 21:05:07 +02:00
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
55 changed files with 6920 additions and 241 deletions

5
.gitignore vendored
View File

@@ -165,3 +165,8 @@ pytest-cache-files-*/
worktrees/
/.agents
uv.lock
# planning-with-files skill artifacts (session-local)
task_plan.md
findings.md
progress.md

View File

@@ -5,6 +5,100 @@ All notable changes to MCP Hub will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [3.11.0] — 2026-04-14
### Plugin-specific access levels, regression tests, UX polish (Track F.7d)
Polish pass on top of v3.10.0 tool-access hardening. Access levels and credential guidance are now tailored per plugin; UX signals have been tightened.
#### Added
- **Plugin-aware scope presets** (`core/tool_access.py::get_scope_presets_for_plugin`): WordPress / WordPress Advanced / WooCommerce / Gitea / Coolify (5 tiers) / OpenPanel — each with tailored EN+FA labels and one-line hints. Custom preset always available.
- **Admin Tools card** on per-service dashboard page (4-col grid when `admin` tools > 0).
- **Credential requirement notice** on Tool Access: describes exactly what the saved Credential must grant, per plugin/tier (WP Application Password, WooCommerce REST key scope, Gitea scoped tokens, OpenPanel client mode, Coolify API token permissions).
- **Auto-scoped quick key** on the Connect section (site_id injected → key capped to this site's tier).
#### Changed
- Scope buttons rendered dynamically from `scope_presets_json` (was: hardcoded 4 tiers).
- WordPress / WP Advanced / WooCommerce: dropped redundant `write` tier (admin-scope tool count = 0) — now **Read Only + Full Access + Custom**.
- Coolify tool list visually filtered by category for all 5 tiers in JS (mirrors `SCOPE_TO_CATEGORIES`).
- OpenPanel Client ID hint clarifies the correct dashboard path (`{org}/{project-id}/settings/clients`).
---
## [3.10.0] — 2026-04-12
### Tool access hardening + per-site API keys + Tailwind build (Track F.7c)
#### Added
- **Per-site API keys** (DB migration v8 adds `site_id` column): UI site selector in create modal; endpoint enforces site-scoped key access.
- **Configuration Snippet section** on keys page (transport / WP / bearer notes).
- **Pre-built Tailwind CSS** via `scripts/build-css.sh` (replaces CDN).
- **Unified site manage page**: collapsible Connection / Tool Access / Connect sections.
#### Fixed
- **CSRF bug**: removed conflicting `getCsrf()` that tried to read an `httponly` cookie; now relies on the global meta-tag CSRF interceptor in `head_assets.html`.
- **Tool scope UI**: tools outside the selected scope are visually dimmed; summary shows in-scope count.
- Sidebar logo hidden when collapsed.
#### Changed
- **828 tests passing** (was: 766).
- `/dashboard/connect` and `/dashboard/edit` redirect to unified site page.
---
## [3.9.0] — 2026-04-09
### Tool access UI + unified keys page (Track F.7b)
#### Added
- `sites/edit`: Tool Access card with scope dropdown + per-tool toggle grid.
- `sites/view`: new `/dashboard/sites/{id}` page with MCP URL + config snippets.
- `/dashboard/keys`: unified page (user view with scope selector, admin view).
- Sidebar: "Connect" replaced with "API Keys" → `/dashboard/keys`.
- `sites/list`: added "Connect" link per site row.
- 12 new tests (keys unified + tool access UI smoke).
#### Changed
- **Site-scoped tool access** (schema v7): dropped `user_tool_toggles` in favour of `site_tool_toggles(site_id)`; added `sites.tool_scope` column (default `admin`). Resolves the limitation that a user with multiple sites of the same plugin (e.g. Coolify production + staging) needed independent tool filters.
- `ToolAccessManager` now takes `site_id`; `get_visible_tools` intersects key-scope categories with the site's `tool_scope` preset, then applies `site_tool_toggles` overrides.
- Dashboard API routes moved under `/api/sites/{site_id}/...`.
- `/dashboard/connect``/dashboard/keys` (301); `/dashboard/api-keys``/dashboard/keys` (301).
---
## [3.8.0] — 2026-04-07
### Coolify plugin Phase 2+3 — projects, databases, services (Track F.17)
#### Added
- **Coolify projects handler** (8 tools): CRUD projects + environments.
- **Coolify databases handler** (16 tools): CRUD, lifecycle, 6 DB types, backups.
- **Coolify services handler** (13 tools): CRUD, lifecycle, env vars.
- **Scope-based tool visibility + per-user toggles** (Track F.7 core): every `ToolDefinition` now carries `category` + `sensitivity` fields. Extends the scope model with `deploy` and `read:sensitive`. `tools/list` filters by the API key's scopes via `SCOPE_TO_CATEGORIES` in `core/tool_access.py`. Coolify handlers are annotated; other plugins keep the default `read` category (backward compatible).
#### Changed
- **Coolify plugin total: 67 tools** (was 30). Platform total: **633 tools**.
- **766 tests passing** (48 new).
---
## [3.7.0] — 2026-04-04
### Coolify MCP plugin — Phase 1 MVP (Track F.17)
#### Added
- **Coolify plugin** (30 tools, admin-only):
- **Applications** (17): CRUD, lifecycle (start/stop/restart), logs, env vars.
- **Deployments** (5): list, get, cancel, deploy by tag/UUID, app history.
- **Servers** (8): CRUD, resources, domains, validation.
- Coolify credential fields added to `site_api` (URL + API token), with health check.
- Registered in `server.py` startup so Coolify tools show up in `/coolify/mcp`.
#### Changed
- Platform plugin count: **10** (added Coolify). Tool count: **597**.
---
## [3.6.0] — 2026-04-02
### Gitea Plugin — Public Release (Track F.16)

View File

@@ -4,7 +4,7 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
## Project Overview
**MCP Hub** — a Python MCP (Model Context Protocol) server that manages multiple self-hosted services through a unified plugin architecture. Supports 10 plugin types (WordPress, WooCommerce, WordPress Advanced, Gitea, n8n, Supabase, OpenPanel, Appwrite, Directus, Coolify) with 597 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 633 tools total. The tool count stays constant regardless of how many sites are configured.
## Quick Setup
@@ -70,7 +70,7 @@ All configured in `pyproject.toml`:
```
├── server.py # Primary entry point
├── core/ # Layer 1: Core system modules
├── plugins/ # Layer 2: Plugin system (9 plugins)
├── plugins/ # Layer 2: Plugin system (10 plugins)
├── core/templates/ # Jinja2 templates (dashboard + OAuth)
├── tests/ # Organized test suite
├── scripts/ # Setup & deployment scripts
@@ -85,7 +85,7 @@ All configured in `pyproject.toml`:
```
Layer 1: Core System (core/) — Auth, site discovery, tool registry, health, rate limiting
Layer 2: Plugin System (plugins/) — 9 plugin types, each with handlers + schemas
Layer 2: Plugin System (plugins/) — 10 plugin types, each with handlers + schemas
Layer 3: API & Web UI (server.py + core/dashboard/) — FastMCP server, Starlette routes, dashboard
```

View File

@@ -2,7 +2,7 @@
**The AI-native management hub for WordPress, WooCommerce, and self-hosted services.**
565 tools across 9 plugins. Connect your sites, stores, repos, and databases — manage them all through Claude, ChatGPT, Cursor, or any MCP client.
633 tools across 10 plugins (incl. Coolify). Connect your sites, stores, repos, and databases — manage them all through Claude, ChatGPT, Cursor, or any MCP client.
> **Don't want to self-host?** Try the hosted instance at **[mcp.palebluedot.live](https://mcp.palebluedot.live)** — log in with GitHub or Google, add your sites, and connect your AI client in minutes.
@@ -92,7 +92,7 @@ Use the most specific endpoint for your use case:
| `/u/{user_id}/{alias}/mcp` | 22-100 | No (pre-scoped) | Hosted/OAuth users |
| `/project/{alias}/mcp` | 22-100 | No (pre-scoped) | Single-site workflow |
| `/{plugin}/mcp` | 23-101 | Yes | Multi-site management |
| `/mcp` | 565 | Yes | Admin & discovery only |
| `/mcp` | 633 | Yes | Admin & discovery only |
Available plugin endpoints: `/wordpress/mcp`, `/woocommerce/mcp`, `/wordpress-advanced/mcp`, `/gitea/mcp`, `/n8n/mcp`, `/supabase/mcp`, `/openpanel/mcp`, `/appwrite/mcp`, `/directus/mcp`, `/system/mcp`

View File

@@ -13,8 +13,8 @@ Connect your sites, stores, repos, and databases — manage them all through Cla
[![Python 3.11+](https://img.shields.io/badge/python-3.11+-3776ab.svg)](https://www.python.org/)
[![PyPI](https://img.shields.io/pypi/v/mcphub-server.svg)](https://pypi.org/project/mcphub-server/)
[![Docker](https://img.shields.io/docker/v/airano/mcphub?label=docker)](https://hub.docker.com/r/airano/mcphub)
[![Tests: 686 passing](https://img.shields.io/badge/tests-686%20passing-brightgreen.svg)]()
[![Tools: 596](https://img.shields.io/badge/tools-596-orange.svg)]()
[![Tests: 828 passing](https://img.shields.io/badge/tests-828%20passing-brightgreen.svg)]()
[![Tools: 633](https://img.shields.io/badge/tools-633-orange.svg)]()
[![CI](https://github.com/airano-ir/mcphub/actions/workflows/ci.yml/badge.svg)](https://github.com/airano-ir/mcphub/actions/workflows/ci.yml)
</div>
@@ -25,7 +25,7 @@ Connect your sites, stores, repos, and databases — manage them all through Cla
WordPress powers 43% of the web. WooCommerce runs 36% of online stores. Yet **no MCP server existed** for managing them through AI — until now.
MCP Hub is the first MCP server that lets you manage WordPress, WooCommerce, and 8 other self-hosted services through any AI assistant. Instead of clicking through dashboards, just tell your AI what to do:
MCP Hub is the first MCP server that lets you manage WordPress, WooCommerce, and 7 other self-hosted services through any AI assistant. Instead of clicking through dashboards, just tell your AI what to do:
> *"Update the SEO meta description for all WooCommerce products that don't have one"*
>
@@ -49,22 +49,22 @@ MCP Hub is the first MCP server that lets you manage WordPress, WooCommerce, and
---
## 565 Tools Across 9 Plugins
## 633 Tools Across 10 Plugins
| Plugin | Tools | What You Can Do |
|--------|-------|-----------------|
| **WordPress** | 67 | Posts, pages, media, users, menus, taxonomies, SEO (Rank Math/Yoast) |
| **WooCommerce** | 28 | Products, orders, customers, coupons, reports, shipping |
| **WordPress Advanced** | 22 | Database ops, bulk operations, WP-CLI, system management |
| **Gitea** | 56 | Repos, issues, pull requests, releases, webhooks, organizations |
| **Gitea** | 58 | Repos, issues, pull requests, releases, webhooks, organizations, labels |
| **n8n** | 56 | Workflows, executions, credentials, variables, audit |
| **Supabase** | 70 | Database, auth, storage, edge functions, realtime |
| **OpenPanel** | 42 | Events, export, insights, profiles, projects, system |
| **Appwrite** | 100 | Databases, auth, storage, functions, teams, messaging |
| **Directus** | 100 | Collections, items, users, files, flows, permissions |
| **Coolify** | 30 | Applications, deployments, servers, env vars, logs |
| **System** | 24 | Health monitoring, API keys, OAuth management, audit |
| **Total** | **596** | Constant count — scales to unlimited sites |
| **Coolify** | 67 | Applications, deployments, servers, projects, databases, services |
| **System** | 23 | Health monitoring, API keys, OAuth management, audit |
| **Total** | **633** | Constant count — scales to unlimited sites |
---
@@ -165,7 +165,6 @@ MASTER_API_KEY=your-secure-key-here
| OpenPanel | URL, Client ID, Client Secret | OpenPanel Dashboard → Project Settings |
| Appwrite | URL, API Key, Project ID | Appwrite Console → Settings → API Keys |
| Directus | URL, Static Token | Directus Admin → Settings |
| Coolify | URL, API Token | Coolify → Keys & Tokens → API tokens |
</details>
@@ -267,30 +266,30 @@ MCP Hub supports **Open Dynamic Client Registration** (RFC 7591). ChatGPT can au
## Architecture
```
/mcp → Admin endpoint (all 565 tools)
/system/mcp → System tools only (24 tools)
/mcp → Admin endpoint (all 633 tools)
/system/mcp → System tools only (23 tools)
/wordpress/mcp → WordPress tools (67 tools)
/woocommerce/mcp → WooCommerce tools (28 tools)
/wordpress-advanced/mcp → WordPress Advanced tools (22 tools)
/gitea/mcp → Gitea tools (56 tools)
/gitea/mcp → Gitea tools (58 tools)
/n8n/mcp → n8n tools (56 tools)
/supabase/mcp → Supabase tools (70 tools)
/openpanel/mcp → OpenPanel tools (42 tools)
/appwrite/mcp → Appwrite tools (100 tools)
/directus/mcp → Directus tools (100 tools)
/coolify/mcp → Coolify tools (30 tools)
/coolify/mcp → Coolify tools (67 tools)
/project/{alias}/mcp → Per-project endpoint (auto-injects site)
/u/{user_id}/{alias}/mcp → Per-user endpoint (hosted/OAuth users)
```
**Recommendation**: Use plugin-specific endpoints instead of `/mcp` (596 tools) to minimize token usage.
**Recommendation**: Use plugin-specific endpoints instead of `/mcp` (633 tools) to minimize token usage.
| Endpoint | Use Case | Tools |
|----------|----------|------:|
| `/u/{user_id}/{alias}/mcp` | Hosted users (OAuth login) | 22-100 |
| `/project/{alias}/mcp` | Single-site workflow (recommended) | 22-100 |
| `/{plugin}/mcp` | Multi-site management | 23-101 |
| `/mcp` | Admin & discovery only | 565 |
| `/mcp` | Admin & discovery only | 633 |
### Security
@@ -300,7 +299,7 @@ MCP Hub supports **Open Dynamic Client Registration** (RFC 7591). ChatGPT can au
- **GDPR-compliant audit logging** with automatic sensitive data filtering
- **Web dashboard** with real-time health monitoring (8 pages, EN/FA i18n)
> **Compatibility Note**: MCP Hub requires FastMCP 2.x (`>=2.14.0,<3.0.0`). FastMCP 3.0 introduced breaking changes and is not yet supported. If you install dependencies manually, ensure you don't upgrade to FastMCP 3.x.
> **Compatibility Note**: MCP Hub requires FastMCP 3.x (`>=3.0.0,<4.0.0`). The legacy multi-endpoint server and ProjectManager have been removed in v3.5.0.
### WordPress Plugin Requirements

View File

@@ -160,6 +160,7 @@ DASHBOARD_TRANSLATIONS = {
"admin_login": "Admin Login with API Key",
"profile": "Profile",
"admin_badge": "Admin",
"keys": "API Keys",
},
"fa": {
# Navigation
@@ -265,6 +266,7 @@ DASHBOARD_TRANSLATIONS = {
"admin_login": "ورود مدیر با کلید API",
"profile": "پروفایل",
"admin_badge": "مدیر",
"keys": "کلیدهای API",
},
}
@@ -2905,6 +2907,154 @@ 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} — Unified site management page (F.7c).
Combines connection settings, tool access, and connect/config snippets
into a single page with 3 sections.
"""
user_session, redirect = _require_user_session(request)
if redirect:
return redirect
site_id = request.path_params.get("id", "")
import json
from core.config_snippets import get_supported_clients
from core.site_api import get_user_credential_fields, get_user_plugin_names, get_user_site
from core.tool_access import get_scope_presets_for_plugin
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"
plugin_fields = get_user_credential_fields()
plugin_names = get_user_plugin_names()
scope_presets = get_scope_presets_for_plugin(site["plugin_type"])
return templates.TemplateResponse(
request,
"dashboard/sites/manage.html",
{
"lang": lang,
"t": t,
"session": user_session,
"site": site,
"plugin_names": plugin_names,
"plugin_fields": plugin_fields,
"plugin_fields_json": json.dumps(plugin_fields),
"scope_presets": scope_presets,
"scope_presets_json": json.dumps(scope_presets),
"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 + config snippets
from core.config_snippets import get_supported_clients
from core.site_api import get_user_sites
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
sites = await get_user_sites(user_session["user_id"])
return templates.TemplateResponse(
request,
"dashboard/keys/list.html",
{
"lang": lang,
"t": t,
"session": user_session,
"is_admin": False,
"user_keys": user_keys,
"sites": sites,
"clients": get_supported_clients(),
"current_page": "keys",
},
)
return RedirectResponse(url="/auth/login", status_code=303)
# ======================================================================
# Site Management API Routes (E.3)
# ======================================================================
@@ -3023,43 +3173,13 @@ async def api_test_site(request: Request) -> Response:
async def dashboard_sites_edit(request: Request) -> Response:
"""GET /dashboard/sites/{id}/edit — Render the Edit Site form."""
user_session, redirect = _require_user_session(request)
if redirect:
return redirect
"""GET /dashboard/sites/{id}/edit — Redirect to unified site page (F.7c)."""
site_id = request.path_params.get("id", "")
from core.site_api import get_user_credential_fields, get_user_plugin_names, 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)
import json
plugin_fields = get_user_credential_fields()
plugin_names = get_user_plugin_names()
return templates.TemplateResponse(
request,
"dashboard/sites/edit.html",
{
"lang": lang,
"t": t,
"session": user_session,
"site": site,
"plugin_fields": plugin_fields,
"plugin_fields_json": json.dumps(plugin_fields),
"plugin_names": plugin_names,
"current_page": "my_sites",
},
)
url = f"/dashboard/sites/{site_id}"
if query_lang:
url += f"?lang={query_lang}"
return RedirectResponse(url, status_code=301)
async def api_update_site(request: Request) -> Response:
@@ -3113,11 +3233,25 @@ async def api_create_key(request: Request) -> Response:
try:
key_mgr = get_user_key_manager()
# Validate site_id if provided (must belong to user)
site_id = body.get("site_id")
if site_id:
from core.database import get_database
db = get_database()
site = await db.get_site(site_id, user_session["user_id"])
if site is None:
return JSONResponse({"error": "Site not found"}, status_code=404)
# F.7c: All user keys get full access — tool visibility is controlled
# per-site via tool_scope and per-tool toggles.
result = await key_mgr.create_key(
user_id=user_session["user_id"],
name=body.get("name", "Default"),
scopes=body.get("scopes", "read write admin"),
scopes="read write admin",
expires_in_days=body.get("expires_in_days"),
site_id=site_id,
)
return JSONResponse({"key": result})
except RuntimeError as e:
@@ -3162,6 +3296,181 @@ async def api_delete_key(request: Request) -> Response:
return JSONResponse({"error": str(e)}, status_code=503)
# ----------------------------------------------------------------------
# F.7b: per-site tool visibility management
# ----------------------------------------------------------------------
_VALID_TOOL_SCOPES = {"read", "read:sensitive", "deploy", "write", "admin", "custom"}
async def _require_owned_site(request: Request) -> tuple[dict | None, Response | None]:
"""Resolve ``{site_id}`` path param → site row owned by the current user.
Returns ``(site, None)`` on success, or ``(None, error_response)``.
"""
user_session, redirect = _require_user_session(request)
if redirect:
return None, JSONResponse({"error": "Unauthorized"}, status_code=401)
site_id = request.path_params.get("site_id", "")
if not site_id:
return None, JSONResponse({"error": "Missing site_id"}, status_code=400)
from core.database import get_database
try:
db = get_database()
except RuntimeError:
return None, JSONResponse({"error": "Database unavailable"}, status_code=503)
site = await db.get_site(site_id, user_session["user_id"])
if site is None:
return None, JSONResponse({"error": "Site not found"}, status_code=404)
return site, None
async def api_list_site_tools(request: Request) -> Response:
"""GET /api/sites/{site_id}/tools — list tools for a site with toggle state."""
site, err = await _require_owned_site(request)
if err:
return err
assert site is not None
from core.tool_access import get_tool_access_manager
access = get_tool_access_manager()
tools = await access.list_tools_for_site(site["id"], site["plugin_type"])
return JSONResponse(
{
"site_id": site["id"],
"plugin_type": site["plugin_type"],
"tool_scope": site.get("tool_scope", "admin"),
"tools": tools,
}
)
async def api_patch_site_tool(request: Request) -> Response:
"""PATCH /api/sites/{site_id}/tools/{tool_name} — toggle a single tool."""
site, err = await _require_owned_site(request)
if err:
return err
assert site is not None
tool_name = request.path_params.get("tool_name", "")
try:
body = await request.json()
except Exception:
return JSONResponse({"error": "Invalid JSON body"}, status_code=400)
if "enabled" not in body or not isinstance(body["enabled"], bool):
return JSONResponse({"error": "Missing boolean 'enabled' field"}, status_code=400)
from core.tool_access import get_tool_access_manager
from core.tool_registry import get_tool_registry
tool_def = get_tool_registry().get_by_name(tool_name)
if tool_def is None:
return JSONResponse({"error": f"Unknown tool '{tool_name}'"}, status_code=404)
if tool_def.plugin_type != site["plugin_type"]:
return JSONResponse(
{"error": f"Tool '{tool_name}' does not belong to this site's plugin"},
status_code=400,
)
access = get_tool_access_manager()
try:
await access.toggle_tool(
site["id"],
tool_name,
bool(body["enabled"]),
body.get("reason"),
)
except RuntimeError as exc:
return JSONResponse({"error": str(exc)}, status_code=503)
return JSONResponse({"ok": True, "tool_name": tool_name, "enabled": body["enabled"]})
async def api_bulk_toggle_site_tools(request: Request) -> Response:
"""POST /api/sites/{site_id}/tools/bulk-toggle — toggle a category set."""
site, err = await _require_owned_site(request)
if err:
return err
assert site is not None
try:
body = await request.json()
except Exception:
return JSONResponse({"error": "Invalid JSON body"}, status_code=400)
scope = body.get("scope")
enabled = body.get("enabled")
if not isinstance(scope, str) or not isinstance(enabled, bool):
return JSONResponse(
{"error": "Body must contain string 'scope' and bool 'enabled'"},
status_code=400,
)
from core.tool_access import get_tool_access_manager
access = get_tool_access_manager()
try:
affected = await access.bulk_toggle_by_scope(
site["id"], scope, enabled, plugin_type=site["plugin_type"]
)
except ValueError as exc:
return JSONResponse({"error": str(exc)}, status_code=400)
except RuntimeError as exc:
return JSONResponse({"error": str(exc)}, status_code=503)
return JSONResponse({"ok": True, "affected": affected})
async def api_set_site_tool_scope(request: Request) -> Response:
"""PATCH /api/sites/{site_id}/tool-scope — update the site's scope preset."""
site, err = await _require_owned_site(request)
if err:
return err
assert site is not None
try:
body = await request.json()
except Exception:
return JSONResponse({"error": "Invalid JSON body"}, status_code=400)
scope = body.get("scope")
if not isinstance(scope, str) or scope not in _VALID_TOOL_SCOPES:
return JSONResponse(
{
"error": (
"Body must contain 'scope' with one of: "
+ ", ".join(sorted(_VALID_TOOL_SCOPES))
)
},
status_code=400,
)
from core.database import get_database
db = get_database()
await db.set_site_tool_scope(site["id"], scope)
return JSONResponse({"ok": True, "site_id": site["id"], "tool_scope": scope})
async def api_scope_presets(request: Request) -> Response:
"""GET /api/scope-presets — static scope → categories mapping."""
user_session, redirect = _require_user_session(request)
if redirect:
return JSONResponse({"error": "Unauthorized"}, status_code=401)
del user_session
from core.tool_access import SCOPE_TO_CATEGORIES
return JSONResponse({"presets": {k: sorted(v) for k, v in SCOPE_TO_CATEGORIES.items()}})
async def api_get_config(request: Request) -> Response:
"""GET /api/config/{alias} — Get config snippets for a site."""
user_session, redirect = _require_user_session(request)
@@ -3471,8 +3780,11 @@ def register_dashboard_routes(mcp):
dashboard_project_detail
)
# API Keys routes
mcp.custom_route("/dashboard/api-keys", methods=["GET"])(dashboard_api_keys_list)
# API Keys routes (unified — /dashboard/keys replaces /dashboard/api-keys and /dashboard/connect)
mcp.custom_route("/dashboard/keys", methods=["GET"])(dashboard_keys_unified)
mcp.custom_route("/dashboard/api-keys", methods=["GET"])(
lambda r: RedirectResponse("/dashboard/keys", status_code=301)
)
# OAuth Clients routes
mcp.custom_route("/dashboard/oauth-clients", methods=["GET"])(dashboard_oauth_clients_list)
@@ -3519,7 +3831,11 @@ def register_dashboard_routes(mcp):
mcp.custom_route("/dashboard/sites", methods=["GET"])(dashboard_sites_list)
mcp.custom_route("/dashboard/sites/add", methods=["GET"])(dashboard_sites_add)
mcp.custom_route("/dashboard/sites/{id}/edit", methods=["GET"])(dashboard_sites_edit)
mcp.custom_route("/dashboard/connect", methods=["GET"])(dashboard_connect_page)
mcp.custom_route("/dashboard/sites/{id}", methods=["GET"])(dashboard_sites_view)
# /dashboard/connect → /dashboard/sites (F.7c: sites page has connect info now)
mcp.custom_route("/dashboard/connect", methods=["GET"])(
lambda r: RedirectResponse("/dashboard/sites", status_code=301)
)
# Service pages (F.3)
mcp.custom_route("/dashboard/services", methods=["GET"])(dashboard_services_list)

View File

@@ -37,7 +37,7 @@ logger = logging.getLogger(__name__)
_DEFAULT_DATA_DIR = "/app/data" if Path("/app").exists() else "./data"
# Schema version — increment when adding migrations
SCHEMA_VERSION = 5
SCHEMA_VERSION = 8
# Initial schema DDL
_SCHEMA_SQL = """\
@@ -67,6 +67,7 @@ CREATE TABLE IF NOT EXISTS sites (
status_msg TEXT,
last_health TEXT,
last_tested_at TEXT,
tool_scope TEXT NOT NULL DEFAULT 'admin',
created_at TEXT NOT NULL,
UNIQUE(user_id, alias)
);
@@ -82,7 +83,8 @@ CREATE TABLE IF NOT EXISTS user_api_keys (
last_used TEXT,
use_count INTEGER NOT NULL DEFAULT 0,
created_at TEXT NOT NULL,
expires_at TEXT
expires_at TEXT,
site_id TEXT
);
-- WP plugin connection tokens (short-lived, for MCP Connect plugin)
@@ -100,6 +102,18 @@ CREATE TABLE IF NOT EXISTS schema_version (
version INTEGER PRIMARY KEY,
applied_at TEXT NOT NULL
);
-- F.7b: per-site tool toggles (scope-based visibility overrides)
CREATE TABLE IF NOT EXISTS site_tool_toggles (
id TEXT PRIMARY KEY,
site_id TEXT NOT NULL REFERENCES sites(id) ON DELETE CASCADE,
tool_name TEXT NOT NULL,
enabled INTEGER NOT NULL DEFAULT 1,
reason TEXT,
updated_at TEXT NOT NULL,
UNIQUE(site_id, tool_name)
);
CREATE INDEX IF NOT EXISTS idx_site_tool_toggles_site ON site_tool_toggles(site_id);
"""
# Migration registry: version -> SQL string
@@ -120,6 +134,42 @@ _MIGRATIONS: dict[int, str] = {
" updated_at TEXT NOT NULL DEFAULT (datetime('now'))\n"
");\n"
),
6: (
# F.7: per-user tool toggles for scope-based visibility & per-tool disable
"CREATE TABLE IF NOT EXISTS user_tool_toggles (\n"
" id TEXT PRIMARY KEY,\n"
" user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,\n"
" tool_name TEXT NOT NULL,\n"
" enabled INTEGER NOT NULL DEFAULT 1,\n"
" reason TEXT,\n"
" updated_at TEXT NOT NULL,\n"
" UNIQUE(user_id, tool_name)\n"
");\n"
"CREATE INDEX IF NOT EXISTS idx_user_tool_toggles_user "
"ON user_tool_toggles(user_id);\n"
),
7: (
# F.7b: move tool toggles from per-user to per-site and add a
# per-site preset column. user_tool_toggles was merged on Phase-1
# but never populated with real data — safe to drop.
"DROP TABLE IF EXISTS user_tool_toggles;\n"
"CREATE TABLE IF NOT EXISTS site_tool_toggles (\n"
" id TEXT PRIMARY KEY,\n"
" site_id TEXT NOT NULL REFERENCES sites(id) ON DELETE CASCADE,\n"
" tool_name TEXT NOT NULL,\n"
" enabled INTEGER NOT NULL DEFAULT 1,\n"
" reason TEXT,\n"
" updated_at TEXT NOT NULL,\n"
" UNIQUE(site_id, tool_name)\n"
");\n"
"CREATE INDEX IF NOT EXISTS idx_site_tool_toggles_site "
"ON site_tool_toggles(site_id);\n"
"ALTER TABLE sites ADD COLUMN tool_scope TEXT NOT NULL DEFAULT 'admin';\n"
),
8: (
# F.7c: per-site API keys — allow keys scoped to a single site
"ALTER TABLE user_api_keys ADD COLUMN site_id TEXT;\n"
),
}
@@ -637,6 +687,7 @@ class Database:
name: str,
scopes: str = "read write",
expires_at: str | None = None,
site_id: str | None = None,
) -> dict[str, Any]:
"""Create a new user API key.
@@ -647,6 +698,7 @@ class Database:
name: Human label (e.g. "Claude Desktop").
scopes: Space-separated scopes.
expires_at: Optional ISO 8601 expiry timestamp.
site_id: Optional site UUID to scope key to a single site.
Returns:
The created API key row as a dict.
@@ -656,14 +708,14 @@ class Database:
await self.execute(
"INSERT INTO user_api_keys "
"(id, user_id, key_hash, key_prefix, name, scopes, created_at, expires_at) "
"VALUES (?, ?, ?, ?, ?, ?, ?, ?)",
(key_id, user_id, key_hash, key_prefix, name, scopes, now, expires_at),
"(id, user_id, key_hash, key_prefix, name, scopes, created_at, expires_at, site_id) "
"VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)",
(key_id, user_id, key_hash, key_prefix, name, scopes, now, expires_at, site_id),
)
result = await self.fetchone(
"SELECT id, user_id, key_prefix, name, scopes, last_used, use_count, "
"created_at, expires_at FROM user_api_keys WHERE id = ?",
"created_at, expires_at, site_id FROM user_api_keys WHERE id = ?",
(key_id,),
)
if result is None:
@@ -681,7 +733,7 @@ class Database:
"""
return await self.fetchall(
"SELECT id, user_id, key_prefix, name, scopes, last_used, use_count, "
"created_at, expires_at FROM user_api_keys WHERE user_id = ? ORDER BY created_at",
"created_at, expires_at, site_id FROM user_api_keys WHERE user_id = ? ORDER BY created_at",
(user_id,),
)
@@ -726,6 +778,121 @@ class Database:
(_utc_now(), key_id),
)
# ------------------------------------------------------------------
# Site tool toggles & tool_scope (F.7b)
# ------------------------------------------------------------------
async def get_site_tool_toggles(self, site_id: str) -> dict[str, bool]:
"""Get explicit tool toggle overrides for a site.
Only rows where a tool has been explicitly toggled are stored.
Tools not in the result are implicitly enabled.
Args:
site_id: Site UUID.
Returns:
Dict mapping ``tool_name`` → ``enabled`` (bool).
"""
rows = await self.fetchall(
"SELECT tool_name, enabled FROM site_tool_toggles WHERE site_id = ?",
(site_id,),
)
return {row["tool_name"]: bool(row["enabled"]) for row in rows}
async def set_site_tool_toggle(
self,
site_id: str,
tool_name: str,
enabled: bool,
reason: str | None = None,
) -> None:
"""Upsert a single tool toggle for a site.
Args:
site_id: Site UUID.
tool_name: Fully-qualified tool name (e.g. ``coolify_list_applications``).
enabled: Whether the tool should be visible on this site.
reason: Optional note.
"""
toggle_id = str(uuid.uuid4())
now = _utc_now()
await self.execute(
"INSERT INTO site_tool_toggles (id, site_id, tool_name, enabled, reason, updated_at) "
"VALUES (?, ?, ?, ?, ?, ?) "
"ON CONFLICT(site_id, tool_name) DO UPDATE SET "
"enabled = excluded.enabled, reason = excluded.reason, updated_at = excluded.updated_at",
(toggle_id, site_id, tool_name, 1 if enabled else 0, reason, now),
)
async def delete_site_tool_toggle(self, site_id: str, tool_name: str) -> bool:
"""Delete a site's toggle for a tool (reverts to the default).
Args:
site_id: Site UUID.
tool_name: Fully-qualified tool name.
Returns:
True if a row was deleted.
"""
cursor = await self.execute(
"DELETE FROM site_tool_toggles WHERE site_id = ? AND tool_name = ?",
(site_id, tool_name),
)
return cursor.rowcount > 0
async def bulk_set_site_tool_toggles(
self,
site_id: str,
toggles: list[tuple[str, bool]],
reason: str | None = None,
) -> int:
"""Upsert multiple tool toggles for a site in one transaction.
Args:
site_id: Site UUID.
toggles: List of ``(tool_name, enabled)`` pairs.
reason: Optional shared reason applied to every row.
Returns:
Number of rows affected.
"""
if not toggles:
return 0
now = _utc_now()
rows = [
(str(uuid.uuid4()), site_id, tool_name, 1 if enabled else 0, reason, now)
for tool_name, enabled in toggles
]
await self.executemany(
"INSERT INTO site_tool_toggles (id, site_id, tool_name, enabled, reason, updated_at) "
"VALUES (?, ?, ?, ?, ?, ?) "
"ON CONFLICT(site_id, tool_name) DO UPDATE SET "
"enabled = excluded.enabled, reason = excluded.reason, updated_at = excluded.updated_at",
rows,
)
return len(rows)
async def get_site_tool_scope(self, site_id: str) -> str:
"""Return the site's ``tool_scope`` preset (defaults to ``'admin'``)."""
row = await self.fetchone("SELECT tool_scope FROM sites WHERE id = ?", (site_id,))
if row is None:
return "admin"
return row["tool_scope"] or "admin"
async def set_site_tool_scope(self, site_id: str, scope: str) -> None:
"""Update the ``tool_scope`` preset for a site.
Args:
site_id: Site UUID.
scope: One of ``read``, ``read:sensitive``, ``deploy``,
``write``, ``admin``, ``custom``.
"""
await self.execute(
"UPDATE sites SET tool_scope = ? WHERE id = ?",
(scope, site_id),
)
# ======================================================================
# Module-level helpers

View File

@@ -157,7 +157,7 @@ PLUGIN_CREDENTIAL_FIELDS: dict[str, list[dict[str, Any]]] = {
"label": "Client ID",
"type": "text",
"required": True,
"hint": "OpenPanel Dashboard → Settings → Clients → Create client with 'root' mode for full access",
"hint": "Open your project on dashboard.openpanel.dev → Settings → Clients (URL: dashboard.openpanel.dev/{org}/{project-id}/settings/clients). Create a client in 'root' mode for full access.",
},
{
"name": "client_secret",

View File

@@ -111,9 +111,6 @@
<img src="/static/logo.svg" alt="MCP Hub" class="w-8 h-8">
<span class="{% if lang == 'fa' %}mr-3{% else %}ml-3{% endif %} font-bold text-lg text-gray-900 dark:text-white">MCP Hub</span>
</div>
<div x-show="!sidebarOpen" class="flex items-center justify-center">
<img src="/static/logo.svg" alt="MCP Hub" class="w-8 h-8">
</div>
<button @click="sidebarOpen = !sidebarOpen" class="p-2 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors text-gray-600 dark:text-gray-300">
<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"
@@ -136,8 +133,8 @@
01-9-9m9 9c1.657 0 3-4.03 3-9s-1.343-9-3-9m0 18c-1.657 0-3-4.03-3-9s1.343-9 3-9m-9 9a9 9 0 019-9',
'/dashboard/sites'),
('services', t.get('services', 'Services'), 'M19 11H5m14 0a2 2 0 012 2v6a2 2 0 01-2 2H5a2 2 0 01-2-2v-6a2 2 0 012-2m14 0V9a2 2 0 00-2-2M5 11V9a2 2 0 012-2m0 0V5a2 2 0 012-2h6a2 2 0 012 2v2M7 7h10', '/dashboard/services'),
('connect', t.get('connect', 'Connect'), 'M13.828 10.172a4 4 0 00-5.656 0l-4 4a4 4 0 105.656
5.656l1.102-1.101m-.758-4.899a4 4 0 005.656 0l4-4a4 4 0 00-5.656-5.656l-1.1 1.1', '/dashboard/connect'),
('keys', t.get('keys', 'API Keys'), 'M15 7a2 2 0 012 2m4 0a6 6 0 01-7.743 5.743L11 17H9v2H7v2H4a1 1 0
01-1-1v-2.586a1 1 0 01.293-.707l5.964-5.964A6 6 0 1121 9z', '/dashboard/keys'),
('oauth_clients', t.oauth_clients, 'M9 12l2 2 4-4m5.618-4.016A11.955 11.955 0 0112 2.944a11.955 11.955 0
01-8.618 3.04A12.02 12.02 0 003 9c0 5.591 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622
0-1.042-.133-2.052-.382-3.016z', '/dashboard/oauth-clients'),

View File

@@ -0,0 +1,676 @@
{% 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">
{% if key.site_id %}
<span class="px-2 py-0.5 bg-blue-100 dark:bg-blue-500/20 text-blue-700 dark:text-blue-400 text-xs rounded font-medium">
{% for site in sites %}{% if site.id == key.site_id %}{{ site.alias }}{% endif %}{% endfor %}
</span>
{% else %}
<span class="px-2 py-0.5 bg-green-100 dark:bg-green-500/20 text-green-700 dark:text-green-400 text-xs rounded font-medium">{% if lang == 'fa' %}همه سرویس‌ها{% else %}All Services{% endif %}</span>
{% endif %}
</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>
{% if not is_admin %}
<!-- Configuration Snippet -->
<div class="bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700 p-6">
<h3 class="text-lg font-semibold text-gray-900 dark:text-white mb-4">
{% if lang == 'fa' %}نمونه کد پیکربندی{% else %}Configuration Snippet{% endif %}
</h3>
{% if sites %}
<div class="grid grid-cols-1 md:grid-cols-2 gap-4 mb-4">
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">{% if lang == 'fa' %}سایت{% else %}Site{% endif %}</label>
<select id="config-site" 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 site in sites %}
<option value="{{ site.alias }}" data-site-id="{{ site.id }}" data-plugin="{{ site.plugin_type }}">{{ site.alias }} ({{ site.plugin_type }})</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 %}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>
<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-[120px]">{% if lang == 'fa' %}در حال بارگذاری...{% else %}Loading...{% endif %}</pre>
<button onclick="copyConfig()" class="absolute top-2 {{ 'left-2' if lang == 'fa' else '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" id="copy-config-btn">{% if lang == 'fa' %}کپی{% else %}Copy{% endif %}</button>
</div>
<!-- Transport type info -->
<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) می‌باشد. از <code>sse</code> استفاده نکنید — باعث خطای <code>400 Bad Request</code> می‌شود.
{% else %}
<strong>Note:</strong> Config above includes the correct transport type (<code>streamableHttp</code> for Claude Desktop, <code>http</code> for VS Code/Claude Code). Do not use <code>sse</code> — it will cause <code>400 Bad Request</code> errors.
{% endif %}
</p>
</div>
<!-- WordPress SEO plugin note -->
<div id="wp-seo-note" style="display:none" class="mt-3 bg-amber-50 dark:bg-amber-500/10 border border-amber-200 dark:border-amber-500/30 rounded-lg p-4">
<p class="text-sm text-amber-700 dark:text-amber-400">
{% if lang == 'fa' %}
<strong>وردپرس:</strong> برای استفاده از ابزارهای SEO، افزونه
<a href="https://wordpress.org/plugins/airano-mcp-seo-bridge/" target="_blank" class="underline">Airano MCP SEO Bridge</a>
را روی سایت وردپرسی خود نصب کنید.
{% else %}
<strong>WordPress:</strong> For SEO tools, install the
<a href="https://wordpress.org/plugins/airano-mcp-seo-bridge/" target="_blank" class="underline">Airano MCP SEO Bridge</a>
plugin on your WordPress site.
{% endif %}
</p>
</div>
<!-- Connection tip for web-based clients -->
<div id="connection-tip" style="display:none" class="mt-3 bg-green-50 dark:bg-green-500/10 border border-green-200 dark:border-green-500/30 rounded-lg p-4">
<p class="text-sm text-green-700 dark:text-green-400">
{% if lang == 'fa' %}
<strong>نکته:</strong> فقط آدرس URL کافی است. هنگام اتصال می‌توانید با <strong>API Key</strong> یا <strong>GitHub/Google</strong> احراز هویت کنید.
ساخت <a href="/dashboard/oauth-clients{% if lang and lang != 'en' %}?lang={{ lang }}{% endif %}" class="underline">OAuth Client</a> اختیاری است.
{% else %}
<strong>Tip:</strong> You only need the URL above. When connecting, you can authenticate with an <strong>API Key</strong> or <strong>GitHub/Google</strong>.
Creating an <a href="/dashboard/oauth-clients{% if lang and lang != 'en' %}?lang={{ lang }}{% endif %}" class="underline">OAuth Client</a> is optional.
{% endif %}
</p>
</div>
<!-- API Key hint for desktop/CLI clients -->
<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> از بخش API Keys بالا بسازید و مقدار آن را در تنظیمات وارد کنید:
{% else %}
<strong>API Key:</strong> Create one above and use it in your config:
{% 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>
{% else %}
<p class="text-gray-500 dark:text-gray-400 text-sm">
{% if lang == 'fa' %}ابتدا یک سایت اضافه کنید.{% else %}Add a site first to see configuration snippets.{% endif %}
<a href="/dashboard/sites/add{% if lang and lang != 'en' %}?lang={{ lang }}{% endif %}" class="text-blue-600 dark:text-blue-400 hover:text-blue-500 dark:hover:text-blue-300">{{ t.add_site }}</a>
</p>
{% endif %}
</div>
{% endif %}
{% 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 %}Service Scope{% endif %}</label>
<select id="new-key-site"
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="">{% if lang == 'fa' %}همه سرویس‌ها (عمومی){% else %}All Services (global){% endif %}</option>
{% if sites %}
{% for site in sites %}
<option value="{{ site.id }}">{{ site.alias }} ({{ site.plugin_type }})</option>
{% endfor %}
{% endif %}
</select>
<p class="text-xs text-gray-400 dark:text-gray-500 mt-1">
{% if lang == 'fa' %}کلید اختصاصی فقط برای سرویس انتخاب‌شده قابل استفاده است.{% else %}A site-scoped key only works for the selected service.{% endif %}
</p>
</div>
<div class="flex items-center gap-3">
<span class="px-3 py-1 bg-green-100 dark:bg-green-500/20 text-green-700 dark:text-green-400 text-sm font-medium rounded-lg">
{% if lang == 'fa' %}دسترسی کامل{% else %}Full Access{% endif %}
</span>
<p class="text-xs text-gray-400 dark:text-gray-500">
{% if lang == 'fa' %}فیلتر ابزار هر سایت در <a href="/dashboard/sites{% if lang and lang != 'en' %}?lang={{ lang }}{% endif %}" class="underline hover:text-gray-300">تنظیمات سایت</a> انجام می‌شود.{% else %}Tool access is controlled per-site in <a href="/dashboard/sites{% if lang and lang != 'en' %}?lang={{ lang }}{% endif %}" class="underline hover:text-gray-300">Site Settings</a>.{% 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');
// F.7d: pre-select site from ?site= query param when present
const params = new URLSearchParams(window.location.search);
const siteParam = params.get('site');
if (siteParam) {
const siteSelect = document.getElementById('new-key-site');
if (siteSelect) {
const match = Array.from(siteSelect.options).find(o => o.value === siteParam);
if (match) siteSelect.value = siteParam;
}
}
}
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();
if (!name) { alert(lang === 'fa' ? 'نام کلید الزامی است' : 'Key name is required'); return; }
const siteId = document.getElementById('new-key-site')?.value || null;
const payload = { name, scopes: 'read write admin' };
if (siteId) payload.site_id = siteId;
const r = await fetch('/api/keys', {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
});
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 %}
// ── Config Snippets (user view) ────────────────────────────────
{% if not is_admin %}
const WEB_CLIENTS = ['claude_connectors', 'chatgpt'];
const WP_TYPES = ['wordpress', 'woocommerce'];
async function updateConfig() {
const siteSelect = document.getElementById('config-site');
const alias = siteSelect?.value;
const client = document.getElementById('config-client')?.value;
if (!alias || !client) return;
const isWebClient = WEB_CLIENTS.includes(client);
const transportNote = document.getElementById('transport-note');
if (transportNote) transportNote.style.display = isWebClient ? 'none' : '';
const connTip = document.getElementById('connection-tip');
if (connTip) connTip.style.display = isWebClient ? '' : 'none';
const bearerHint = document.getElementById('bearer-hint');
if (bearerHint) bearerHint.style.display = isWebClient ? 'none' : '';
const selectedOption = siteSelect.options[siteSelect.selectedIndex];
const pluginType = selectedOption?.dataset?.plugin || '';
const wpNote = document.getElementById('wp-seo-note');
if (wpNote) wpNote.style.display = WP_TYPES.includes(pluginType) ? '' : 'none';
try {
const resp = await fetch('/api/config/' + alias + '?client=' + client);
const data = await resp.json();
if (resp.ok) {
document.getElementById('config-output').textContent = data.config;
}
} catch (e) {
document.getElementById('config-output').textContent = 'Error loading config';
}
}
window.updateConfig = updateConfig;
function copyConfig() {
const text = document.getElementById('config-output').textContent;
navigator.clipboard.writeText(text);
const btn = document.getElementById('copy-config-btn');
btn.textContent = lang === 'fa' ? 'کپی شد!' : 'Copied!';
setTimeout(() => btn.textContent = lang === 'fa' ? 'کپی' : 'Copy', 2000);
}
window.copyConfig = copyConfig;
document.addEventListener('DOMContentLoaded', () => {
// F.7d: support ?site=<id-or-alias> from /dashboard/sites/{id} link
const params = new URLSearchParams(window.location.search);
const siteParam = params.get('site');
const siteSelect = document.getElementById('config-site');
if (siteParam && siteSelect) {
// Match either the option value (alias) or its data-site-id attribute
const match = Array.from(siteSelect.options).find(
o => o.value === siteParam || o.dataset?.siteId === siteParam
);
if (match) siteSelect.value = match.value;
}
if (siteSelect) updateConfig();
// F.7d: auto-open create modal when ?create=1 (e.g. from Connect section)
if (params.get('create') === '1') openCreateModal();
});
{% endif %}
})();
</script>
{% endblock %}

View File

@@ -220,24 +220,14 @@ https://claude.com/api/mcp/auth_callback</textarea>
</div>
<div>
<label class="block text-sm text-gray-500 dark:text-gray-400 mb-1">{% if lang == 'fa' %}دامنه‌ها{% else %}Scopes{% endif %}</label>
<div class="flex flex-wrap gap-2">
<label class="flex items-center gap-2">
<input type="checkbox" name="scopes" value="read" checked class="rounded bg-gray-700 border-gray-600 text-primary-500">
<span class="text-sm text-gray-700 dark:text-gray-300">read</span>
</label>
<label class="flex items-center gap-2">
<input type="checkbox" name="scopes" value="write" checked class="rounded bg-gray-700 border-gray-600 text-primary-500">
<span class="text-sm text-gray-700 dark:text-gray-300">write</span>
</label>
<label class="flex items-center gap-2">
<input type="checkbox" name="scopes" value="admin" checked class="rounded bg-gray-700 border-gray-600 text-primary-500">
<span class="text-sm text-gray-700 dark:text-gray-300">admin</span>
</label>
<div class="flex items-center gap-3">
<span class="px-3 py-1 bg-green-100 dark:bg-green-500/20 text-green-700 dark:text-green-400 text-sm font-medium rounded-lg">
{% if lang == 'fa' %}دسترسی کامل{% else %}Full Access{% endif %}
</span>
<p class="text-xs text-gray-500 dark:text-gray-400">
{% if lang == 'fa' %}دسترسی ابزار در تنظیمات هر سایت کنترل می‌شود.{% else %}Tool access is controlled per-site in Site Settings.{% endif %}
</p>
</div>
<p class="text-xs text-gray-500 mt-1">
{% if lang == 'fa' %}admin: دسترسی کامل به همه عملیات{% else %}admin: Full access to all operations{% endif %}
</p>
</div>
<div class="flex justify-end gap-3 pt-4">
@@ -426,10 +416,8 @@ https://claude.com/api/mcp/auth_callback</textarea>
e.preventDefault();
var formData = new FormData(this);
var scopes = [];
document.querySelectorAll('input[name="scopes"]:checked').forEach(function(cb) {
scopes.push(cb.value);
});
// F.7c: All clients get full scope — tool access controlled per-site
var scopes = ['read', 'write', 'admin'];
// Parse redirect URIs (one per line)
var redirectUrisText = formData.get('redirect_uris') || '';

View File

@@ -4,8 +4,8 @@
<link href="https://fonts.googleapis.com/css2?family=Vazirmatn:wght@100;200;300;400;500;600;700;800;900&display=swap"
rel="stylesheet">
<!-- Tailwind CSS -->
<script src="https://cdn.tailwindcss.com"></script>
<!-- Tailwind CSS (pre-built) -->
<link rel="stylesheet" href="/static/tailwind.min.css">
<!-- HTMX for dynamic updates -->
<script src="https://unpkg.com/htmx.org@1.9.10"></script>
@@ -13,34 +13,6 @@
<!-- Alpine.js for simple interactions -->
<script defer src="https://unpkg.com/alpinejs@3.14.8/dist/cdn.min.js"></script>
<!-- Custom Tailwind Config -->
<script>
tailwind.config = {
darkMode: 'class',
theme: {
extend: {
fontFamily: {
'vazirmatn': ['Vazirmatn', 'system-ui', 'sans-serif'],
},
colors: {
primary: {
50: '#f5f3ff',
100: '#ede9fe',
200: '#ddd6fe',
300: '#c4b5fd',
400: '#a78bfa',
500: '#8b5cf6',
600: '#7c3aed',
700: '#6d28d9',
800: '#5b21b6',
900: '#4c1d95',
}
}
}
}
}
</script>
<!-- Theme Initialization -->
<script>
if (localStorage.theme === 'dark' || (!('theme' in localStorage) && window.matchMedia('(prefers-color-scheme: dark)').matches)) {

View File

@@ -57,7 +57,8 @@
{% endif %}
<!-- Overview Cards -->
<div class="grid grid-cols-1 md:grid-cols-3 gap-6">
{% set admin_tools = service.tools|selectattr('scope', 'equalto', 'admin')|list %}
<div class="grid grid-cols-1 md:grid-cols-{{ 4 if admin_tools|length > 0 else 3 }} gap-6">
<!-- Tools Count -->
<div class="bg-white dark:bg-gray-800 rounded-xl p-6 border border-gray-200 dark:border-gray-700">
<div class="flex items-center justify-between">
@@ -112,6 +113,25 @@
</div>
</div>
</div>
<!-- Admin Tools (F.7d) -->
{% if admin_tools|length > 0 %}
<div class="bg-white dark:bg-gray-800 rounded-xl p-6 border border-gray-200 dark:border-gray-700">
<div class="flex items-center justify-between">
<div>
<p class="text-sm font-medium text-gray-500 dark:text-gray-400">
{% if lang == 'fa' %}ابزار مدیر{% else %}Admin Tools{% endif %}
</p>
<p class="text-3xl font-bold text-gray-900 dark:text-white mt-1">{{ admin_tools|length }}</p>
</div>
<div class="w-12 h-12 bg-red-500/20 rounded-lg flex items-center justify-center">
<svg class="w-6 h-6 text-red-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-4M7.835 4.697a3.42 3.42 0 001.946-.806 3.42 3.42 0 014.438 0 3.42 3.42 0 001.946.806 3.42 3.42 0 013.138 3.138 3.42 3.42 0 00.806 1.946 3.42 3.42 0 010 4.438 3.42 3.42 0 00-.806 1.946 3.42 3.42 0 01-3.138 3.138 3.42 3.42 0 00-1.946.806 3.42 3.42 0 01-4.438 0 3.42 3.42 0 00-1.946-.806 3.42 3.42 0 01-3.138-3.138 3.42 3.42 0 00-.806-1.946 3.42 3.42 0 010-4.438 3.42 3.42 0 00.806-1.946 3.42 3.42 0 013.138-3.138z"/>
</svg>
</div>
</div>
</div>
{% endif %}
</div>
<!-- Setup Requirements -->

View File

@@ -42,13 +42,17 @@
</span>
{% endif %}
</div>
<div class="flex items-center gap-4 text-sm text-gray-500 dark:text-gray-400">
<div class="flex flex-wrap items-center gap-x-3 gap-y-1 text-sm text-gray-500 dark:text-gray-400">
<span>{{ svc.tools_count }} {% if lang == 'fa' %}ابزار{% else %}tools{% endif %}</span>
<span>&middot;</span>
{% set read_count = svc.tools|selectattr('scope', 'equalto', 'read')|list|length %}
{% set write_count = svc.tools|selectattr('scope', 'equalto', 'write')|list|length %}
{% set admin_count = svc.tools|selectattr('scope', 'equalto', 'admin')|list|length %}
<span class="text-green-600 dark:text-green-400">{{ read_count }} read</span>
<span class="text-orange-600 dark:text-orange-400">{{ write_count }} write</span>
{% if admin_count > 0 %}
<span class="text-red-600 dark:text-red-400">{{ admin_count }} admin</span>
{% endif %}
</div>
</a>
{% endfor %}

View File

@@ -4,7 +4,8 @@
{% block page_title %}{{ t.add_site }}{% endblock %}
{% block content %}
<div class="max-w-2xl mx-auto space-y-6">
<div class="max-w-3xl mx-auto space-y-6">
<!-- Header -->
<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">
@@ -18,55 +19,119 @@
<!-- Error display -->
<div id="error-msg" class="hidden bg-red-50 dark:bg-red-500/20 border border-red-200 dark:border-red-500/50 text-red-700 dark:text-red-300 px-4 py-3 rounded-lg"></div>
<form id="add-site-form" class="bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700 p-6 space-y-6">
<!-- Plugin Type -->
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">{{ t.plugin_type }}</label>
<select id="plugin_type" name="plugin_type" 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.5 text-gray-900 dark:text-white focus:ring-2 focus:ring-blue-500 focus:border-transparent"
onchange="updateFields()">
<option value="">{{ t.select_plugin }}</option>
{% for ptype, pname in plugin_names.items() %}
<option value="{{ ptype }}" {% if ptype == preselect_plugin %}selected{% endif %}>{{ pname }}</option>
{% endfor %}
</select>
</div>
<!-- ═══════════════════════════════════════════════════════════════ -->
<!-- Section 1: Add Site / Connect Service -->
<!-- ═══════════════════════════════════════════════════════════════ -->
<div class="bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700">
<button type="button" onclick="toggleSection('addsite')" class="w-full flex items-center justify-between p-6 cursor-pointer">
<div class="flex items-center gap-3">
<div class="w-8 h-8 bg-blue-500/20 rounded-lg flex items-center justify-center">
<svg class="w-4 h-4 text-blue-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4"/>
</svg>
</div>
<div>
<h3 class="text-base font-semibold text-gray-900 dark:text-white">
{% if lang == 'fa' %}اتصال سرویس{% else %}Connect Service{% endif %}
</h3>
<p class="text-xs text-gray-500 dark:text-gray-400 mt-0.5">
{% if lang == 'fa' %}نوع پلاگین و اطلاعات اتصال{% else %}Plugin type and connection details{% endif %}
</p>
</div>
</div>
<svg id="addsite-chevron" class="w-5 h-5 text-gray-400 transition-transform rotate-180" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7"/>
</svg>
</button>
<div id="addsite-section" class="border-t border-gray-200 dark:border-gray-700 p-6 space-y-4">
<form id="add-site-form" class="space-y-4">
<!-- Plugin Type -->
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">{{ t.plugin_type }}</label>
<select id="plugin_type" name="plugin_type" 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.5 text-gray-900 dark:text-white focus:ring-2 focus:ring-blue-500 focus:border-transparent"
onchange="updateFields()">
<option value="">{{ t.select_plugin }}</option>
{% for ptype, pname in plugin_names.items() %}
<option value="{{ ptype }}" {% if ptype == preselect_plugin %}selected{% endif %}>{{ pname }}</option>
{% endfor %}
</select>
</div>
<!-- URL -->
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">{{ t.site_url }}</label>
<input type="url" id="url" name="url" required placeholder="https://example.com"
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 dark:placeholder-gray-500 focus:ring-2 focus:ring-blue-500 focus:border-transparent">
<p id="url-hint" class="text-xs text-gray-500 dark:text-gray-400 mt-1 hidden"></p>
</div>
<!-- URL -->
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">{{ t.site_url }}</label>
<input type="url" id="url" name="url" required placeholder="https://example.com"
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 dark:placeholder-gray-500 focus:ring-2 focus:ring-blue-500 focus:border-transparent">
<p id="url-hint" class="text-xs text-gray-500 dark:text-gray-400 mt-1 hidden"></p>
</div>
<!-- Alias -->
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">{{ t.site_alias }}</label>
<input type="text" id="alias" name="alias" required placeholder="myblog" pattern="[a-zA-Z0-9_-]+"
minlength="2" maxlength="50"
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 dark:placeholder-gray-500 focus:ring-2 focus:ring-blue-500 focus:border-transparent">
<p class="text-xs text-gray-500 dark:text-gray-400 mt-1">{{ t.site_alias_hint }}</p>
</div>
<!-- Alias -->
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">{{ t.site_alias }}</label>
<input type="text" id="alias" name="alias" required placeholder="myblog" pattern="[a-zA-Z0-9_-]+"
minlength="2" maxlength="50"
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 dark:placeholder-gray-500 focus:ring-2 focus:ring-blue-500 focus:border-transparent">
<p class="text-xs text-gray-500 dark:text-gray-400 mt-1">{{ t.site_alias_hint }}</p>
</div>
<!-- Dynamic Credential Fields -->
<div id="credential-fields" class="space-y-4">
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300">{{ t.credentials }}</label>
<p class="text-sm text-gray-500 dark:text-gray-400">{{ t.select_plugin }}</p>
</div>
<!-- Dynamic Credential Fields -->
<div id="credential-fields" class="space-y-4">
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300">{{ t.credentials }}</label>
<p class="text-sm text-gray-500 dark:text-gray-400">{{ t.select_plugin }}</p>
</div>
<!-- Submit -->
<div class="flex items-center gap-4 pt-4">
<button type="submit" id="submit-btn"
class="btn-primary px-6 py-2.5 rounded-lg text-white font-medium disabled:opacity-50">
{{ t.add_site }}
</button>
<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">
{{ t.cancel }}
</a>
<!-- Submit -->
<div class="flex items-center gap-3 pt-2">
<button type="submit" id="submit-btn"
class="px-5 py-2 bg-blue-600 hover:bg-blue-700 rounded-lg text-white text-sm font-medium disabled:opacity-50 transition-colors">
{{ t.add_site }}
</button>
<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 text-sm">
{{ t.cancel }}
</a>
</div>
</form>
</div>
</form>
</div>
<!-- ═══════════════════════════════════════════════════════════════ -->
<!-- Section 2: What happens next (info card) -->
<!-- ═══════════════════════════════════════════════════════════════ -->
<div class="bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700">
<button type="button" onclick="toggleSection('info')" class="w-full flex items-center justify-between p-6 cursor-pointer">
<div class="flex items-center gap-3">
<div class="w-8 h-8 bg-green-500/20 rounded-lg flex items-center justify-center">
<svg class="w-4 h-4 text-green-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"/>
</svg>
</div>
<h3 class="text-base font-semibold text-gray-900 dark:text-white">
{% if lang == 'fa' %}مراحل بعدی{% else %}What happens next{% endif %}
</h3>
</div>
<svg id="info-chevron" class="w-5 h-5 text-gray-400 transition-transform" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7"/>
</svg>
</button>
<div id="info-section" class="hidden border-t border-gray-200 dark:border-gray-700 p-6">
<div class="space-y-3 text-sm text-gray-600 dark:text-gray-400">
<div class="flex items-start gap-3">
<span class="flex-shrink-0 w-6 h-6 bg-blue-500/20 text-blue-500 rounded-full flex items-center justify-center text-xs font-bold">1</span>
<p>{% if lang == 'fa' %}سرویس خود را با انتخاب نوع پلاگین و وارد کردن URL و اعتبارنامه اضافه کنید.{% else %}Add your service by selecting the plugin type and entering the URL and credentials.{% endif %}</p>
</div>
<div class="flex items-start gap-3">
<span class="flex-shrink-0 w-6 h-6 bg-blue-500/20 text-blue-500 rounded-full flex items-center justify-center text-xs font-bold">2</span>
<p>{% if lang == 'fa' %}سطح دسترسی ابزارها و تنظیمات اتصال را از صفحه مدیریت سایت پیکربندی کنید.{% else %}Configure tool access levels and connection settings from the site management page.{% endif %}</p>
</div>
<div class="flex items-start gap-3">
<span class="flex-shrink-0 w-6 h-6 bg-blue-500/20 text-blue-500 rounded-full flex items-center justify-center text-xs font-bold">3</span>
<p>{% if lang == 'fa' %}یک کلید API بسازید و از قطعه کد پیکربندی برای اتصال MCP client خود استفاده کنید.{% else %}Create an API key and use the configuration snippet to connect your MCP client.{% endif %}</p>
</div>
</div>
</div>
</div>
</div>
{% endblock %}
@@ -79,6 +144,14 @@
'supabase': 'Your Supabase project URL (e.g., https://xxxxx.supabase.co)',
};
// ── Section toggle ──────────────────────────────────────────────
function toggleSection(name) {
const section = document.getElementById(name + '-section');
const chevron = document.getElementById(name + '-chevron');
section.classList.toggle('hidden');
chevron.classList.toggle('rotate-180');
}
function updateFields() {
const ptype = document.getElementById('plugin_type').value;
const container = document.getElementById('credential-fields');
@@ -131,7 +204,7 @@
container.innerHTML = html;
}
// Auto-fill credential fields if plugin_type is pre-selected (e.g., from service page)
// Auto-fill credential fields if plugin_type is pre-selected
if (document.getElementById('plugin_type').value) {
updateFields();
}
@@ -171,7 +244,13 @@
});
const data = await resp.json();
if (resp.ok) {
window.location.href = '/dashboard/sites?msg={{ t.site_added }}{% if lang and lang != "en" %}&lang={{ lang }}{% endif %}';
// Redirect to manage page for the new site
const siteId = data.site?.id || data.site_id;
if (siteId) {
window.location.href = '/dashboard/sites/' + siteId + '{% if lang and lang != "en" %}?lang={{ lang }}{% endif %}';
} else {
window.location.href = '/dashboard/sites?msg={{ t.site_added }}{% if lang and lang != "en" %}&lang={{ lang }}{% endif %}';
}
} else {
errorEl.textContent = data.error || 'Failed to add site';
errorEl.classList.remove('hidden');

View File

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

View File

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

View File

@@ -0,0 +1,768 @@
{% 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">
<!-- Header -->
<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>
</div>
<!-- ═══════════════════════════════════════════════════════════════ -->
<!-- Section 1: Connection Settings -->
<!-- ═══════════════════════════════════════════════════════════════ -->
<div class="bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700">
<button type="button" onclick="toggleSection('connection')" class="w-full flex items-center justify-between p-6 cursor-pointer">
<div class="flex items-center gap-3">
<div class="w-8 h-8 bg-blue-500/20 rounded-lg flex items-center justify-center">
<svg class="w-4 h-4 text-blue-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13.828 10.172a4 4 0 00-5.656 0l-4 4a4 4 0 105.656 5.656l1.102-1.101m-.758-4.899a4 4 0 005.656 0l4-4a4 4 0 00-5.656-5.656l-1.1 1.1"/>
</svg>
</div>
<h3 class="text-base font-semibold text-gray-900 dark:text-white">
{% if lang == 'fa' %}تنظیمات اتصال{% else %}Connection Settings{% endif %}
</h3>
</div>
<svg id="connection-chevron" class="w-5 h-5 text-gray-400 transition-transform" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7"/>
</svg>
</button>
<div id="connection-section" class="hidden border-t border-gray-200 dark:border-gray-700 p-6 space-y-4">
<!-- Info notice -->
<div class="bg-blue-50 dark:bg-blue-500/10 border border-blue-200 dark:border-blue-500/30 text-blue-700 dark:text-blue-300 px-4 py-3 rounded-lg text-sm">
{{ t.keep_existing }}
</div>
<!-- Error display -->
<div id="error-msg" class="hidden bg-red-50 dark:bg-red-500/20 border border-red-200 dark:border-red-500/50 text-red-700 dark:text-red-300 px-4 py-3 rounded-lg"></div>
<form id="edit-site-form" class="space-y-4">
<!-- URL -->
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">{{ t.site_url }}</label>
<input type="url" id="url" name="url" required value="{{ site.url }}"
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 dark:placeholder-gray-500 focus:ring-2 focus:ring-blue-500 focus:border-transparent">
</div>
<!-- Credential Fields -->
<div id="credential-fields" class="space-y-4">
{% set fields = plugin_fields.get(site.plugin_type, []) %}
{% for field in fields %}
{% if not field.get('advanced', False) %}
<div>
<label class="block text-xs font-medium text-gray-500 dark:text-gray-400 mb-1">
{{ field.label }}{% if field.required %} *{% endif %}
</label>
<input type="{{ field.type }}" name="cred_{{ field.name }}"
placeholder="{% if field.required %}{{ t.keep_existing }}{% else %}{{ t.leave_blank_to_clear }}{% endif %}"
data-required="{{ field.required | lower }}"
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 dark:placeholder-gray-500 focus:ring-2 focus:ring-blue-500 focus:border-transparent">
{% if field.hint %}
<p class="text-xs text-gray-500 dark:text-gray-400 mt-1">{{ field.hint }}</p>
{% endif %}
</div>
{% endif %}
{% endfor %}
{% set has_advanced = fields | map(attribute='advanced') | select | list | length > 0 if fields else false %}
{% if has_advanced %}
<details class="mt-2 border border-gray-200 dark:border-gray-600 rounded-lg">
<summary class="cursor-pointer px-4 py-2.5 text-sm font-medium text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white select-none">
Advanced Settings
</summary>
<div class="px-4 pb-4 pt-2 space-y-4">
{% for field in fields %}
{% if field.get('advanced', False) %}
<div>
<label class="block text-xs font-medium text-gray-500 dark:text-gray-400 mb-1">{{ field.label }}</label>
<input type="{{ field.type }}" name="cred_{{ field.name }}"
placeholder="{{ t.leave_blank_to_clear }}" data-required="false"
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 dark:placeholder-gray-500 focus:ring-2 focus:ring-blue-500 focus:border-transparent">
{% if field.hint %}
<p class="text-xs text-gray-500 dark:text-gray-400 mt-1">{{ field.hint }}</p>
{% endif %}
</div>
{% endif %}
{% endfor %}
</div>
</details>
{% endif %}
</div>
<!-- Test + Save buttons -->
<div class="flex items-center gap-3 pt-2">
<button type="submit" id="submit-btn"
class="px-5 py-2 bg-blue-600 hover:bg-blue-700 rounded-lg text-white text-sm font-medium disabled:opacity-50 transition-colors">
{{ t.save }}
</button>
<button type="button" id="test-btn" onclick="testConnection()"
class="px-5 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">
{% if lang == 'fa' %}تست اتصال{% else %}Test Connection{% endif %}
</button>
<span id="test-status" class="text-sm"></span>
</div>
</form>
</div>
</div>
<!-- ═══════════════════════════════════════════════════════════════ -->
<!-- Section 2: Tool Access -->
<!-- ═══════════════════════════════════════════════════════════════ -->
<div class="bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700">
<button type="button" onclick="toggleSection('tools')" class="w-full flex items-center justify-between p-6 cursor-pointer">
<div class="flex items-center gap-3">
<div class="w-8 h-8 bg-purple-500/20 rounded-lg flex items-center justify-center">
<svg class="w-4 h-4 text-purple-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 6V4m0 2a2 2 0 100 4m0-4a2 2 0 110 4m-6 8a2 2 0 100-4m0 4a2 2 0 110-4m0 4v2m0-6V4m6 6v10m6-2a2 2 0 100-4m0 4a2 2 0 110-4m0 4v2m0-6V4"/>
</svg>
</div>
<div>
<h3 class="text-base font-semibold text-gray-900 dark:text-white">
{% if lang == 'fa' %}دسترسی به ابزارها{% else %}Tool Access{% endif %}
</h3>
<p id="tool-summary" class="text-xs text-gray-500 dark:text-gray-400 mt-0.5"></p>
</div>
</div>
<svg id="tools-chevron" class="w-5 h-5 text-gray-400 transition-transform" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7"/>
</svg>
</button>
<div id="tools-section" class="hidden border-t border-gray-200 dark:border-gray-700 p-6 space-y-4">
<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 tier selector (F.7d: rendered from plugin-specific presets) -->
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
{% if lang == 'fa' %}سطح دسترسی{% else %}Access Level{% endif %}
</label>
<div id="scope-tiers" class="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-{{ scope_presets|length }} gap-2">
{% for preset in scope_presets %}
<button type="button" data-scope="{{ preset.value }}" onclick="selectScope('{{ preset.value }}')"
class="scope-btn px-3 py-2.5 rounded-lg border-2 text-sm font-medium transition-all text-center">
<div class="font-semibold">{% if lang == 'fa' %}{{ preset.label_fa }}{% else %}{{ preset.label }}{% endif %}</div>
<div class="text-xs opacity-70 mt-0.5">{% if lang == 'fa' %}{{ preset.hint_fa }}{% else %}{{ preset.hint }}{% endif %}</div>
</button>
{% endfor %}
</div>
<div id="scope-status" class="hidden text-xs mt-2"></div>
<div id="scope-notice" class="hidden mt-3 rounded-lg p-3 text-xs"></div>
</div>
<!-- Per-tool toggles (visible for all tiers, expanded for custom) -->
<div id="tool-overrides-wrapper">
<div class="flex items-center justify-between mb-2">
<p class="text-sm font-medium text-gray-700 dark:text-gray-300">
{% if lang == 'fa' %}ابزارها{% else %}Tools{% endif %}
<span id="tool-count" class="text-gray-400 font-normal"></span>
</p>
<input type="text" id="tool-search" placeholder="{% if lang == 'fa' %}جستجو...{% else %}Search...{% endif %}"
class="w-48 bg-gray-50 dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-lg px-3 py-1.5 text-sm text-gray-900 dark:text-white focus:ring-2 focus:ring-blue-500 focus:border-transparent">
</div>
<div id="tool-list" class="space-y-1 max-h-80 overflow-y-auto border border-gray-200 dark:border-gray-700 rounded-lg p-3"></div>
</div>
</div>
</div>
</div>
<!-- ═══════════════════════════════════════════════════════════════ -->
<!-- Section 3: Connect -->
<!-- ═══════════════════════════════════════════════════════════════ -->
<div class="bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700">
<button type="button" onclick="toggleSection('connect')" class="w-full flex items-center justify-between p-6 cursor-pointer">
<div class="flex items-center gap-3">
<div class="w-8 h-8 bg-green-500/20 rounded-lg flex items-center justify-center">
<svg class="w-4 h-4 text-green-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 9l3 3-3 3m5 0h3M5 20h14a2 2 0 002-2V6a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z"/>
</svg>
</div>
<h3 class="text-base font-semibold text-gray-900 dark:text-white">
{% if lang == 'fa' %}اتصال{% else %}Connect{% endif %}
</h3>
</div>
<svg id="connect-chevron" class="w-5 h-5 text-gray-400 transition-transform rotate-180" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7"/>
</svg>
</button>
<div id="connect-section" class="border-t border-gray-200 dark:border-gray-700 p-6 space-y-5">
<!-- MCP Endpoint URL -->
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
{% if lang == 'fa' %}آدرس MCP{% else %}MCP Endpoint{% endif %}
</label>
<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="copyText('mcp-url')" 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-1.5">
{% if lang == 'fa' %}برای احراز هویت از کلید API یا OAuth استفاده کنید.{% else %}Authenticate with an API key (Bearer token) or OAuth.{% endif %}
</p>
</div>
<!-- Config Snippets -->
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
{% if lang == 'fa' %}نمونه کد پیکربندی{% else %}Configuration Snippet{% 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 mb-3">
{% for client in clients %}
<option value="{{ client.id }}">{{ client.label }}</option>
{% endfor %}
</select>
<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-[80px]">{% 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-3 bg-blue-50 dark:bg-blue-500/10 border border-blue-200 dark:border-blue-500/30 rounded-lg p-3">
<p class="text-xs text-blue-700 dark:text-blue-400">
{% if lang == 'fa' %}<strong>نکته:</strong> از <code>streamableHttp</code> برای Claude Desktop و <code>http</code> برای VS Code استفاده کنید.
{% else %}<strong>Note:</strong> Use <code>streamableHttp</code> for Claude Desktop, <code>http</code> for VS Code/Claude Code.{% endif %}
</p>
</div>
</div>
<!-- Quick API Key Create -->
<div class="border-t border-gray-200 dark:border-gray-700 pt-4">
<div class="flex items-center justify-between mb-3">
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300">
{% if lang == 'fa' %}کلید API{% else %}API Key{% endif %}
</label>
<a href="/dashboard/keys{% if lang and lang != 'en' %}?lang={{ lang }}{% endif %}" class="text-xs text-blue-500 hover:text-blue-400">
{% if lang == 'fa' %}مدیریت کلیدها{% else %}Manage Keys{% endif %} &rarr;
</a>
</div>
<div id="quick-key-area">
<div class="flex gap-2">
<input type="text" id="quick-key-name" placeholder="{% if lang == 'fa' %}نام کلید (مثلا Claude Desktop){% else %}Key name (e.g. Claude Desktop){% endif %}"
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-sm text-gray-900 dark:text-white focus:ring-2 focus:ring-blue-500">
<button onclick="quickCreateKey()" id="quick-key-btn"
class="px-4 py-2 bg-green-600 hover:bg-green-700 rounded-lg text-white text-sm font-medium transition-colors whitespace-nowrap">
{% if lang == 'fa' %}ایجاد کلید{% else %}Create Key{% endif %}
</button>
</div>
<p class="text-xs text-gray-500 dark:text-gray-400 mt-2">
{% if lang == 'fa' %}
این کلید به‌طور خودکار فقط برای این سرویس (<span class="font-mono">{{ site.alias }}</span>) فعال می‌شود.
{% else %}
This key will be auto-scoped to this service (<span class="font-mono">{{ site.alias }}</span>) only.
{% endif %}
</p>
<div id="quick-key-result" class="hidden mt-3">
<div class="bg-yellow-500/10 border border-yellow-500/30 rounded-lg p-3 mb-2">
<p class="text-xs text-yellow-600 dark:text-yellow-400">{% if lang == 'fa' %}این کلید فقط یکبار نمایش داده می‌شود!{% else %}This key is only shown once!{% endif %}</p>
</div>
<div class="flex items-center gap-2">
<input type="text" id="quick-key-value" 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-sm font-mono text-gray-900 dark:text-white">
<button onclick="copyText('quick-key-value', true)" class="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>
</div>
</div>
</div>
</div>
</div>
</div>
{% endblock %}
{% block scripts %}
<script>
const siteId = "{{ site.id }}";
const siteAlias = "{{ site.alias }}";
const pluginType = "{{ site.plugin_type }}";
const pluginFields = {{ plugin_fields_json | safe }};
const WEB_CLIENTS = ['claude_connectors', 'chatgpt'];
// ── Section toggle ──────────────────────────────────────────────
function toggleSection(name) {
const section = document.getElementById(name + '-section');
const chevron = document.getElementById(name + '-chevron');
section.classList.toggle('hidden');
chevron.classList.toggle('rotate-180');
}
// ── Connection form ─────────────────────────────────────────────
document.getElementById('edit-site-form').addEventListener('submit', async (e) => {
e.preventDefault();
const btn = document.getElementById('submit-btn');
const errorEl = document.getElementById('error-msg');
errorEl.classList.add('hidden');
btn.disabled = true;
btn.textContent = '{{ t.updating_site }}';
const url = document.getElementById('url').value;
const creds = {};
if (pluginFields[pluginType]) {
pluginFields[pluginType].forEach(field => {
const input = document.querySelector(`[name="cred_${field.name}"]`);
if (!input) return;
const val = input.value.trim();
if (val) creds[field.name] = val;
else if (input.dataset.required === 'false') creds[field.name] = '';
});
}
try {
const resp = await fetch('/api/sites/' + siteId, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ url, credentials: creds }),
});
const data = await resp.json();
if (resp.ok) {
btn.textContent = '{% if lang == "fa" %}ذخیره شد{% else %}Saved!{% endif %}';
btn.className = btn.className.replace('bg-blue-600 hover:bg-blue-700', 'bg-green-600 hover:bg-green-700');
setTimeout(() => {
btn.textContent = '{{ t.save }}';
btn.className = btn.className.replace('bg-green-600 hover:bg-green-700', 'bg-blue-600 hover:bg-blue-700');
btn.disabled = false;
}, 2000);
} else {
errorEl.textContent = data.error || 'Failed to update site';
errorEl.classList.remove('hidden');
btn.textContent = '{{ t.save }}';
btn.disabled = false;
}
} catch (err) {
errorEl.textContent = 'Network error: ' + err.message;
errorEl.classList.remove('hidden');
btn.textContent = '{{ t.save }}';
btn.disabled = false;
}
});
async function testConnection() {
const btn = document.getElementById('test-btn');
const status = document.getElementById('test-status');
btn.disabled = true;
status.textContent = '{% if lang == "fa" %}در حال تست...{% else %}Testing...{% endif %}';
status.className = 'text-sm text-gray-400';
try {
const r = await fetch('/api/sites/' + siteId + '/test', { method: 'POST' });
const d = await r.json();
if (r.ok) {
status.textContent = '{% if lang == "fa" %}متصل{% else %}Connected{% endif %}';
status.className = 'text-sm text-green-500';
} else {
status.textContent = d.error || '{% if lang == "fa" %}خطا{% else %}Failed{% endif %}';
status.className = 'text-sm text-red-500';
}
} catch (_) {
status.textContent = '{% if lang == "fa" %}خطای شبکه{% else %}Network error{% endif %}';
status.className = 'text-sm text-red-500';
}
btn.disabled = false;
setTimeout(() => { status.textContent = ''; }, 4000);
}
// ── Tool Access ─────────────────────────────────────────────────
let allTools = [];
let currentScope = 'admin';
async function loadToolAccess() {
try {
const r = await fetch('/api/sites/' + siteId + '/tools');
if (!r.ok) return;
const data = await r.json();
allTools = data.tools || [];
currentScope = data.tool_scope || 'admin';
renderToolAccess();
} catch (_) {}
}
function renderToolAccess() {
document.getElementById('tool-access-loading').classList.add('hidden');
document.getElementById('tool-access-content').classList.remove('hidden');
// Update scope buttons
document.querySelectorAll('.scope-btn').forEach(btn => {
const s = btn.dataset.scope;
if (s === currentScope) {
btn.className = 'scope-btn px-3 py-2.5 rounded-lg border-2 text-sm font-medium transition-all text-center border-blue-500 bg-blue-500/10 text-blue-600 dark:text-blue-400';
} else {
btn.className = 'scope-btn px-3 py-2.5 rounded-lg border-2 text-sm font-medium transition-all text-center border-gray-200 dark:border-gray-600 text-gray-600 dark:text-gray-400 hover:border-gray-300 dark:hover:border-gray-500';
}
});
// Summary — show in-scope tools count
const inScopeTools = allTools.filter(t => isToolInScope(t));
const enabled = inScopeTools.filter(t => t.enabled !== false).length;
const scopeLabel = currentScope === 'custom' ? '' : ' [' + currentScope + ']';
document.getElementById('tool-summary').textContent =
'{% if lang == "fa" %}' + enabled + ' از ' + inScopeTools.length + ' ابزار فعال' + scopeLabel + '{% else %}' + enabled + ' of ' + inScopeTools.length + ' tools enabled' + scopeLabel + '{% endif %}';
document.getElementById('tool-count').textContent = '(' + allTools.length + ')';
renderScopeNotice();
renderToolList();
}
// F.7d: Credential requirement — per plugin + scope, describes what
// the Credential already saved in Connection Settings MUST grant on
// the source service so the chosen Access Level is actually usable.
// Phrased as a requirement on the existing credential, not an imperative
// to create a new one.
const CREDENTIAL_GUIDES = {
wordpress: {
read: {
en: 'The Application Password saved in Connection Settings must belong to a WordPress user with at least <strong>Editor</strong> role (or lower). No CRUD capabilities required.',
fa: 'Application Password ثبت‌شده در Connection Settings باید متعلق به کاربری با حداقل نقش <strong>Editor</strong> (یا پایین‌تر) باشد. نیاز به قابلیت CRUD نیست.'
},
admin: {
en: 'The Application Password saved in Connection Settings must belong to a WordPress <strong>Administrator</strong> — required for full CRUD. SEO + plugin/theme tools additionally require the <code class="font-mono">Airano MCP SEO Bridge</code> plugin to be installed on the site.',
fa: 'Application Password ثبت‌شده در Connection Settings باید متعلق به کاربر <strong>Administrator</strong> باشد تا CRUD کامل کار کند. ابزارهای SEO و مدیریت افزونه/قالب علاوه بر آن نیازمند نصب افزونه <code class="font-mono">Airano MCP SEO Bridge</code> روی سایت هستند.'
}
},
wordpress_advanced: {
read: {
en: 'The Application Password in Connection Settings must belong to an <strong>Editor</strong>-or-lower user. Read-only WP-CLI operations are enforced by role caps.',
fa: 'Application Password ثبت‌شده در Connection Settings باید برای کاربری با نقش <strong>Editor</strong> یا پایین‌تر باشد.'
},
admin: {
en: 'The Application Password in Connection Settings must belong to an <strong>Administrator</strong> and the Docker container name must be correct for shell-level plugin/theme operations. Install <code class="font-mono">Airano MCP SEO Bridge</code> on the site to unlock SEO + plugin/theme tools.',
fa: 'Application Password ثبت‌شده باید متعلق به کاربر <strong>Administrator</strong> باشد و نام Docker Container درست وارد شده باشد. برای ابزارهای SEO افزونه <code class="font-mono">Airano MCP SEO Bridge</code> روی سایت نصب باشد.'
}
},
woocommerce: {
read: {
en: 'The REST API key (Consumer Key / Secret) saved in Connection Settings must have <strong>Read</strong> permission, and the creating WP user at least <em>Shop Manager</em> to see orders/customers.',
fa: 'کلید REST API (Consumer Key / Secret) ثبت‌شده در Connection Settings باید سطح <strong>Read</strong> داشته باشد و کاربر سازنده حداقل نقش <em>Shop Manager</em> را داشته باشد.'
},
admin: {
en: 'The REST API key in Connection Settings must have <strong>Read/Write</strong> permission and belong to an <em>Administrator</em> or <em>Shop Manager</em> — enables all 28 store-management tools. SEO tools additionally require the <code class="font-mono">Airano MCP SEO Bridge</code> plugin.',
fa: 'کلید REST API ثبت‌شده باید سطح <strong>Read/Write</strong> داشته و متعلق به <em>Administrator</em> یا <em>Shop Manager</em> باشد تا همه ۲۸ ابزار فعال شوند. ابزارهای SEO نیازمند افزونه <code class="font-mono">Airano MCP SEO Bridge</code> هستند.'
}
},
gitea: {
read: {
en: 'The Access Token in Connection Settings must have scopes <code class="font-mono">read:repository</code>, <code class="font-mono">read:issue</code>, <code class="font-mono">read:user</code>, <code class="font-mono">read:organization</code> (Gitea 1.19+ scoped tokens).',
fa: 'توکن ثبت‌شده در Connection Settings باید اسکوپ‌های <code class="font-mono">read:repository</code>، <code class="font-mono">read:issue</code>، <code class="font-mono">read:user</code> و <code class="font-mono">read:organization</code> را داشته باشد.'
},
write: {
en: 'The Access Token in Connection Settings must include the <code class="font-mono">read:*</code> scopes above <strong>plus</strong> <code class="font-mono">write:repository</code>, <code class="font-mono">write:issue</code>, <code class="font-mono">write:user</code>.',
fa: 'توکن ثبت‌شده علاوه بر اسکوپ‌های <code class="font-mono">read:*</code> باید <code class="font-mono">write:repository</code>، <code class="font-mono">write:issue</code> و <code class="font-mono">write:user</code> را هم داشته باشد.'
},
admin: {
en: 'The Access Token in Connection Settings must belong to a Gitea <strong>site admin</strong> and include <code class="font-mono">admin:repo_hook</code>, <code class="font-mono">admin:org_hook</code>, <code class="font-mono">admin:application</code> scopes.',
fa: 'توکن ثبت‌شده باید متعلق به یک <strong>site admin</strong> در Gitea باشد و اسکوپ‌های <code class="font-mono">admin:repo_hook</code>، <code class="font-mono">admin:org_hook</code> و <code class="font-mono">admin:application</code> را داشته باشد.'
}
},
openpanel: {
read: {
en: 'The Client ID / Secret in Connection Settings must belong to a client created in <strong>read</strong> mode — limited to Export + Insights on the current project.',
fa: 'جفت Client ID / Secret ثبت‌شده در Connection Settings باید متعلق به Clientی با حالت <strong>read</strong> باشد (فقط Export و Insights در پروژه جاری).'
},
write: {
en: 'The Client ID / Secret in Connection Settings must belong to a <strong>write</strong>-mode client (event tracking / ingestion only, no export).',
fa: 'جفت Client ID / Secret ثبت‌شده باید متعلق به Clientی با حالت <strong>write</strong> باشد (فقط ارسال رویداد، بدون Export).'
},
admin: {
en: 'The Client ID / Secret in Connection Settings must belong to a <strong>root</strong>-mode client — required for exporting any project, managing other projects, and full organisation access.',
fa: 'جفت Client ID / Secret ثبت‌شده باید متعلق به Clientی در حالت <strong>root</strong> باشد (خروجی از همه پروژه‌ها و مدیریت سازمان).'
}
},
coolify: {
read: {
en: 'The API token saved in Connection Settings must have permission <code class="font-mono">read</code> — view resources only, no secrets.',
fa: 'توکن API ثبت‌شده در Connection Settings باید مجوز <code class="font-mono">read</code> داشته باشد — فقط مشاهده‌ی منابع بدون اسرار.'
},
'read:sensitive': {
en: 'The API token saved in Connection Settings must have permission <code class="font-mono">read:sensitive</code> — view resources <em>plus</em> env vars and backups.',
fa: 'توکن API ثبت‌شده باید مجوز <code class="font-mono">read:sensitive</code> داشته باشد — شامل مشاهده متغیرهای محیطی و بکاپ.'
},
deploy: {
en: 'The API token saved in Connection Settings must have <code class="font-mono">read</code> + <code class="font-mono">write</code> permissions (Coolify has no separate deploy permission — write enables start/stop/restart/deploy).',
fa: 'توکن API ثبت‌شده باید مجوزهای <code class="font-mono">read</code> و <code class="font-mono">write</code> را داشته باشد (Coolify مجوز deploy جداگانه ندارد؛ write اجازه start/stop/restart/deploy می‌دهد).'
},
write: {
en: 'The API token saved in Connection Settings must have <code class="font-mono">read</code> + <code class="font-mono">write</code> + <code class="font-mono">read:sensitive</code> for full CRUD + env management.',
fa: 'توکن API ثبت‌شده باید <code class="font-mono">read</code> + <code class="font-mono">write</code> + <code class="font-mono">read:sensitive</code> داشته باشد تا CRUD کامل و مدیریت env کار کند.'
},
admin: {
en: 'The API token saved in Connection Settings must have permission <code class="font-mono">root</code> — unrestricted, including system commands and team administration.',
fa: 'توکن API ثبت‌شده باید مجوز <code class="font-mono">root</code> داشته باشد — دسترسی نامحدود شامل دستورات سیستمی و مدیریت تیم.'
}
}
};
function renderScopeNotice() {
const el = document.getElementById('scope-notice');
if (!el) return;
const isFa = {% if lang == 'fa' %}true{% else %}false{% endif %};
const scopeLabels = { read: 'Read', 'read:sensitive': 'Read+Secrets', deploy: 'Deploy', write: 'Write', admin: 'Full Access' };
if (currentScope === 'custom') {
el.classList.add('hidden');
el.innerHTML = '';
return;
}
const guide = (CREDENTIAL_GUIDES[pluginType] || {})[currentScope];
if (!guide) {
el.classList.add('hidden');
el.innerHTML = '';
return;
}
const title = isFa
? 'پیش‌نیاز Credential برای سطح <strong>' + (scopeLabels[currentScope] || currentScope) + '</strong>:'
: 'Credential requirement for <strong>' + (scopeLabels[currentScope] || currentScope) + '</strong>:';
const body = isFa ? guide.fa : guide.en;
el.innerHTML =
'<div class="bg-indigo-50 dark:bg-indigo-500/10 border border-indigo-200 dark:border-indigo-500/30 rounded-lg p-3 text-indigo-700 dark:text-indigo-300">' +
'<p class="mb-1">' + title + '</p>' +
'<p>' + body + '</p>' +
'</div>';
el.classList.remove('hidden');
}
function renderToolList() {
const container = document.getElementById('tool-list');
const search = (document.getElementById('tool-search').value || '').toLowerCase();
container.innerHTML = '';
// Group by required_scope
const groups = { read: [], write: [], admin: [] };
for (const tool of allTools) {
const s = tool.required_scope || 'read';
if (!groups[s]) groups[s] = [];
groups[s].push(tool);
}
const scopeLabels = {
read: '{% if lang == "fa" %}خواندن{% else %}Read{% endif %}',
write: '{% if lang == "fa" %}نوشتن{% else %}Write{% endif %}',
admin: 'Admin',
};
let visibleCount = 0;
for (const scope of ['read', 'write', 'admin']) {
const tools = (groups[scope] || []).filter(t => !search || t.name.toLowerCase().includes(search) || (t.description || '').toLowerCase().includes(search));
if (!tools.length) continue;
const header = document.createElement('p');
header.className = 'text-xs font-semibold uppercase tracking-wider text-gray-400 dark:text-gray-500 mb-1 mt-3 first:mt-0';
header.textContent = scopeLabels[scope] || scope;
container.appendChild(header);
for (const tool of tools) {
container.appendChild(renderToolRow(tool));
visibleCount++;
}
}
if (visibleCount === 0) {
container.innerHTML = '<p class="text-sm text-gray-400 py-4 text-center">{% if lang == "fa" %}ابزاری یافت نشد{% else %}No tools found{% endif %}</p>';
}
}
// Universal scope tiers: maps scope → set of allowed required_scope values
const SCOPE_TIERS = {
read: new Set(['read']),
write: new Set(['read', 'write']),
admin: new Set(['read', 'write', 'admin']),
custom: null, // no filter
};
// F.7d: Coolify-style category overlay for fine-grained tiers.
// Mirrors core.tool_access.SCOPE_TO_CATEGORIES.
const SCOPE_CATEGORIES = {
'read': new Set(['read']),
'read:sensitive': new Set(['read', 'read_sensitive', 'backup']),
'deploy': new Set(['read', 'lifecycle']),
'write': new Set(['read', 'lifecycle', 'crud', 'env']),
'admin': new Set(['read', 'read_sensitive', 'lifecycle', 'crud', 'env', 'backup', 'system']),
};
const KNOWN_CATEGORIES = new Set(['read', 'read_sensitive', 'lifecycle', 'crud', 'env', 'backup', 'system']);
const CATEGORY_PLUGINS = new Set(['coolify']);
function isToolInScope(tool) {
if (currentScope === 'custom') return true;
// Coolify-style category match (only for plugins with category annotations).
if (CATEGORY_PLUGINS.has(pluginType)) {
const allowed = SCOPE_CATEGORIES[currentScope];
if (!allowed) return true;
// Tools without a known category are always visible.
if (!KNOWN_CATEGORIES.has(tool.category)) return true;
return allowed.has(tool.category);
}
// Universal 3-tier filter for everything else.
const tier = SCOPE_TIERS[currentScope];
if (!tier) return true;
return tier.has(tool.required_scope || 'read');
}
async function selectScope(scope) {
currentScope = scope;
renderToolAccess();
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' },
body: JSON.stringify({ scope }),
});
if (r.ok) {
statusEl.textContent = '{% if lang == "fa" %}ذخیره شد{% else %}Saved{% endif %}';
statusEl.className = 'text-xs text-green-500';
} else {
const data = await r.json().catch(() => ({}));
statusEl.textContent = data.error || '{% if lang == "fa" %}خطا{% else %}Error{% 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);
}
function renderToolRow(tool) {
const inScope = isToolInScope(tool);
const row = document.createElement('div');
row.className = 'flex items-center justify-between py-1.5 px-2 rounded hover:bg-gray-50 dark:hover:bg-gray-700/30' + (inScope ? '' : ' opacity-40');
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 truncate font-mono ' + (inScope ? 'text-gray-800 dark:text-gray-200' : 'text-gray-400 dark:text-gray-500 line-through');
nameEl.textContent = tool.name;
nameEl.title = (tool.description || '') + (inScope ? '' : ' — {% if lang == "fa" %}خارج از سطح دسترسی{% else %}outside current scope{% endif %}');
left.appendChild(nameEl);
if (!inScope) {
const scopeBadge = document.createElement('span');
scopeBadge.className = 'flex-shrink-0 px-1.5 py-0.5 rounded text-xs font-medium bg-gray-100 dark:bg-gray-700 text-gray-400 dark:text-gray-500';
scopeBadge.textContent = tool.required_scope || 'read';
left.appendChild(scopeBadge);
}
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);
}
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' },
body: JSON.stringify({ enabled }),
});
if (!r.ok) input.checked = !enabled;
else {
tool.enabled = enabled;
const en = allTools.filter(t => t.enabled !== false).length;
document.getElementById('tool-summary').textContent =
'{% if lang == "fa" %}' + en + ' از ' + allTools.length + ' ابزار فعال{% else %}' + en + ' of ' + allTools.length + ' tools enabled{% endif %}';
}
} 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;
}
// Tool search
document.getElementById('tool-search').addEventListener('input', renderToolList);
// ── Config Snippets ─────────────────────────────────────────────
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' : '';
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{% endif %}';
} catch (_) {
document.getElementById('config-output').textContent = '{% if lang == "fa" %}خطای شبکه{% else %}Network error{% endif %}';
}
}
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);
}
function copyText(elementId, isInput) {
const el = document.getElementById(elementId);
const text = isInput ? el.value : el.textContent.trim();
navigator.clipboard.writeText(text);
}
// ── Quick Key Create ────────────────────────────────────────────
async function quickCreateKey() {
const name = document.getElementById('quick-key-name').value.trim();
if (!name) { document.getElementById('quick-key-name').focus(); return; }
const btn = document.getElementById('quick-key-btn');
btn.disabled = true;
try {
// F.7d: Connect-section keys are auto-scoped to this site
const r = await fetch('/api/keys', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name, scopes: 'read write admin', site_id: siteId }),
});
const res = await r.json();
if (r.ok) {
document.getElementById('quick-key-value').value = res.key.key;
document.getElementById('quick-key-result').classList.remove('hidden');
btn.textContent = '{% if lang == "fa" %}ایجاد شد{% else %}Created!{% endif %}';
} else {
alert(res.error || 'Failed');
}
} catch (e) { alert('Network error'); }
btn.disabled = false;
}
// ── Init ────────────────────────────────────────────────────────
document.addEventListener('DOMContentLoaded', () => {
loadToolAccess();
updateConfig();
});
</script>
{% endblock %}

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 %}

View File

@@ -0,0 +1,3 @@
@tailwind base;
@tailwind components;
@tailwind utilities;

File diff suppressed because one or more lines are too long

532
core/tool_access.py Normal file
View File

@@ -0,0 +1,532 @@
"""Tool access manager — site-scoped visibility and per-site toggles (F.7c).
Provides a central pipeline that filters the set of MCP tools presented for
a user endpoint based on:
1. **Universal scope tiers.** A 3-level system (``read`` / ``write`` /
``admin``) that works across ALL plugins using the tool's
``required_scope`` field. For Coolify (which has fine-grained
``category`` annotations) the legacy category mapping is kept as an
overlay for the ``custom`` preset.
2. **Per-site tool toggles.** Site owners may explicitly disable specific
tools via the ``site_tool_toggles`` table. Only overrides are stored —
tools without an entry are enabled by default.
The ``tool_scope`` value ``"custom"`` is a sentinel meaning "do not apply a
site-level preset filter" — in that case only the per-tool toggles and the
key scope are considered.
Usage::
from core.tool_access import get_tool_access_manager
mgr = get_tool_access_manager()
visible = await mgr.get_visible_tools(
site_id=site["id"],
key_scopes=["read"],
plugin_type="coolify",
)
"""
from __future__ import annotations
import logging
from typing import Any
from core.tool_registry import ToolDefinition
logger = logging.getLogger(__name__)
# ── Universal 3-tier scope system (F.7c) ─────────────────────────────
# Maps a scope tier to the set of ``required_scope`` values it may access.
# Works for ALL plugins because every tool has ``required_scope``.
UNIVERSAL_SCOPE_TIERS: dict[str, set[str]] = {
"read": {"read"},
"write": {"read", "write"},
"admin": {"read", "write", "admin"},
}
# ── Legacy Coolify category mapping (kept for ``custom`` overlay) ─────
# Mapping from scope → set of tool categories that scope may see.
SCOPE_TO_CATEGORIES: dict[str, set[str]] = {
"read": {"read"},
"read:sensitive": {"read", "read_sensitive", "backup"},
"deploy": {"read", "lifecycle"},
"write": {"read", "lifecycle", "crud", "env"},
"admin": {
"read",
"read_sensitive",
"lifecycle",
"crud",
"env",
"backup",
"system",
},
}
# All known Coolify categories.
KNOWN_CATEGORIES: set[str] = {
"read",
"read_sensitive",
"lifecycle",
"crud",
"env",
"backup",
"system",
}
# Sentinel meaning "no site-level preset filter — use per-tool toggles only".
SCOPE_CUSTOM = "custom"
# Plugins that have fine-grained category annotations.
_CATEGORY_PLUGINS: set[str] = {"coolify"}
def scopes_to_categories(scopes: list[str]) -> set[str]:
"""Return the union of categories allowed by the given scope list.
Args:
scopes: List of scope strings as presented on the API key / token.
Returns:
Set of category names the scopes collectively allow.
"""
allowed: set[str] = set()
for scope in scopes:
allowed |= SCOPE_TO_CATEGORIES.get(scope.strip(), set())
return allowed
def _scopes_to_required(scopes: list[str]) -> set[str]:
"""Return the union of ``required_scope`` values allowed by universal tiers."""
allowed: set[str] = set()
for scope in scopes:
allowed |= UNIVERSAL_SCOPE_TIERS.get(scope.strip(), set())
return allowed
def get_scope_presets_for_plugin(plugin_type: str) -> list[dict[str, str]]:
"""Return the appropriate scope presets for a plugin type (F.7d).
Each preset is a dict with ``value`` (the canonical scope key persisted
to ``sites.tool_scope``), ``label`` / ``label_fa`` (button title), and
``hint`` / ``hint_fa`` (one-line description shown under the title).
The valid scope values are constrained by ``_VALID_TOOL_SCOPES`` in
``core.dashboard.routes`` — currently:
``{"read", "read:sensitive", "deploy", "write", "admin", "custom"}``.
"""
custom = {
"value": "custom",
"label": "Custom",
"label_fa": "سفارشی",
"hint": "Per-tool toggles",
"hint_fa": "هر ابزار جداگانه",
}
if plugin_type == "coolify":
# 5 fine-grained Coolify tiers + custom (matches SCOPE_TO_CATEGORIES).
return [
{
"value": "read",
"label": "Read",
"label_fa": "خواندن",
"hint": "List/inspect resources",
"hint_fa": "مشاهده و فهرست منابع",
},
{
"value": "read:sensitive",
"label": "Read + Secrets",
"label_fa": "خواندن + اسرار",
"hint": "Includes env vars and backups",
"hint_fa": "شامل متغیرهای محیطی و بکاپ",
},
{
"value": "deploy",
"label": "Deploy",
"label_fa": "استقرار",
"hint": "Read + lifecycle (start/stop/restart)",
"hint_fa": "مشاهده + راه‌اندازی/توقف/ریستارت",
},
{
"value": "write",
"label": "Write",
"label_fa": "نوشتن",
"hint": "Read + lifecycle + CRUD + env",
"hint_fa": "مشاهده + لایفسایکل + CRUD + env",
},
{
"value": "admin",
"label": "Root",
"label_fa": "روت",
"hint": "Everything including system commands",
"hint_fa": "همه چیز شامل دستورات سیستم",
},
custom,
]
if plugin_type == "openpanel":
return [
{
"value": "read",
"label": "Read",
"label_fa": "خواندن",
"hint": "Export current project only",
"hint_fa": "خروجی پروژه فعلی",
},
{
"value": "write",
"label": "Write",
"label_fa": "نوشتن",
"hint": "Default ingestion (track events)",
"hint_fa": "ارسال رویداد (ingestion)",
},
{
"value": "admin",
"label": "Root",
"label_fa": "روت",
"hint": "Export any project",
"hint_fa": "خروجی هر پروژه",
},
custom,
]
if plugin_type == "woocommerce":
# WooCommerce has no admin-scope tools (read=14, write=14, admin=0),
# so Write and "Read + Write" tiers are identical. Present a single
# full-access tier instead of two duplicates.
return [
{
"value": "read",
"label": "Read Only",
"label_fa": "فقط خواندن",
"hint": "Browse products, orders, customers",
"hint_fa": "مشاهده محصولات، سفارش‌ها و مشتریان",
},
{
"value": "admin",
"label": "Read + Write",
"label_fa": "خواندن + نوشتن",
"hint": "Full store management (all 28 tools)",
"hint_fa": "مدیریت کامل فروشگاه (همه ۲۸ ابزار)",
},
custom,
]
if plugin_type in {"wordpress", "wordpress_advanced"}:
# WordPress has no admin-scope tools (read=27, write=40, admin=0).
# SEO + plugin/theme tools require the Airano MCP SEO Bridge plugin
# to be installed on the WP site itself. Present 2 tiers + custom.
return [
{
"value": "read",
"label": "Read Only",
"label_fa": "فقط خواندن",
"hint": "View posts, pages, media",
"hint_fa": "مشاهده نوشته‌ها، صفحات و رسانه",
},
{
"value": "admin",
"label": "Full Access",
"label_fa": "دسترسی کامل",
"hint": "All tools (CRUD + SEO via add-on)",
"hint_fa": "همه ابزارها (CRUD و SEO با افزونه)",
},
custom,
]
if plugin_type == "gitea":
return [
{
"value": "read",
"label": "Read",
"label_fa": "خواندن",
"hint": "Browse repos, issues, users",
"hint_fa": "مشاهده مخازن، ایشوها، کاربران",
},
{
"value": "write",
"label": "Read + Write",
"label_fa": "خواندن + نوشتن",
"hint": "Create issues, PRs, branches",
"hint_fa": "ایجاد ایشو، PR و شاخه",
},
{
"value": "admin",
"label": "Admin",
"label_fa": "مدیر",
"hint": "Repo + org + user admin",
"hint_fa": "مدیریت مخزن، سازمان و کاربر",
},
custom,
]
# Universal default for all other plugins.
return [
{
"value": "read",
"label": "Read",
"label_fa": "فقط خواندن",
"hint": "View only",
"hint_fa": "فقط مشاهده",
},
{
"value": "write",
"label": "Read + Write",
"label_fa": "خواندن + نوشتن",
"hint": "CRUD ops",
"hint_fa": "عملیات CRUD",
},
{
"value": "admin",
"label": "Full Access",
"label_fa": "دسترسی کامل",
"hint": "All tools",
"hint_fa": "همه ابزارها",
},
custom,
]
class ToolAccessManager:
"""Central manager for scope-based visibility and per-site tool toggles."""
def apply_scope_filter(
self,
tools: list[ToolDefinition],
scopes: list[str],
plugin_type: str | None = None,
) -> list[ToolDefinition]:
"""Drop tools not allowed by the presented scopes.
For plugins with category annotations (Coolify) the legacy
category-based filter is used. For all other plugins the universal
3-tier filter based on ``required_scope`` is applied.
Args:
tools: Candidate tool list.
scopes: Scopes presented on the API key (or a single-element list
containing a site's ``tool_scope`` preset).
plugin_type: Plugin type hint. When provided and the plugin is
NOT in ``_CATEGORY_PLUGINS``, the universal tier filter is
used.
Returns:
Filtered tool list.
"""
# Try universal tiers first (works for all plugins)
allowed_scopes = _scopes_to_required(scopes)
if allowed_scopes and (plugin_type is None or plugin_type not in _CATEGORY_PLUGINS):
# Universal filter: match tool.required_scope against allowed tiers
return [t for t in tools if t.required_scope in allowed_scopes]
# Fallback: legacy category-based filter for Coolify / custom scopes
allowed = scopes_to_categories(scopes)
if not allowed:
return [t for t in tools if t.category not in KNOWN_CATEGORIES]
result: list[ToolDefinition] = []
for tool in tools:
if tool.category not in KNOWN_CATEGORIES:
result.append(tool)
continue
if tool.category in allowed:
result.append(tool)
return result
async def apply_site_toggles(
self,
tools: list[ToolDefinition],
site_id: str,
) -> list[ToolDefinition]:
"""Drop tools the site owner has explicitly disabled.
Args:
tools: Candidate tool list.
site_id: Site UUID.
Returns:
Filtered tool list.
"""
from core.database import get_database
try:
db = get_database()
except RuntimeError:
return tools
toggles = await db.get_site_tool_toggles(site_id)
if not toggles:
return tools
return [t for t in tools if toggles.get(t.name, True)]
async def get_visible_tools(
self,
site_id: str,
key_scopes: list[str],
plugin_type: str,
) -> list[ToolDefinition]:
"""Return the visible tool list for a site on a given plugin.
Pipeline:
1. ``ToolRegistry.get_by_plugin_type``
2. Key-scope filter (API key's declared scopes)
3. Site-scope filter (site's stored ``tool_scope`` preset,
skipped when it is ``custom``)
4. Per-site toggle filter (``site_tool_toggles``)
Args:
site_id: Site UUID (the MCP endpoint alias resolves to this).
key_scopes: Scopes presented on the API key / token.
plugin_type: Plugin type (e.g. ``coolify``).
Returns:
List of visible ``ToolDefinition`` objects.
"""
from core.database import get_database
from core.tool_registry import get_tool_registry
registry = get_tool_registry()
tools = registry.get_by_plugin_type(plugin_type)
tools = self.apply_scope_filter(tools, key_scopes, plugin_type=plugin_type)
try:
db = get_database()
site_scope = await db.get_site_tool_scope(site_id)
except RuntimeError:
site_scope = "admin"
if site_scope and site_scope != SCOPE_CUSTOM:
tools = self.apply_scope_filter(tools, [site_scope], plugin_type=plugin_type)
tools = await self.apply_site_toggles(tools, site_id)
return tools
async def toggle_tool(
self,
site_id: str,
tool_name: str,
enabled: bool,
reason: str | None = None,
) -> None:
"""Enable or disable a single tool for a site.
Args:
site_id: Site UUID.
tool_name: Fully-qualified tool name.
enabled: True to enable, False to disable.
reason: Optional note.
"""
from core.database import get_database
db = get_database()
await db.set_site_tool_toggle(site_id, tool_name, enabled, reason)
logger.info(
"site %s toggled %s%s",
site_id,
tool_name,
"enabled" if enabled else "disabled",
)
async def bulk_toggle_by_scope(
self,
site_id: str,
scope_name: str,
enabled: bool,
plugin_type: str | None = None,
) -> int:
"""Toggle every tool whose category belongs to the given scope.
Only the *exclusive* category set of the scope is affected — i.e.
the categories explicitly listed under ``SCOPE_TO_CATEGORIES[scope_name]``.
Tools outside those categories are left unchanged.
Args:
site_id: Site UUID.
scope_name: Scope key (``"read"``, ``"deploy"``, ...).
enabled: True to enable, False to disable.
plugin_type: Optional filter — only affect tools from this plugin.
When ``None`` every plugin's tools in that category are touched.
Returns:
Number of tools affected.
"""
from core.database import get_database
from core.tool_registry import get_tool_registry
categories = SCOPE_TO_CATEGORIES.get(scope_name)
if categories is None:
raise ValueError(f"Unknown scope '{scope_name}'")
registry = get_tool_registry()
candidates = registry.get_all()
if plugin_type is not None:
candidates = [t for t in candidates if t.plugin_type == plugin_type]
affected = [t.name for t in candidates if t.category in categories]
if not affected:
return 0
db = get_database()
await db.bulk_set_site_tool_toggles(
site_id,
[(name, enabled) for name in affected],
reason=f"bulk:{scope_name}",
)
return len(affected)
async def list_tools_for_site(
self,
site_id: str,
plugin_type: str,
) -> list[dict[str, Any]]:
"""Return every tool for a plugin, annotated with per-site toggle state.
Used by the dashboard API to present the per-site management view.
Does not apply scope filters — the UI decides what to show.
Args:
site_id: Site UUID.
plugin_type: Plugin type.
Returns:
List of dicts with tool metadata + ``enabled`` flag.
"""
from core.database import get_database
from core.tool_registry import get_tool_registry
try:
db = get_database()
toggles = await db.get_site_tool_toggles(site_id)
except RuntimeError:
toggles = {}
registry = get_tool_registry()
tools = registry.get_by_plugin_type(plugin_type)
return [
{
"name": t.name,
"description": t.description,
"plugin_type": t.plugin_type,
"category": t.category,
"sensitivity": t.sensitivity,
"required_scope": t.required_scope,
"enabled": toggles.get(t.name, True),
}
for t in tools
]
# Singleton
_manager: ToolAccessManager | None = None
def get_tool_access_manager() -> ToolAccessManager:
"""Return the singleton :class:`ToolAccessManager`."""
global _manager
if _manager is None:
_manager = ToolAccessManager()
return _manager

View File

@@ -181,6 +181,9 @@ class ToolGenerator:
description = spec["description"]
schema = spec["schema"]
scope = spec.get("scope", "read")
# F.7: optional category + sensitivity for scope-based visibility
category = spec.get("category", "read")
sensitivity = spec.get("sensitivity", "normal")
# Create full tool name
tool_name = f"{plugin_type}_{action_name}"
@@ -202,6 +205,8 @@ class ToolGenerator:
handler=handler,
required_scope=scope,
plugin_type=plugin_type,
category=category,
sensitivity=sensitivity,
)
def _add_site_parameter(

View File

@@ -27,6 +27,10 @@ class ToolDefinition(BaseModel):
handler: Async function that executes the tool
required_scope: Required API key scope ("read", "write", "admin")
plugin_type: Plugin type this tool belongs to (e.g., "wordpress")
category: Tool category for scope-based visibility filtering (F.7)
One of: "read", "read_sensitive", "lifecycle", "crud", "env",
"backup", "system". Defaults to "read" for backward compatibility.
sensitivity: "normal" or "sensitive" (logs, envs, backups, connection strings).
"""
name: str = Field(..., description="Unique tool identifier")
@@ -40,6 +44,14 @@ class ToolDefinition(BaseModel):
default="read", description="Required API key scope (read/write/admin)"
)
plugin_type: str = Field(..., description="Plugin type (wordpress, gitea, etc)")
category: str = Field(
default="read",
description="Tool category for scope-based visibility (F.7)",
)
sensitivity: str = Field(
default="normal",
description="Data sensitivity: normal or sensitive (F.7)",
)
model_config = ConfigDict(arbitrary_types_allowed=True) # Allow Callable type

View File

@@ -27,6 +27,8 @@ from typing import Any
from starlette.requests import Request
from starlette.responses import JSONResponse, Response
from core.tool_registry import ToolDefinition
logger = logging.getLogger(__name__)
# Per-user rate limiting defaults
@@ -36,9 +38,6 @@ USER_RATE_LIMIT_PER_HR = int(os.getenv("USER_RATE_LIMIT_PER_HR", "500"))
# In-memory rate limit tracking: user_id -> list of timestamps
_rate_limits: dict[str, list[float]] = {}
# Cache for tool schemas per plugin type (computed once)
_tool_schema_cache: dict[str, list[dict[str, Any]]] = {}
def _check_user_rate_limit(user_id: str) -> tuple[bool, str]:
"""Check per-user rate limits.
@@ -71,24 +70,15 @@ def _check_user_rate_limit(user_id: str) -> tuple[bool, str]:
return True, ""
def _get_tools_for_plugin(plugin_type: str) -> list[dict[str, Any]]:
"""Get MCP tool definitions for a plugin type (cached).
def _tools_to_mcp_schema(tools: list[ToolDefinition]) -> list[dict[str, Any]]:
"""Convert ToolDefinition objects into MCP ``tools/list`` response shape.
Returns tool schemas with the ``site`` parameter removed
(auto-injected for user endpoints).
Strips the auto-injected ``site`` parameter, since user endpoints bind a
single site per alias.
"""
if plugin_type in _tool_schema_cache:
return _tool_schema_cache[plugin_type]
from core.tool_registry import get_tool_registry
registry = get_tool_registry()
tools = registry.get_by_plugin_type(plugin_type)
result = []
for tool_def in tools:
schema = deepcopy(tool_def.input_schema)
# Remove 'site' parameter (auto-injected)
if "properties" in schema:
schema["properties"].pop("site", None)
if "required" in schema and "site" in schema["required"]:
@@ -101,11 +91,24 @@ def _get_tools_for_plugin(plugin_type: str) -> list[dict[str, Any]]:
"inputSchema": schema,
}
)
_tool_schema_cache[plugin_type] = result
return result
async def _get_visible_tools_for_site(
site_id: str,
key_scopes: list[str],
plugin_type: str,
) -> list[dict[str, Any]]:
"""Return tools/list payload filtered by key scope + site scope + toggles (F.7b)."""
from core.tool_access import get_tool_access_manager
access = get_tool_access_manager()
tools = await access.get_visible_tools(
site_id=site_id, key_scopes=key_scopes, plugin_type=plugin_type
)
return _tools_to_mcp_schema(tools)
async def _execute_tool(
tool_name: str,
arguments: dict[str, Any],
@@ -237,6 +240,28 @@ async def user_mcp_handler(request: Request) -> Response:
_jsonrpc_error(None, -32600, "API key does not match user"),
status_code=403,
)
# Check site-scoped key: if key is scoped to a site, it can only
# be used for that specific site's alias.
key_site_id = key_info.get("site_id")
if key_site_id:
try:
from core.database import get_database
db = get_database()
site = await db.get_site(key_site_id, user_id)
if site is None or site.get("alias") != alias:
return JSONResponse(
_jsonrpc_error(
None,
-32600,
"API key is scoped to a different site",
),
status_code=403,
)
except RuntimeError:
pass # DB unavailable — allow through
key_scopes = key_info.get("scopes", "read").split()
else:
# Try OAuth JWT token (issued after consent flow via GitHub/Google login)
@@ -353,7 +378,7 @@ async def user_mcp_handler(request: Request) -> Response:
return Response(status_code=204)
elif method == "tools/list":
tools = _get_tools_for_plugin(site["plugin_type"])
tools = await _get_visible_tools_for_site(site["id"], key_scopes, site["plugin_type"])
return JSONResponse(_jsonrpc_result(req_id, {"tools": tools}))
elif method == "tools/call":
@@ -378,19 +403,56 @@ async def user_mcp_handler(request: Request) -> Response:
required_scope = tool_def.required_scope
# key_scopes is set during authentication (both mhu_ and JWT paths)
# F.7b: enforce category-based scope allowlist in addition to the
# legacy read/write/admin hierarchy. A tool is allowed only if BOTH
# (a) the legacy hierarchy grants it, AND
# (b) the tool's category is in BOTH the key-scope set AND the
# site's stored tool_scope set (the narrower layer wins).
from core.tool_access import KNOWN_CATEGORIES, SCOPE_CUSTOM, scopes_to_categories
scope_hierarchy = {"read": 1, "write": 2, "admin": 3}
required_level = scope_hierarchy.get(required_scope, 0)
key_level = max([scope_hierarchy.get(s, 0) for s in key_scopes] + [0])
legacy_ok = key_level >= required_level
if key_level < required_level:
key_cats = scopes_to_categories(key_scopes)
key_category_ok = tool_def.category not in KNOWN_CATEGORIES or tool_def.category in key_cats
# Site-level scope check (skipped for "custom" preset).
site_scope = site.get("tool_scope") or "admin"
if site_scope and site_scope != SCOPE_CUSTOM:
site_cats = scopes_to_categories([site_scope])
site_category_ok = (
tool_def.category not in KNOWN_CATEGORIES or tool_def.category in site_cats
)
else:
site_category_ok = True
if not (legacy_ok and key_category_ok and site_category_ok):
return JSONResponse(
_jsonrpc_error(
req_id,
-32600,
f"Insufficient scope. Tool '{tool_name}' requires '{required_scope}' scope.",
f"Insufficient scope. Tool '{tool_name}' requires "
f"scope '{required_scope}' (category '{tool_def.category}').",
)
)
# F.7b: honour per-site tool toggles — a disabled tool cannot be called
# even if scopes would otherwise allow it.
try:
toggles = await db.get_site_tool_toggles(site["id"])
if not toggles.get(tool_name, True):
return JSONResponse(
_jsonrpc_error(
req_id,
-32600,
f"Tool '{tool_name}' is disabled for this site.",
)
)
except Exception as exc: # non-fatal — fall through on DB errors
logger.warning("Failed to check site tool toggles for %s: %s", site["id"], exc)
# Decrypt credentials
try:
from core.encryption import get_credential_encryption

View File

@@ -49,8 +49,8 @@ class UserKeyManager:
"""
def __init__(self) -> None:
# Cache: raw_key -> (key_id, user_id, scopes, cached_at)
self._cache: dict[str, tuple[str, str, str, float]] = {}
# Cache: raw_key -> (key_id, user_id, scopes, site_id, cached_at)
self._cache: dict[str, tuple[str, str, str, str | None, float]] = {}
async def create_key(
self,
@@ -58,6 +58,7 @@ class UserKeyManager:
name: str,
scopes: str = "read write admin",
expires_in_days: int | None = None,
site_id: str | None = None,
) -> dict[str, Any]:
"""Create a new API key for a user.
@@ -66,10 +67,11 @@ class UserKeyManager:
name: Human label (e.g. "Claude Desktop").
scopes: Access scopes (default: "read write admin" for full access).
expires_in_days: Optional expiry in days from now. None = never.
site_id: Optional site UUID to scope key to a single site.
Returns:
Dict with ``key`` (plaintext, shown once), ``key_id``, ``name``,
``scopes``, ``created_at``, ``expires_at``.
``scopes``, ``created_at``, ``expires_at``, ``site_id``.
"""
from core.database import get_database
@@ -89,9 +91,10 @@ class UserKeyManager:
name=name,
scopes=scopes,
expires_at=expires_at,
site_id=site_id,
)
logger.info("Created user API key %s for user %s", row["id"], user_id)
logger.info("Created user API key %s for user %s (site=%s)", row["id"], user_id, site_id)
return {
"key": raw_key, # shown once
"key_id": row["id"],
@@ -99,6 +102,7 @@ class UserKeyManager:
"scopes": row["scopes"],
"created_at": row["created_at"],
"expires_at": row["expires_at"],
"site_id": row.get("site_id"),
}
async def validate_key(self, api_key: str) -> dict[str, Any] | None:
@@ -118,7 +122,7 @@ class UserKeyManager:
# Check cache first
cached = self._cache.get(api_key)
if cached is not None:
key_id, user_id, scopes, cached_at = cached
key_id, user_id, scopes, site_id, cached_at = cached
if time.time() - cached_at < _CACHE_TTL_SECONDS:
# Update usage in background (fire-and-forget via DB)
try:
@@ -130,7 +134,7 @@ class UserKeyManager:
asyncio.create_task(db.update_api_key_usage(key_id))
except Exception:
pass # Non-critical
return {"key_id": key_id, "user_id": user_id, "scopes": scopes}
return {"key_id": key_id, "user_id": user_id, "scopes": scopes, "site_id": site_id}
else:
del self._cache[api_key]
@@ -159,10 +163,12 @@ class UserKeyManager:
await db.update_api_key_usage(row["id"])
# Cache the result
site_id = row.get("site_id")
self._cache[api_key] = (
row["id"],
row["user_id"],
row["scopes"],
site_id,
time.time(),
)
@@ -170,6 +176,7 @@ class UserKeyManager:
"key_id": row["id"],
"user_id": row["user_id"],
"scopes": row["scopes"],
"site_id": site_id,
}
async def list_keys(self, user_id: str) -> list[dict[str, Any]]:

View File

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

View File

@@ -0,0 +1,136 @@
از مهارت project-ops برای آشنایی با محیط استفاده کن. حافظه و فایل‌های مرجع را بررسی کن.
## فایل‌های مرجع
- docs/plans/2026-03-25-v4-development-cycle.md (mcphub-internal) ← Phase F.7 طراحی کامل
- core/tool_generator.py (mcphub-internal) ← تولید ابزار فعلی
- core/tool_registry.py (mcphub-internal) ← رجیستری ابزار
- core/user_endpoints.py (mcphub-internal) ← فیلتر ابزار در endpoint کاربر
- core/user_keys.py (mcphub-internal) ← سیستم API key کاربر
- core/database.py (mcphub-internal) ← دیتابیس و مایگریشن
- core/plugin_visibility.py (mcphub-internal) ← فیلتر پلاگین فعلی
- core/dashboard/routes.py (mcphub-internal) ← روت‌های داشبورد
- server.py (mcphub-internal) ← middleware و scope enforcement
- CLAUDE.md (mcphub-internal)
## وضعیت فعلی
- MCPHub: v3.8.0 — 633 ابزار، 10 پلاگین، 67 ابزار Coolify
- تست‌ها: 766 (internal), 734 (public)
- CI سبز
- Scope فعلی: read/write/admin (سه سطح ساده)
- فیلتر فعلی: فقط plugin-level (ENABLED_PLUGINS) — بدون per-tool toggle
## ریپازیتوری
- MCPHub (internal): `/config/workspace/mcphub-internal` (branch Phase-1)
## هدف session: F.7 — Smart Tool Visibility & Scope-Based Access Control
### مشکلاتی که حل می‌شوند
1. همه ابزارهای یک پلاگین فعال به همه کاربران نشان داده می‌شوند — کنترل per-tool نداریم
2. وقتی کاربر API key با scope خاص (مثلا read) می‌سازد، باز هم همه ابزارها در tools/list نمایش داده می‌شوند
3. ابزارهای وردپرس که نیاز به افزونه‌های کمکی دارند (SEO Bridge, WP-CLI) بدون بررسی prerequisite نشان داده می‌شوند
4. کاربران نمی‌توانند ابزارهایی که نیاز ندارند را غیرفعال کنند
### مدل scope پیشنهادی (گسترش‌یافته)
فعلی: `read`, `write`, `admin`
جدید:
- `deploy` — عملیات lifecycle (start/stop/restart/deploy) + read
- `read:sensitive` — read + لاگ، env var، بکاپ، connection string
**نگاشت scope → دسته‌بندی ابزار:**
| Scope | ابزارها |
|-------|---------|
| `read` | list_*, get_* (بدون sensitive) |
| `read:sensitive` | read + *_logs, *_envs, *_backups |
| `deploy` | read + start_*, stop_*, restart_*, deploy |
| `write` | deploy + create_*, update_*, delete_*_env |
| `admin` | write + delete_* (منابع)، create_server |
### بخش اول: Core — ساختار داده و مدیریت دسترسی (بدون UI)
#### مرحله ۱: دیتابیس
- [ ] جدول `user_tool_toggles` در `core/database.py`
```sql
CREATE TABLE user_tool_toggles (
id TEXT PRIMARY KEY, user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
tool_name TEXT NOT NULL, enabled INTEGER NOT NULL DEFAULT 1,
reason TEXT, updated_at TEXT NOT NULL, UNIQUE(user_id, tool_name)
);
```
- [ ] جدول `scope_presets` — پریست‌های scope سیستمی + سفارشی
- [ ] Migration اجرا شود
#### مرحله ۲: ماژول tool_access.py
- [ ] `core/tool_access.py` — کلاس `ToolAccessManager`
- [ ] `get_visible_tools(user_id, scopes, plugin_type)` → لیست فیلتر شده
- [ ] `apply_scope_filter(tools, scopes)` → فقط ابزارهای مجاز بر اساس scope
- [ ] `apply_user_toggles(tools, user_id)` → اعمال toggleهای کاربر
- [ ] `toggle_tool(user_id, tool_name, enabled)` → ذخیره تنظیم
- [ ] `bulk_toggle_by_scope(user_id, scope_name)` → فعال/غیرفعال دسته‌جمعی
#### مرحله ۳: Tool metadata enhancement
- [ ] اضافه کردن `sensitivity` و `category` به tool specs در handlerها:
- `sensitivity`: "normal" | "sensitive" (لاگ، env، بکاپ)
- `category`: "read" | "lifecycle" | "crud" | "env" | "backup" | "system"
- [ ] شروع از Coolify (آخرین و تمیزترین) سپس سایر پلاگین‌ها
- [ ] ToolDefinition در tool_registry.py آپدیت شود
#### مرحله ۴: فیلتر در user_endpoints.py
- [ ] `_get_tools_for_plugin()` از ToolAccessManager استفاده کند
- [ ] Pipeline فیلتر:
1. plugin_visibility (موجود)
2. scope-to-tool mapping (جدید)
3. user toggles (جدید)
- [ ] Scope enforcement در middleware آپدیت شود (server.py)
#### مرحله ۵: تست
- [ ] `tests/test_tool_access.py` — unit tests
- [ ] تست‌های scope mapping: key با scope "read" → فقط ابزارهای read
- [ ] تست‌های toggle: کاربر disable کرده → ابزار در tools/list نیست
- [ ] تست‌های integration: API key scope → فیلتر واقعی
### بخش دوم: API — روت‌های مدیریت toggle
- [ ] `GET /api/user/tools` — لیست ابزارها با وضعیت toggle
- [ ] `PATCH /api/user/tools/{tool_name}` — تغییر toggle
- [ ] `POST /api/user/tools/bulk-toggle` — toggle دسته‌جمعی بر اساس scope
- [ ] `GET /api/user/scope-presets` — لیست presetها
- [ ] تست: روت‌ها کار کنند
### بخش سوم: Prerequisites (وردپرس/ووکامرس)
- [ ] `check_prerequisites(tools, site_config)` در tool_access.py
- [ ] تشخیص SEO Bridge: `wp-json/airano-mcp-seo-bridge/v1/status`
- [ ] تشخیص WP-CLI: بررسی `container` field در credentials
- [ ] تشخیص WooCommerce: `wp-json/wc/v3/system_status`
- [ ] ابزارهای وابسته علامت‌گذاری شوند (نه حذف — فقط annotation)
### بخش چهارم: UI — صفحه مدیریت ابزار
- [ ] `core/templates/dashboard/tool-preferences.html`
- [ ] لیست ابزارها گروه‌بندی شده بر اساس category
- [ ] Toggle switch برای هر ابزار
- [ ] Badge برای prerequisite (نصب نشده / نیاز به Docker)
- [ ] Dropdown برای اعمال scope preset
- [ ] در صفحه Connect: پیش‌نمایش ابزارها هنگام ساخت API key
### بخش پنجم: ثبت و تست نهایی
- [ ] `uvx --python 3.12 black .`
- [ ] `uvx ruff check --fix .`
- [ ] `pytest` — همه تست‌ها سبز
- [ ] Commit: `feat(F.7): add smart tool visibility and scope-based access control`
- [ ] Push و درخواست redeploy
- [ ] تست live: ساخت API key با scope "read" → بررسی tools/list
- [ ] Sync به نسخه عمومی
- [ ] آپدیت ورژن به v3.9.0 (اگر تأیید شد)
- [ ] حافظه آپدیت شود
- [ ] پلن آپدیت شود (F.7 complete)
## نکات مهم
- فیلتر scope باید backward-compatible باشد — keyهای موجود بدون تغییر کار کنند
- Default: همه ابزارها فعال — فقط explicit disable ذخیره شود
- `user_tool_toggles` فقط overrideها رو ذخیره می‌کنه، نه همه ابزارها
- Prerequisite check باید non-blocking باشه — ابزار حذف نشه، فقط annotate بشه
- server.py نیازی به تغییر زیاد ندارد — فقط middleware scope check آپدیت شود
- ایمیل git داخلی: mcphub.dev@gmail.com
- بخش اول و دوم اولویت اصلی هستند — بخش سوم و چهارم اگر وقت شد

View File

@@ -0,0 +1,98 @@
از مهارت project-ops برای آشنایی با محیط استفاده کن. حافظه و فایل‌های مرجع را بررسی کن.
## فایل‌های مرجع
- docs/plans/2026-04-04-f7b-site-scoped-tool-access.md ← پلن کامل F.7b
- core/tool_access.py ← ToolAccessManager (سایت‌محور — session 1)
- core/dashboard/routes.py ← روت‌های API site tools (بخش F.7b)
- core/templates/dashboard/sites/edit.html ← صفحه edit سایت (بدون بخش tools)
- core/templates/dashboard/connect.html ← صفحه connect فعلی (config snippets + keys)
- core/templates/dashboard/api-keys/list.html ← صفحه admin کلیدها (UI بهتر)
- core/dashboard/routes.py::dashboard_connect_page / dashboard_api_keys_list
- CLAUDE.md (mcphub-internal)
## وضعیت فعلی
- MCPHub: v3.8.0 + F.7b session 1 (commit روی Phase-1)
- Tests: 813 passed، CI سبز
- Backend F.7b کامل است: per-site tool_scope + site_tool_toggles + 4 روت جدید تحت `/api/sites/{site_id}/...` + `/api/scope-presets`
- فقط UI باقی مانده — هدف این session
## ریپازیتوری
- MCPHub (internal): `/config/workspace/mcphub-internal` (branch Phase-1)
## هدف session: F.7b — UI + page merge
### ۱. بخش "Tool Access" در صفحه edit سایت
فایل: `core/templates/dashboard/sites/edit.html`
- [ ] اضافه کردن یک کارت جدید "Tool Access" بعد از فرم credentials
- [ ] Dropdown برای `tool_scope` (values: read / read:sensitive / deploy / write / admin / custom)
- PATCH روی `/api/sites/{site_id}/tool-scope` با body `{scope: "..."}`
- توضیح کوتاه کنار هر گزینه: "Read (X tools)" — شمارش زنده از `/api/sites/{site_id}/tools`
- [ ] Collapsible "Advanced — per-tool overrides":
- گرید/لیست گروه‌بندی شده بر اساس `category` (read / read_sensitive / lifecycle / crud / env / backup / system)
- Toggle switch برای هر ابزار → PATCH `/api/sites/{site_id}/tools/{tool_name}` با `{enabled: bool}`
- Badge قرمز برای `sensitivity=sensitive`
- نام کوتاه از `name`، tooltip با `description`
- [ ] استفاده از HTMX (در پروژه موجود است) برای updates بدون full reload
- [ ] CSRF token از cookie `dashboard_csrf` به header `X-CSRF-Token`
### ۲. انتقال config snippets از connect به صفحه سایت
فایل: `core/templates/dashboard/sites/view.html` (یا ایجاد اگر وجود ندارد)
- [ ] هر سایت در `/dashboard/sites/{id}` نمایش دهد:
- URL MCP مخصوص آن سایت: `{PUBLIC_URL}/u/{user_id}/{alias}/mcp`
- Tabs یا accordion با snippets برای Claude Desktop / Cursor / Zed / کلاینت‌های دیگر
- استفاده از `core/config_snippets.py::get_supported_clients` (موجود)
- [ ] از صفحه `/dashboard/sites` (list) دکمه "Connect" به این صفحه لینک بزند
### ۳. ادغام `/dashboard/connect` و `/dashboard/api-keys` → `/dashboard/keys` (گزینه A)
UI مبنا: `core/templates/dashboard/api-keys/list.html` (قشنگ‌تر و کامل‌تر است طبق تأیید کاربر)
- [ ] ساخت handler `dashboard_keys_unified(request)` که بر اساس session type branch می‌زند:
- OAuth user → نمایش `user_api_keys` برای آن کاربر
- Admin/master → نمایش کامل `api_keys` (همان view فعلی)
- [ ] template جدید `core/templates/dashboard/keys/list.html` با ادغام design از `api-keys/list.html`
- User view: ساده‌تر، scope selector در create dialog، لیست کلیدهای خود کاربر
- Admin view: فیلترهای کامل (project, status, search, pagination) — بدون تغییر
- [ ] **Scope selector در create-key dialog** — این بخش حیاتی است:
- Radio/select: read / read:sensitive / deploy / write / admin
- Helper text: "Per-site tool filters are set in Site Settings"
- POST به `/api/keys` (همان endpoint فعلی) با `scopes: "<selected>"`
- [ ] Redirect های قدیمی:
- `/dashboard/connect``/dashboard/keys` (301)
- `/dashboard/api-keys``/dashboard/keys` (301)
- [ ] حذف handler های قدیمی `dashboard_connect_page` و `dashboard_api_keys_list` و یا تبدیل به thin wrapper redirect
- [ ] منوی navigation sidebar را update کن — فقط یک entry "API Keys"
### ۴. گزینه B (ادغام عمیق DB) — deferred
در پلن session 1 ذکر شده اما اجرا نمی‌کنیم مگر کاربر صراحتاً درخواست کند. کامنت در code اضافه کنید به `api_create_key` که "dual-table model is intentional — see F.7b plan".
### ۵. تست
- [ ] `tests/test_dashboard_keys_unified.py` — تست منوی unified، scope selector، 301 redirect از URL های قدیمی
- [ ] `tests/test_sites_tool_access_ui.py` — smoke test که edit page با tool_scope=read درست render شود (می‌توان با TestClient چک کرد که template بدون 500 می‌آید)
- [ ] به‌روزرسانی `tests/test_dashboard.py::test_dashboard_connect_page` → به `/dashboard/keys` منتقل شود یا به پذیرش redirect
- [ ] pytest کامل سبز
### ۶. ورژن، sync، commit
- [ ] `uvx --python 3.12 black . && uvx --python 3.12 ruff check --fix .`
- [ ] bump version به `v3.9.0` در pyproject.toml + `__version__` در server.py (اگر وجود دارد)
- [ ] Commit: `feat(F.7b): tool access UI + unified keys page (v3.9.0)`
- [ ] Push به Phase-1
- [ ] `python3.11 scripts/community-build/sync.py --output ../mcphub/` سپس در repo عمومی `black` + `ruff`
- [ ] Commit عمومی با ایمیل `hi.airano@gmail.com` و push
- [ ] درخواست deploy از کاربر
### ۷. تست live پس از deploy
- [ ] ورود به `/dashboard/sites/{id}/edit` → بخش Tool Access → تغییر scope به `read` → save
- [ ] بدون ساخت کلید جدید، MCP client (همان کلید admin موجود) روی آن alias → `tools/list` باید فقط ابزارهای read را نشان دهد
- [ ] تغییر به `custom` → Advanced → disable یک ابزار خاص (مثلاً `coolify_delete_server`) → تست
- [ ] ساخت کلید جدید از صفحه unified با scope=`read` → بررسی در لیست
## نکات مهم
- **Backward compatibility:** سایت‌های موجود `tool_scope='admin'` دارند (default migration v7) → هیچ تغییر رفتاری روی سایت‌های قدیمی
- **فیلترها:** key scope و site scope **intersect** می‌شوند. admin key + site=read → فقط read. write key + site=deploy → فقط read + lifecycle.
- **CSRF:** middleware روی `/api/sites/*` فعال است. UI باید header `X-CSRF-Token` از cookie `dashboard_csrf` بفرستد. HTMX این را با `hx-headers` هندل می‌کند.
- **CSS:** پروژه Tailwind دارد. از همان کلاس‌های موجود در `api-keys/list.html` استفاده کن برای consistency.
- **i18n:** پروژه EN/FA است. متن‌های جدید را به `core/i18n.py` اضافه کن.
- **ایمیل git داخلی:** mcphub.dev@gmail.com | ایمیل عمومی: hi.airano@gmail.com
- **نباید:** توابع F.7 v1 (با `user_` prefix) را بازگردانی کنی. همه سایت‌محور است.

View File

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

View File

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

View File

@@ -11,6 +11,7 @@ def get_tool_specifications() -> list[dict[str, Any]]:
return [
{
"name": "list_applications",
"category": "read",
"method_name": "list_applications",
"description": "List all Coolify applications. Optionally filter by tag name.",
"schema": {
@@ -26,6 +27,7 @@ def get_tool_specifications() -> list[dict[str, Any]]:
},
{
"name": "get_application",
"category": "read",
"method_name": "get_application",
"description": "Get details of a specific Coolify application by UUID.",
"schema": {
@@ -43,6 +45,7 @@ def get_tool_specifications() -> list[dict[str, Any]]:
},
{
"name": "create_application_public",
"category": "crud",
"method_name": "create_application_public",
"description": (
"Create a Coolify application from a public Git repository. "
@@ -113,6 +116,7 @@ def get_tool_specifications() -> list[dict[str, Any]]:
},
{
"name": "create_application_dockerfile",
"category": "crud",
"method_name": "create_application_dockerfile",
"description": (
"Create a Coolify application from a Dockerfile (without git). "
@@ -170,6 +174,7 @@ def get_tool_specifications() -> list[dict[str, Any]]:
},
{
"name": "create_application_docker_image",
"category": "crud",
"method_name": "create_application_docker_image",
"description": (
"Create a Coolify application from a Docker image. "
@@ -233,6 +238,7 @@ def get_tool_specifications() -> list[dict[str, Any]]:
},
{
"name": "create_application_compose",
"category": "crud",
"method_name": "create_application_compose",
"description": (
"Create a Coolify application from Docker Compose. "
@@ -278,6 +284,7 @@ def get_tool_specifications() -> list[dict[str, Any]]:
},
{
"name": "update_application",
"category": "crud",
"method_name": "update_application",
"description": (
"Update a Coolify application settings. Supports name, description, "
@@ -357,6 +364,7 @@ def get_tool_specifications() -> list[dict[str, Any]]:
},
{
"name": "delete_application",
"category": "system",
"method_name": "delete_application",
"description": (
"Delete a Coolify application permanently. "
@@ -397,6 +405,7 @@ def get_tool_specifications() -> list[dict[str, Any]]:
},
{
"name": "start_application",
"category": "lifecycle",
"method_name": "start_application",
"description": "Deploy/start a Coolify application. Triggers a new deployment.",
"schema": {
@@ -424,6 +433,7 @@ def get_tool_specifications() -> list[dict[str, Any]]:
},
{
"name": "stop_application",
"category": "lifecycle",
"method_name": "stop_application",
"description": "Stop a running Coolify application.",
"schema": {
@@ -446,6 +456,7 @@ def get_tool_specifications() -> list[dict[str, Any]]:
},
{
"name": "restart_application",
"category": "lifecycle",
"method_name": "restart_application",
"description": "Restart a Coolify application.",
"schema": {
@@ -463,6 +474,8 @@ def get_tool_specifications() -> list[dict[str, Any]]:
},
{
"name": "get_application_logs",
"category": "read_sensitive",
"sensitivity": "sensitive",
"method_name": "get_application_logs",
"description": "Get logs for a Coolify application.",
"schema": {
@@ -487,6 +500,8 @@ def get_tool_specifications() -> list[dict[str, Any]]:
},
{
"name": "list_application_envs",
"category": "read_sensitive",
"sensitivity": "sensitive",
"method_name": "list_application_envs",
"description": "List environment variables for a Coolify application.",
"schema": {
@@ -504,6 +519,8 @@ def get_tool_specifications() -> list[dict[str, Any]]:
},
{
"name": "create_application_env",
"category": "env",
"sensitivity": "sensitive",
"method_name": "create_application_env",
"description": "Create an environment variable for a Coolify application.",
"schema": {
@@ -550,6 +567,8 @@ def get_tool_specifications() -> list[dict[str, Any]]:
},
{
"name": "update_application_env",
"category": "env",
"sensitivity": "sensitive",
"method_name": "update_application_env",
"description": "Update an environment variable for a Coolify application.",
"schema": {
@@ -592,6 +611,8 @@ def get_tool_specifications() -> list[dict[str, Any]]:
},
{
"name": "update_application_envs_bulk",
"category": "env",
"sensitivity": "sensitive",
"method_name": "update_application_envs_bulk",
"description": "Bulk update environment variables for a Coolify application.",
"schema": {
@@ -625,6 +646,8 @@ def get_tool_specifications() -> list[dict[str, Any]]:
},
{
"name": "delete_application_env",
"category": "env",
"sensitivity": "sensitive",
"method_name": "delete_application_env",
"description": "Delete an environment variable from a Coolify application.",
"schema": {

View File

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

View File

@@ -11,6 +11,7 @@ def get_tool_specifications() -> list[dict[str, Any]]:
return [
{
"name": "list_deployments",
"category": "read",
"method_name": "list_deployments",
"description": "List all running deployments on the Coolify instance.",
"schema": {
@@ -21,6 +22,7 @@ def get_tool_specifications() -> list[dict[str, Any]]:
},
{
"name": "get_deployment",
"category": "read",
"method_name": "get_deployment",
"description": "Get details of a specific deployment by UUID.",
"schema": {
@@ -38,6 +40,7 @@ def get_tool_specifications() -> list[dict[str, Any]]:
},
{
"name": "cancel_deployment",
"category": "lifecycle",
"method_name": "cancel_deployment",
"description": "Cancel a running deployment.",
"schema": {
@@ -55,6 +58,7 @@ def get_tool_specifications() -> list[dict[str, Any]]:
},
{
"name": "deploy",
"category": "lifecycle",
"method_name": "deploy",
"description": (
"Trigger deployment by tag name or resource UUID. "
@@ -82,6 +86,7 @@ def get_tool_specifications() -> list[dict[str, Any]]:
},
{
"name": "list_app_deployments",
"category": "read",
"method_name": "list_app_deployments",
"description": "List deployment history for a specific application.",
"schema": {

View File

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

View File

@@ -11,6 +11,7 @@ def get_tool_specifications() -> list[dict[str, Any]]:
return [
{
"name": "list_servers",
"category": "read",
"method_name": "list_servers",
"description": "List all servers registered in the Coolify instance.",
"schema": {
@@ -21,6 +22,7 @@ def get_tool_specifications() -> list[dict[str, Any]]:
},
{
"name": "get_server",
"category": "read",
"method_name": "get_server",
"description": "Get details of a specific server by UUID, including settings.",
"schema": {
@@ -38,6 +40,7 @@ def get_tool_specifications() -> list[dict[str, Any]]:
},
{
"name": "create_server",
"category": "system",
"method_name": "create_server",
"description": (
"Register a new server in Coolify. "
@@ -98,6 +101,7 @@ def get_tool_specifications() -> list[dict[str, Any]]:
},
{
"name": "update_server",
"category": "crud",
"method_name": "update_server",
"description": "Update server configuration.",
"schema": {
@@ -142,6 +146,7 @@ def get_tool_specifications() -> list[dict[str, Any]]:
},
{
"name": "delete_server",
"category": "system",
"method_name": "delete_server",
"description": "Delete a server from Coolify. This cannot be undone!",
"schema": {
@@ -159,6 +164,7 @@ def get_tool_specifications() -> list[dict[str, Any]]:
},
{
"name": "get_server_resources",
"category": "read",
"method_name": "get_server_resources",
"description": (
"Get all resources (applications, databases, services) "
@@ -179,6 +185,7 @@ def get_tool_specifications() -> list[dict[str, Any]]:
},
{
"name": "get_server_domains",
"category": "read",
"method_name": "get_server_domains",
"description": "Get all domains configured on a specific server.",
"schema": {
@@ -196,6 +203,7 @@ def get_tool_specifications() -> list[dict[str, Any]]:
},
{
"name": "validate_server",
"category": "read",
"method_name": "validate_server",
"description": "Validate server connectivity and configuration.",
"schema": {

View File

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

View File

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

View File

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

9
scripts/build-css.sh Executable file
View File

@@ -0,0 +1,9 @@
#!/usr/bin/env bash
# Build Tailwind CSS from templates (requires bun or npx)
set -e
cd "$(dirname "$0")/.."
bunx tailwindcss@3 \
-i core/templates/static/src/input.css \
-o core/templates/static/tailwind.min.css \
--minify
echo "Built core/templates/static/tailwind.min.css"

11
scripts/dev-css.sh Executable file
View File

@@ -0,0 +1,11 @@
#!/usr/bin/env bash
# Watch templates and rebuild Tailwind CSS on changes (dev mode).
# Stops with Ctrl+C. Requires bun (bunx).
set -e
cd "$(dirname "$0")/.."
echo "Watching core/templates/**/*.html for Tailwind changes..."
bunx tailwindcss@3 \
-i core/templates/static/src/input.css \
-o core/templates/static/tailwind.min.css \
--minify \
--watch

View File

@@ -58,16 +58,22 @@ from core import (
set_api_key_context,
)
from core.dashboard.routes import (
# E.3: Site Management routes
# F.7b: Per-site tool visibility
api_bulk_toggle_site_tools,
api_create_key,
# E.3: Site Management routes
api_create_site,
api_delete_key,
api_delete_site,
api_get_config,
api_list_keys,
api_list_site_tools,
api_list_sites,
api_patch_site_tool,
# K.5: Settings routes
api_save_setting,
api_scope_presets,
api_set_site_tool_scope,
api_test_site,
api_update_site,
# E.2: OAuth Social Login routes
@@ -80,7 +86,6 @@ from core.dashboard.routes import (
dashboard_api_keys_create,
dashboard_api_keys_delete,
# K.3: API Keys routes
dashboard_api_keys_list,
dashboard_api_keys_revoke,
dashboard_api_project_detail,
dashboard_api_projects,
@@ -88,11 +93,11 @@ from core.dashboard.routes import (
# K.4: Audit Logs routes
dashboard_audit_logs_list,
# E.3: Dashboard pages
dashboard_connect_page,
# K.5: Health Monitoring routes
dashboard_health_page,
dashboard_health_projects_partial,
dashboard_home,
# F.7b session 2: Unified keys page
dashboard_keys_unified,
dashboard_login_page,
dashboard_login_submit,
dashboard_logout,
@@ -114,6 +119,7 @@ from core.dashboard.routes import (
dashboard_sites_add,
dashboard_sites_edit,
dashboard_sites_list,
dashboard_sites_view,
# Bug C: User OAuth client routes
dashboard_user_oauth_clients_create,
dashboard_user_oauth_clients_delete,
@@ -4783,6 +4789,7 @@ def create_multi_endpoint_app(transport: str = "streamable-http"):
Route("/dashboard/profile", dashboard_profile_page, methods=["GET"]),
Route("/dashboard/sites/add", dashboard_sites_add, methods=["GET"]),
Route("/dashboard/sites/{id}/edit", dashboard_sites_edit, methods=["GET"]),
Route("/dashboard/sites/{id}", dashboard_sites_view, methods=["GET"]),
Route("/dashboard/sites", dashboard_sites_list, methods=["GET"]),
# Bug C: User OAuth client routes (must be before /dashboard/connect)
Route(
@@ -4790,7 +4797,12 @@ def create_multi_endpoint_app(transport: str = "streamable-http"):
dashboard_user_oauth_clients_list,
methods=["GET"],
),
Route("/dashboard/connect", dashboard_connect_page, methods=["GET"]),
# F.7b session 2: /dashboard/connect → /dashboard/keys (301)
Route(
"/dashboard/connect",
lambda r: RedirectResponse("/dashboard/keys", status_code=301),
methods=["GET"],
),
# F.3: Service pages (must be before /dashboard catch-all)
Route("/dashboard/services", dashboard_services_list, methods=["GET"]),
Route("/dashboard/services/{plugin_type}", dashboard_service_page, methods=["GET"]),
@@ -4812,8 +4824,14 @@ def create_multi_endpoint_app(transport: str = "streamable-http"):
dashboard_api_project_detail,
methods=["GET"],
),
# Dashboard API Keys routes (Phase K.3)
Route("/dashboard/api-keys", dashboard_api_keys_list, methods=["GET"]),
# Dashboard API Keys routes (Phase K.3 + F.7b session 2: unified /dashboard/keys)
Route("/dashboard/keys", dashboard_keys_unified, methods=["GET"]),
# /dashboard/api-keys → /dashboard/keys (301)
Route(
"/dashboard/api-keys",
lambda r: RedirectResponse("/dashboard/keys", status_code=301),
methods=["GET"],
),
Route("/api/dashboard/api-keys/create", dashboard_api_keys_create, methods=["POST"]),
Route(
"/api/dashboard/api-keys/{key_id}/revoke", dashboard_api_keys_revoke, methods=["POST"]
@@ -4862,6 +4880,24 @@ def create_multi_endpoint_app(transport: str = "streamable-http"):
Route("/api/keys/{id}", api_delete_key, methods=["DELETE"]),
# Config snippet API (E.3)
Route("/api/config/{alias}", api_get_config, methods=["GET"]),
# F.7b: Per-site tool visibility management
Route("/api/sites/{site_id}/tools", api_list_site_tools, methods=["GET"]),
Route(
"/api/sites/{site_id}/tools/bulk-toggle",
api_bulk_toggle_site_tools,
methods=["POST"],
),
Route(
"/api/sites/{site_id}/tools/{tool_name}",
api_patch_site_tool,
methods=["PATCH"],
),
Route(
"/api/sites/{site_id}/tool-scope",
api_set_site_tool_scope,
methods=["PATCH"],
),
Route("/api/scope-presets", api_scope_presets, methods=["GET"]),
# OAuth endpoints
Route("/.well-known/oauth-authorization-server", oauth_metadata, methods=["GET"]),
# Path-specific OAuth protected resource metadata (must come before root)

29
tailwind.config.js Normal file
View File

@@ -0,0 +1,29 @@
/** @type {import('tailwindcss').Config} */
module.exports = {
darkMode: 'class',
content: [
'./core/templates/**/*.html',
],
theme: {
extend: {
fontFamily: {
'vazirmatn': ['Vazirmatn', 'system-ui', 'sans-serif'],
},
colors: {
primary: {
50: '#f5f3ff',
100: '#ede9fe',
200: '#ddd6fe',
300: '#c4b5fd',
400: '#a78bfa',
500: '#8b5cf6',
600: '#7c3aed',
700: '#6d28d9',
800: '#5b21b6',
900: '#4c1d95',
}
}
}
},
plugins: [],
}

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,143 @@
"""Regression tests for the global CSRF interceptor (F.7c).
Background: previously, dashboard pages defined a local ``getCsrf()`` JS helper
that read the dashboard CSRF cookie via ``document.cookie``. The cookie is
``HttpOnly``, so JS could never read it and every non-GET fetch sent an empty
``X-CSRF-Token`` header — leading to 403 errors when toggling tools or
changing the access scope.
The fix moved CSRF handling into a single global interceptor in
``head_assets.html`` that reads from a ``<meta name="csrf-token">`` tag rendered
server-side in ``base.html``. This file pins down the contract so the bug
cannot silently regress:
1. Every dashboard page renders a non-empty ``<meta name="csrf-token">`` tag.
2. The global ``htmx:configRequest`` + ``window.fetch`` interceptor is
present in ``head_assets.html``.
3. No dashboard template defines its own ``getCsrf()`` helper or sets an
explicit ``x-csrf-token`` header from JS — those would short-circuit the
global interceptor again.
"""
from __future__ import annotations
from pathlib import Path
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
TEMPLATES_DIR = Path(__file__).resolve().parent.parent / "core" / "templates"
DASHBOARD_DIR = TEMPLATES_DIR / "dashboard"
# ── Static template checks ──────────────────────────────────────
class TestStaticTemplateContract:
"""Lint-style checks that don't require a running app."""
def test_head_assets_has_global_csrf_interceptor(self):
head = (DASHBOARD_DIR / "partials" / "head_assets.html").read_text()
# The HTMX hook
assert "htmx:configRequest" in head, "missing global HTMX CSRF hook"
# The fetch monkey-patch
assert "window.fetch" in head, "missing global fetch CSRF hook"
# Both must read from the meta tag — not the cookie
assert 'meta[name="csrf-token"]' in head, "interceptor must read from meta tag"
# Sanity: must set the X-CSRF-Token header
assert "X-CSRF-Token" in head
def test_base_template_renders_csrf_meta_tag(self):
base = (DASHBOARD_DIR / "base.html").read_text()
assert '<meta name="csrf-token"' in base
assert (
"request.state.csrf_token" in base
), "CSRF meta must be populated from request.state.csrf_token"
def test_no_dashboard_template_defines_local_getcsrf(self):
"""Local getCsrf() helpers reintroduce the httponly cookie bug."""
offenders = []
for path in DASHBOARD_DIR.rglob("*.html"):
text = path.read_text()
if "function getCsrf" in text or "const getCsrf =" in text:
offenders.append(str(path.relative_to(DASHBOARD_DIR)))
assert not offenders, (
f"templates must rely on the global CSRF interceptor — "
f"found local getCsrf() in: {offenders}"
)
def test_no_dashboard_template_sets_explicit_csrf_header(self):
"""Explicit ``x-csrf-token`` headers from JS skip the global interceptor.
Allowed exception: head_assets.html, which IS the interceptor.
"""
offenders = []
for path in DASHBOARD_DIR.rglob("*.html"):
if path.name == "head_assets.html":
continue
text = path.read_text().lower()
if "'x-csrf-token'" in text or '"x-csrf-token"' in text:
offenders.append(str(path.relative_to(DASHBOARD_DIR)))
assert not offenders, (
f"templates must not set X-CSRF-Token explicitly — let the "
f"global interceptor handle it. Offenders: {offenders}"
)
# ── Live render check ──────────────────────────────────────────
@pytest.fixture
async def patched_db(tmp_path, monkeypatch):
database = Database(str(tmp_path / "csrf.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="csrf@example.com",
name="csrfuser",
provider="github",
provider_id="gh-csrf-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)
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()
return TestClient(app, follow_redirects=False)
class TestRenderedCsrfMeta:
def test_keys_page_has_non_empty_csrf_meta(self, user_client):
r = user_client.get("/dashboard/keys")
assert r.status_code == 200
# The meta tag must be present *and* populated with a real token.
assert '<meta name="csrf-token"' in r.text
# An empty content="" would re-introduce the bug.
assert 'content=""' not in r.text.split('<meta name="csrf-token"', 1)[1].split(">", 1)[0]

View File

@@ -374,37 +374,15 @@ class TestDashboardCookieManagement:
def test_dashboard_connect_page(monkeypatch):
"""Test that the /dashboard/connect page renders successfully without 500 errors."""
"""Test that /dashboard/connect redirects to /dashboard/keys (F.7b session 2)."""
from server import create_multi_endpoint_app
from starlette.testclient import TestClient
import core.dashboard.routes
import core.site_api
import core.user_keys
app = create_multi_endpoint_app()
client = TestClient(app)
def mock_req(*args):
return {"user_id": "abc", "type": "user"}, None
monkeypatch.setattr(core.dashboard.routes, "_require_user_session", mock_req)
async def mock_sites(*args):
return [{"alias": "Test", "plugin_type": "dummy"}]
monkeypatch.setattr(core.site_api, "get_user_sites", mock_sites)
class MockKeyMgr:
async def list_keys(self, *a):
return [
{"id": "1", "name": "Key", "key_prefix": "prefix", "scopes": "all", "use_count": 0}
]
monkeypatch.setattr(core.user_keys, "get_user_key_manager", lambda: MockKeyMgr())
client = TestClient(app, follow_redirects=False)
resp = client.get("/dashboard/connect")
assert resp.status_code == 200
assert "Test" in resp.text
assert "Key" in resp.text
# /dashboard/connect now redirects 301 to /dashboard/keys
assert resp.status_code == 301
assert "/dashboard/keys" in resp.headers["location"]

View File

@@ -0,0 +1,81 @@
"""Tests for the unified /dashboard/keys page (F.7b session 2)."""
from __future__ import annotations
import pytest
from starlette.testclient import TestClient
import core.dashboard.routes as routes_module
import core.database as db_module
from core.database import Database
@pytest.fixture
async def patched_db(tmp_path, monkeypatch):
database = Database(str(tmp_path / "keys.db"))
await database.initialize()
monkeypatch.setattr(db_module, "_database", database)
yield database
await database.close()
monkeypatch.setattr(db_module, "_database", None)
@pytest.fixture
async def user_row(patched_db):
return await patched_db.create_user(
email="keys@example.com",
name="keysuser",
provider="github",
provider_id="gh-keys-user",
)
@pytest.fixture
def user_client(monkeypatch, user_row, patched_db):
from server import create_multi_endpoint_app
def fake_user_session(_request):
return {"user_id": user_row["id"], "type": "user"}, None
monkeypatch.setattr(routes_module, "_require_user_session", fake_user_session)
# Also patch auth so dashboard_keys_unified finds the user session
class FakeAuth:
def get_session_from_request(self, _r):
return None # not admin
def get_user_session_from_request(self, _r):
return {"user_id": user_row["id"], "type": "user"}
monkeypatch.setattr(routes_module, "get_dashboard_auth", lambda: FakeAuth())
app = create_multi_endpoint_app()
return TestClient(app, follow_redirects=False)
class TestUnifiedKeysUserView:
def test_get_keys_page_returns_200(self, user_client):
r = user_client.get("/dashboard/keys")
assert r.status_code == 200
assert "API Key" in r.text or "کلید" in r.text
def test_old_connect_redirects_301(self, user_client):
r = user_client.get("/dashboard/connect")
assert r.status_code == 301
assert "/dashboard/keys" in r.headers["location"]
def test_old_api_keys_redirects_301(self, user_client):
r = user_client.get("/dashboard/api-keys")
assert r.status_code == 301
assert "/dashboard/keys" in r.headers["location"]
def test_user_view_has_full_access_badge(self, user_client):
r = user_client.get("/dashboard/keys")
assert r.status_code == 200
# F.7c: No scope selector — shows "Full Access" badge instead
assert "Full Access" in r.text or "دسترسی کامل" in r.text
def test_user_view_shows_create_button(self, user_client):
r = user_client.get("/dashboard/keys")
assert r.status_code == 200
assert "Create" in r.text or "ایجاد" in r.text

View File

@@ -538,6 +538,72 @@ class TestEmptyResults:
# ---------------------------------------------------------------------------
class TestSiteToolToggles:
"""F.7b: per-site tool toggle and tool_scope helpers."""
@pytest.mark.unit
async def test_empty_toggles_by_default(self, db, site_row):
assert await db.get_site_tool_toggles(site_row["id"]) == {}
@pytest.mark.unit
async def test_set_and_get_toggle(self, db, site_row):
await db.set_site_tool_toggle(
site_row["id"], "coolify_list_applications", False, reason="not needed"
)
toggles = await db.get_site_tool_toggles(site_row["id"])
assert toggles == {"coolify_list_applications": False}
@pytest.mark.unit
async def test_toggle_is_upsert(self, db, site_row):
await db.set_site_tool_toggle(site_row["id"], "coolify_get_server", False)
await db.set_site_tool_toggle(site_row["id"], "coolify_get_server", True)
toggles = await db.get_site_tool_toggles(site_row["id"])
assert toggles == {"coolify_get_server": True}
@pytest.mark.unit
async def test_delete_toggle(self, db, site_row):
await db.set_site_tool_toggle(site_row["id"], "coolify_get_server", False)
removed = await db.delete_site_tool_toggle(site_row["id"], "coolify_get_server")
assert removed is True
assert await db.get_site_tool_toggles(site_row["id"]) == {}
@pytest.mark.unit
async def test_bulk_set(self, db, site_row):
n = await db.bulk_set_site_tool_toggles(
site_row["id"],
[("coolify_list_applications", False), ("coolify_start_application", False)],
reason="bulk:deploy",
)
assert n == 2
toggles = await db.get_site_tool_toggles(site_row["id"])
assert toggles == {
"coolify_list_applications": False,
"coolify_start_application": False,
}
@pytest.mark.unit
async def test_toggle_cascades_on_site_delete(self, db, user_row, site_row):
await db.set_site_tool_toggle(site_row["id"], "coolify_get_server", False)
await db.delete_site(site_row["id"], user_row["id"])
rows = await db.fetchall(
"SELECT * FROM site_tool_toggles WHERE site_id = ?", (site_row["id"],)
)
assert rows == []
@pytest.mark.unit
async def test_default_tool_scope_is_admin(self, db, site_row):
assert await db.get_site_tool_scope(site_row["id"]) == "admin"
@pytest.mark.unit
async def test_set_tool_scope(self, db, site_row):
await db.set_site_tool_scope(site_row["id"], "read")
assert await db.get_site_tool_scope(site_row["id"]) == "read"
@pytest.mark.unit
async def test_unknown_site_tool_scope_defaults_admin(self, db):
assert await db.get_site_tool_scope("does-not-exist") == "admin"
class TestModuleHelpers:
"""Test get_database() and initialize_database() helpers."""

View File

@@ -0,0 +1,264 @@
"""Integration tests for the per-site tool visibility API (F.7b).
Exercises the five routes registered in server.py:
* ``GET /api/sites/{site_id}/tools``
* ``PATCH /api/sites/{site_id}/tools/{tool_name}``
* ``POST /api/sites/{site_id}/tools/bulk-toggle``
* ``PATCH /api/sites/{site_id}/tool-scope``
* ``GET /api/scope-presets``
Uses the real ``create_multi_endpoint_app()`` so routes wire to the actual
Coolify tool registry. User session auth is stubbed.
"""
from __future__ import annotations
import pytest
from starlette.testclient import TestClient
import core.dashboard.routes as routes_module
import core.database as db_module
import core.tool_access as tool_access_module
from core.database import Database
@pytest.fixture
async def patched_db(tmp_path, monkeypatch):
database = Database(str(tmp_path / "api.db"))
await database.initialize()
monkeypatch.setattr(db_module, "_database", database)
yield database
await database.close()
monkeypatch.setattr(db_module, "_database", None)
@pytest.fixture
def patched_access(monkeypatch):
monkeypatch.setattr(tool_access_module, "_manager", None)
@pytest.fixture
async def user_row(patched_db):
return await patched_db.create_user(
email="api@example.com",
name="api",
provider="github",
provider_id="gh-api-user",
)
@pytest.fixture
async def coolify_site(patched_db, user_row):
return await patched_db.create_site(
user_id=user_row["id"],
plugin_type="coolify",
alias="prod",
url="https://coolify.example.com",
credentials=b"x",
)
@pytest.fixture
async def other_user_site(patched_db):
"""A site belonging to a different user — for ownership-check tests."""
other = await patched_db.create_user(
email="other@example.com",
name="other",
provider="github",
provider_id="gh-other",
)
return await patched_db.create_site(
user_id=other["id"],
plugin_type="coolify",
alias="theirs",
url="https://other.example.com",
credentials=b"x",
)
@pytest.fixture
def client(monkeypatch, user_row, patched_db, patched_access):
"""Build the Starlette app and patch user session auth.
Also pre-sets a matching CSRF cookie + default X-CSRF-Token header so that
mutating requests to ``/api/sites/*`` bypass the Double-Submit CSRF guard
in ``DashboardCSRFMiddleware``.
"""
from server import create_multi_endpoint_app
def fake_require_user_session(_request):
return {"user_id": user_row["id"], "type": "user"}, None
monkeypatch.setattr(routes_module, "_require_user_session", fake_require_user_session)
app = create_multi_endpoint_app()
tc = TestClient(app)
tc.cookies.set("dashboard_csrf", "test-csrf-token")
tc.headers.update({"x-csrf-token": "test-csrf-token"})
return tc
# ---------------------------------------------------------------------------
# GET /api/sites/{site_id}/tools
# ---------------------------------------------------------------------------
class TestListSiteTools:
def test_returns_plugin_tools_all_enabled(self, client, coolify_site):
resp = client.get(f"/api/sites/{coolify_site['id']}/tools")
assert resp.status_code == 200
body = resp.json()
assert body["site_id"] == coolify_site["id"]
assert body["plugin_type"] == "coolify"
assert body["tool_scope"] == "admin"
tools = body["tools"]
by_name = {t["name"]: t for t in tools}
assert "coolify_list_applications" in by_name
assert "coolify_delete_server" in by_name
assert all(t["enabled"] for t in tools)
def test_carries_category_and_sensitivity(self, client, coolify_site):
tools = client.get(f"/api/sites/{coolify_site['id']}/tools").json()["tools"]
by_name = {t["name"]: t for t in tools}
assert by_name["coolify_list_applications"]["category"] == "read"
assert by_name["coolify_delete_server"]["category"] == "system"
assert by_name["coolify_get_application_logs"]["sensitivity"] == "sensitive"
def test_only_own_sites_visible(self, client, other_user_site):
"""A site owned by a different user must 404."""
resp = client.get(f"/api/sites/{other_user_site['id']}/tools")
assert resp.status_code == 404
# ---------------------------------------------------------------------------
# PATCH /api/sites/{site_id}/tools/{tool_name}
# ---------------------------------------------------------------------------
class TestPatchSiteTool:
def test_disable_reflected_in_list(self, client, coolify_site):
r = client.patch(
f"/api/sites/{coolify_site['id']}/tools/coolify_list_applications",
json={"enabled": False, "reason": "not needed"},
)
assert r.status_code == 200
assert r.json()["enabled"] is False
tools = client.get(f"/api/sites/{coolify_site['id']}/tools").json()["tools"]
by_name = {t["name"]: t["enabled"] for t in tools}
assert by_name["coolify_list_applications"] is False
assert by_name["coolify_start_application"] is True
def test_reenable_round_trip(self, client, coolify_site):
base = f"/api/sites/{coolify_site['id']}/tools/coolify_list_applications"
client.patch(base, json={"enabled": False})
client.patch(base, json={"enabled": True})
tools = client.get(f"/api/sites/{coolify_site['id']}/tools").json()["tools"]
by_name = {t["name"]: t["enabled"] for t in tools}
assert by_name["coolify_list_applications"] is True
def test_unknown_tool_404(self, client, coolify_site):
r = client.patch(
f"/api/sites/{coolify_site['id']}/tools/coolify_nonsense",
json={"enabled": False},
)
assert r.status_code == 404
def test_wrong_plugin_400(self, client, coolify_site):
"""Tool from another plugin should be rejected."""
r = client.patch(
f"/api/sites/{coolify_site['id']}/tools/wordpress_list_posts",
json={"enabled": False},
)
assert r.status_code in (400, 404)
def test_missing_enabled_400(self, client, coolify_site):
r = client.patch(
f"/api/sites/{coolify_site['id']}/tools/coolify_list_applications",
json={},
)
assert r.status_code == 400
# ---------------------------------------------------------------------------
# POST /api/sites/{site_id}/tools/bulk-toggle
# ---------------------------------------------------------------------------
class TestBulkToggle:
def test_bulk_disable_deploy_scope(self, client, coolify_site):
r = client.post(
f"/api/sites/{coolify_site['id']}/tools/bulk-toggle",
json={"scope": "deploy", "enabled": False},
)
assert r.status_code == 200
assert r.json()["affected"] >= 5
tools = client.get(f"/api/sites/{coolify_site['id']}/tools").json()["tools"]
by_name = {t["name"]: t["enabled"] for t in tools}
assert by_name["coolify_list_applications"] is False
assert by_name["coolify_start_application"] is False
assert by_name["coolify_create_application_public"] is True
assert by_name["coolify_delete_server"] is True
def test_unknown_scope_400(self, client, coolify_site):
r = client.post(
f"/api/sites/{coolify_site['id']}/tools/bulk-toggle",
json={"scope": "bogus", "enabled": False},
)
assert r.status_code == 400
def test_bad_body_400(self, client, coolify_site):
r = client.post(
f"/api/sites/{coolify_site['id']}/tools/bulk-toggle",
json={"scope": "read"},
)
assert r.status_code == 400
# ---------------------------------------------------------------------------
# PATCH /api/sites/{site_id}/tool-scope
# ---------------------------------------------------------------------------
class TestSetSiteToolScope:
def test_set_read_scope(self, client, coolify_site):
r = client.patch(
f"/api/sites/{coolify_site['id']}/tool-scope",
json={"scope": "read"},
)
assert r.status_code == 200
assert r.json()["tool_scope"] == "read"
listing = client.get(f"/api/sites/{coolify_site['id']}/tools").json()
assert listing["tool_scope"] == "read"
def test_invalid_scope_400(self, client, coolify_site):
r = client.patch(
f"/api/sites/{coolify_site['id']}/tool-scope",
json={"scope": "superadmin"},
)
assert r.status_code == 400
def test_accepts_all_known_presets(self, client, coolify_site):
for scope in ("read", "read:sensitive", "deploy", "write", "admin", "custom"):
r = client.patch(
f"/api/sites/{coolify_site['id']}/tool-scope",
json={"scope": scope},
)
assert r.status_code == 200, scope
# ---------------------------------------------------------------------------
# GET /api/scope-presets
# ---------------------------------------------------------------------------
class TestScopePresets:
def test_returns_all_scopes(self, client):
body = client.get("/api/scope-presets").json()
assert "presets" in body
presets = body["presets"]
assert set(presets.keys()) == {"read", "read:sensitive", "deploy", "write", "admin"}
assert "read" in presets["read"]
assert "system" in presets["admin"]

View File

@@ -0,0 +1,122 @@
"""Smoke tests for unified site management page (F.7c redesign)."""
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 TestSitesEditRedirect:
def test_edit_page_redirects_to_manage(self, client, coolify_site):
"""F.7c: /sites/{id}/edit now redirects to unified /sites/{id}."""
r = client.get(f"/dashboard/sites/{coolify_site['id']}/edit")
assert r.status_code == 301
assert f"/dashboard/sites/{coolify_site['id']}" in r.headers["location"]
class TestUnifiedSiteManagePage:
def test_manage_page_renders_without_500(self, client, coolify_site):
r = client.get(f"/dashboard/sites/{coolify_site['id']}")
assert r.status_code == 200
def test_manage_page_has_connection_section(self, client, coolify_site):
r = client.get(f"/dashboard/sites/{coolify_site['id']}")
assert r.status_code == 200
assert "connection-section" in r.text
def test_manage_page_has_tool_access_section(self, client, coolify_site):
r = client.get(f"/dashboard/sites/{coolify_site['id']}")
assert r.status_code == 200
assert "tool-access-content" in r.text or "Tool Access" in r.text
def test_manage_page_has_scope_tiers(self, client, coolify_site):
r = client.get(f"/dashboard/sites/{coolify_site['id']}")
assert r.status_code == 200
assert "scope-tiers" in r.text
def test_manage_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_manage_page_has_config_snippets(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_manage_page_has_quick_key_create(self, client, coolify_site):
r = client.get(f"/dashboard/sites/{coolify_site['id']}")
assert r.status_code == 200
assert "quick-key-btn" in r.text
def test_nonexistent_site_redirects(self, client):
r = client.get("/dashboard/sites/nonexistent-id")
assert r.status_code in (302, 303)

398
tests/test_tool_access.py Normal file
View File

@@ -0,0 +1,398 @@
"""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:
"""F.7c: Coolify uses legacy category filter (plugin_type='coolify'),
other plugins use universal required_scope filter."""
def test_read_scope_drops_lifecycle_crud_system(self, access_mgr):
# Coolify tools filtered by category
coolify_tools = [t for t in _SAMPLE_TOOLS if t.plugin_type == "coolify"]
out = {
t.name
for t in access_mgr.apply_scope_filter(coolify_tools, ["read"], plugin_type="coolify")
}
assert "coolify_list_applications" in out
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):
coolify_tools = [t for t in _SAMPLE_TOOLS if t.plugin_type == "coolify"]
out = {
t.name
for t in access_mgr.apply_scope_filter(
coolify_tools, ["read:sensitive"], plugin_type="coolify"
)
}
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):
coolify_tools = [t for t in _SAMPLE_TOOLS if t.plugin_type == "coolify"]
out = {
t.name
for t in access_mgr.apply_scope_filter(coolify_tools, ["deploy"], plugin_type="coolify")
}
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):
coolify_tools = [t for t in _SAMPLE_TOOLS if t.plugin_type == "coolify"]
out = {
t.name
for t in access_mgr.apply_scope_filter(coolify_tools, ["write"], plugin_type="coolify")
}
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"], plugin_type="coolify")
}
assert out == {t.name for t in _SAMPLE_TOOLS}
def test_universal_read_filters_by_required_scope(self, access_mgr):
"""Non-Coolify plugins use universal required_scope filter."""
wp_tools = [t for t in _SAMPLE_TOOLS if t.plugin_type == "wordpress"]
out = {
t.name
for t in access_mgr.apply_scope_filter(wp_tools, ["read"], plugin_type="wordpress")
}
# All wordpress sample tools have required_scope="read"
assert "wordpress_list_posts" in out
# ---------------------------------------------------------------------------
# Per-site toggles
# ---------------------------------------------------------------------------
class TestSiteToggles:
async def test_disable_hides_tool(self, db, coolify_site, access_mgr, fresh_registry):
await access_mgr.toggle_tool(coolify_site["id"], "coolify_list_applications", enabled=False)
tools = await access_mgr.get_visible_tools(
site_id=coolify_site["id"], key_scopes=["admin"], plugin_type="coolify"
)
names = {t.name for t in tools}
assert "coolify_list_applications" not in names
assert "coolify_start_application" in names
async def test_toggles_are_per_site(
self, db, coolify_site, wordpress_site, access_mgr, fresh_registry
):
# Disabling a tool on one site must not affect another site.
await access_mgr.toggle_tool(coolify_site["id"], "coolify_list_applications", enabled=False)
# Second coolify site inherits nothing.
other_site = await db.create_site(
user_id=coolify_site["user_id"],
plugin_type="coolify",
alias="staging",
url="https://staging.example.com",
credentials=b"x",
)
tools = await access_mgr.get_visible_tools(
site_id=other_site["id"], key_scopes=["admin"], plugin_type="coolify"
)
assert "coolify_list_applications" in {t.name for t in tools}
async def test_toggle_independent_of_scope(self, db, coolify_site, access_mgr, fresh_registry):
await access_mgr.toggle_tool(coolify_site["id"], "coolify_list_applications", enabled=False)
tools = await access_mgr.get_visible_tools(
site_id=coolify_site["id"], key_scopes=["read"], plugin_type="coolify"
)
assert "coolify_list_applications" not in {t.name for t in tools}
# ---------------------------------------------------------------------------
# Site-level tool_scope preset
# ---------------------------------------------------------------------------
class TestSiteToolScope:
async def test_default_admin_shows_all(self, db, coolify_site, access_mgr, fresh_registry):
tools = await access_mgr.get_visible_tools(
site_id=coolify_site["id"], key_scopes=["admin"], plugin_type="coolify"
)
assert {t.name for t in tools} == {
t.name for t in _SAMPLE_TOOLS if t.plugin_type == "coolify"
}
async def test_read_preset_restricts_even_admin_key(
self, db, coolify_site, access_mgr, fresh_registry
):
"""Site scope is restrictive — admin key but site=read → only read tools."""
await db.set_site_tool_scope(coolify_site["id"], "read")
tools = await access_mgr.get_visible_tools(
site_id=coolify_site["id"], key_scopes=["admin"], plugin_type="coolify"
)
assert {t.name for t in tools} == {"coolify_list_applications"}
async def test_key_scope_and_site_scope_intersect(
self, db, coolify_site, access_mgr, fresh_registry
):
"""Key=write + site=deploy → intersection = lifecycle + read."""
await db.set_site_tool_scope(coolify_site["id"], "deploy")
tools = await access_mgr.get_visible_tools(
site_id=coolify_site["id"], key_scopes=["write"], plugin_type="coolify"
)
names = {t.name for t in tools}
assert "coolify_list_applications" in names # read
assert "coolify_start_application" in names # lifecycle
assert "coolify_stop_application" in names # lifecycle
assert "coolify_create_application_public" not in names # crud (not in deploy)
assert "coolify_create_application_env" not in names # env (not in deploy)
async def test_custom_preset_skips_site_filter(
self, db, coolify_site, access_mgr, fresh_registry
):
"""tool_scope='custom' means per-tool toggles only — no category gate."""
await db.set_site_tool_scope(coolify_site["id"], "custom")
tools = await access_mgr.get_visible_tools(
site_id=coolify_site["id"], key_scopes=["admin"], plugin_type="coolify"
)
# All coolify tools visible because no site-scope filter applied.
assert "coolify_delete_server" in {t.name for t in tools}
# ---------------------------------------------------------------------------
# Bulk toggle (scoped to a plugin)
# ---------------------------------------------------------------------------
class TestBulkToggle:
async def test_bulk_disable_by_scope_affects_only_plugin(
self, db, coolify_site, access_mgr, fresh_registry
):
n = await access_mgr.bulk_toggle_by_scope(
coolify_site["id"], "deploy", enabled=False, plugin_type="coolify"
)
# deploy → read + lifecycle. In sample: list + 2 lifecycle = 3
assert n == 3
tools = await access_mgr.get_visible_tools(
site_id=coolify_site["id"], key_scopes=["admin"], plugin_type="coolify"
)
names = {t.name for t in tools}
assert "coolify_start_application" not in names
assert "coolify_stop_application" not in names
assert "coolify_list_applications" not in names
assert "coolify_create_application_public" in names
assert "coolify_delete_server" in names
async def test_unknown_scope_raises(self, db, coolify_site, access_mgr, fresh_registry):
with pytest.raises(ValueError):
await access_mgr.bulk_toggle_by_scope(
coolify_site["id"], "does_not_exist", enabled=False
)
# ---------------------------------------------------------------------------
# list_tools_for_site end-to-end
# ---------------------------------------------------------------------------
class TestListToolsForSite:
async def test_returns_plugin_tools_with_enabled_flag(
self, db, coolify_site, access_mgr, fresh_registry
):
tools = await access_mgr.list_tools_for_site(coolify_site["id"], "coolify")
by_name = {t["name"]: t for t in tools}
assert "coolify_list_applications" in by_name
assert by_name["coolify_list_applications"]["enabled"] is True
assert by_name["coolify_delete_server"]["category"] == "system"
async def test_respects_toggles(self, db, coolify_site, access_mgr, fresh_registry):
await access_mgr.toggle_tool(coolify_site["id"], "coolify_delete_server", enabled=False)
tools = await access_mgr.list_tools_for_site(coolify_site["id"], "coolify")
by_name = {t["name"]: t for t in tools}
assert by_name["coolify_delete_server"]["enabled"] is False

View File

@@ -85,12 +85,12 @@ def _clear_rate_limits():
@pytest.fixture(autouse=True)
def _clear_tool_cache():
"""Clear the tool schema cache between tests."""
import core.user_endpoints as mod
"""No-op: the per-plugin tool schema cache was removed in F.7.
mod._tool_schema_cache.clear()
Retained so the rest of the test fixtures stay unchanged; the scope-filter
pipeline runs on every ``tools/list`` call so there is nothing to clear.
"""
yield
mod._tool_schema_cache.clear()
@pytest.fixture
@@ -112,6 +112,8 @@ def mock_key_mgr():
def mock_db():
"""Patch get_database to return a mock."""
db = AsyncMock()
db.get_site_tool_scope = AsyncMock(return_value="admin")
db.get_site_tool_toggles = AsyncMock(return_value={})
db.get_site_by_alias = AsyncMock(
return_value={
"id": "site-uuid-001",
@@ -148,6 +150,10 @@ def mock_tool_registry():
tool_def = MagicMock()
tool_def.name = "wordpress_list_posts"
tool_def.description = "List WordPress posts"
tool_def.plugin_type = "wordpress"
tool_def.required_scope = "read"
tool_def.category = "read"
tool_def.sensitivity = "normal"
tool_def.input_schema = {
"type": "object",
"properties": {
@@ -221,6 +227,62 @@ class TestAuthentication:
body = json.loads(response.body)
assert "does not match" in body["error"]["message"]
@pytest.mark.unit
async def test_site_scoped_key_wrong_site(self, mock_key_mgr, mock_db):
"""Site-scoped key (site_id=A) used for site B should return 403."""
# Key is scoped to site-A
mock_key_mgr.validate_key.return_value = {
"key_id": "key-uuid-001",
"user_id": "user-uuid-001",
"scopes": "read write",
"site_id": "site-uuid-A",
}
# The site looked up by site_id (A) has alias "blog-a", but the request
# is for alias "myblog" (which is site-B in get_site_by_alias).
mock_db.get_site = AsyncMock(
return_value={
"id": "site-uuid-A",
"alias": "blog-a",
"user_id": "user-uuid-001",
"plugin_type": "wordpress",
"url": "https://blog-a.example.com",
"credentials": b"x",
"status": "active",
}
)
request = _make_request(alias="myblog")
response = await user_mcp_handler(request)
assert response.status_code == 403
body = json.loads(response.body)
assert "scoped to a different site" in body["error"]["message"]
@pytest.mark.unit
async def test_site_scoped_key_matching_site(self, mock_key_mgr, mock_db, mock_tool_registry):
"""Site-scoped key used for the matching alias should pass auth."""
mock_key_mgr.validate_key.return_value = {
"key_id": "key-uuid-001",
"user_id": "user-uuid-001",
"scopes": "read write",
"site_id": "site-uuid-001",
}
mock_db.get_site = AsyncMock(
return_value={
"id": "site-uuid-001",
"alias": "myblog",
"user_id": "user-uuid-001",
"plugin_type": "wordpress",
"url": "https://myblog.example.com",
"credentials": b"x",
"status": "active",
}
)
request = _make_request(alias="myblog", method_name="tools/list")
response = await user_mcp_handler(request)
assert response.status_code == 200
body = json.loads(response.body)
assert "result" in body
assert "tools" in body["result"]
# ── Site Lookup Tests ────────────────────────────────────────