feat: v3.7.0 — Coolify MCP plugin (30 tools)

New plugin: Coolify deployment management (applications, deployments, servers).
10 plugins, 596 tools, 686 tests passing.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-04-02 18:55:01 +02:00
parent bf893a743a
commit 98f9660712
20 changed files with 2526 additions and 9 deletions

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

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: 481 passing](https://img.shields.io/badge/tests-481%20passing-brightgreen.svg)]()
[![Tools: 565](https://img.shields.io/badge/tools-565-orange.svg)]()
[![Tests: 686 passing](https://img.shields.io/badge/tests-686%20passing-brightgreen.svg)]()
[![Tools: 596](https://img.shields.io/badge/tools-596-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 7 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 8 other self-hosted services through any AI assistant. Instead of clicking through dashboards, just tell your AI what to do:
> *"Update the SEO meta description for all WooCommerce products that don't have one"*
>
@@ -62,8 +62,9 @@ MCP Hub is the first MCP server that lets you manage WordPress, WooCommerce, and
| **OpenPanel** | 42 | Events, export, insights, profiles, projects, system |
| **Appwrite** | 100 | Databases, auth, storage, functions, teams, messaging |
| **Directus** | 100 | Collections, items, users, files, flows, permissions |
| **Coolify** | 30 | Applications, deployments, servers, env vars, logs |
| **System** | 24 | Health monitoring, API keys, OAuth management, audit |
| **Total** | **565** | Constant count — scales to unlimited sites |
| **Total** | **596** | Constant count — scales to unlimited sites |
---
@@ -164,6 +165,7 @@ 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>
@@ -276,11 +278,12 @@ MCP Hub supports **Open Dynamic Client Registration** (RFC 7591). ChatGPT can au
/openpanel/mcp → OpenPanel tools (42 tools)
/appwrite/mcp → Appwrite tools (100 tools)
/directus/mcp → Directus tools (100 tools)
/coolify/mcp → Coolify tools (30 tools)
/project/{alias}/mcp → Per-project endpoint (auto-injects site)
/u/{user_id}/{alias}/mcp → Per-user endpoint (hosted/OAuth users)
```
**Recommendation**: Use plugin-specific endpoints instead of `/mcp` (565 tools) to minimize token usage.
**Recommendation**: Use plugin-specific endpoints instead of `/mcp` (596 tools) to minimize token usage.
| Endpoint | Use Case | Tools |
|----------|----------|------:|

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

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

View File

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

View File

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

View File

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

View File

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

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

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

View File

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

View File

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

View File

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

View File

@@ -1885,6 +1885,24 @@ def register_project_tools():
except Exception as e:
logger.error(f"Failed to generate Directus tools: {e}", exc_info=True)
# Generate tools for Coolify (Phase F.17 - Deployment Management)
logger.info("Generating Coolify tools from plugin specifications...")
try:
from plugins.coolify.plugin import CoolifyPlugin
coolify_tools = tool_generator.generate_tools(CoolifyPlugin, "coolify")
logger.info(f"Generated {len(coolify_tools)} Coolify tools from ToolGenerator")
# Register Coolify tools in ToolRegistry
for tool_def in coolify_tools:
try:
tool_registry.register(tool_def)
except Exception as e:
logger.error(f"Failed to register Coolify tool {tool_def.name}: {e}")
except Exception as e:
logger.error(f"Failed to generate Coolify tools: {e}", exc_info=True)
logger.info(f"Registered {tool_registry.get_count()} tools in ToolRegistry")
# Register tools with FastMCP

452
tests/test_coolify.py Normal file
View File

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