Compare commits
8 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 151ba8e6e6 | |||
| bf893a743a | |||
| 2c9d4ed3d2 | |||
| 4f0c2f9bb0 | |||
| d79c2fd94a | |||
| 1615daf1a7 | |||
| c47ec7fd28 | |||
| 3815f5a6d5 |
13
.env.example
13
.env.example
@@ -2,16 +2,19 @@
|
||||
# MCP Hub - Environment Configuration
|
||||
# ==============================================================================
|
||||
#
|
||||
# Version: 3.0.0
|
||||
# Last Updated: 2026-02-17
|
||||
# Version: 3.4.0
|
||||
# Last Updated: 2026-04-02
|
||||
#
|
||||
# This file contains all environment variables needed to run the MCP server.
|
||||
# This file contains environment variables needed to run the MCP server.
|
||||
# Copy this file to .env and fill in your actual values.
|
||||
#
|
||||
# SITES: Sites are managed via the web dashboard (DB-based), not env vars.
|
||||
# The plugin sections below are kept as credential reference only.
|
||||
#
|
||||
# SECURITY NOTE: Never commit .env file to version control!
|
||||
#
|
||||
# Multi-Endpoint Architecture (v3.0.0):
|
||||
# - /mcp → Admin (all 587 tools, Master API Key required)
|
||||
# - /mcp → Admin (all 565 tools, Master API Key required)
|
||||
# - /system/mcp → System (17 tools, Master Key)
|
||||
# - /wordpress/mcp → WordPress Core (65 tools)
|
||||
# - /woocommerce/mcp → WooCommerce (28 tools)
|
||||
@@ -19,7 +22,7 @@
|
||||
# - /gitea/mcp → Gitea (56 tools)
|
||||
# - /n8n/mcp → n8n Automation (56 tools)
|
||||
# - /supabase/mcp → Supabase Self-Hosted (70 tools)
|
||||
# - /openpanel/mcp → OpenPanel Analytics (73 tools)
|
||||
# - /openpanel/mcp → OpenPanel Analytics (42 tools)
|
||||
# - /appwrite/mcp → Appwrite Backend (100 tools)
|
||||
# - /directus/mcp → Directus CMS (100 tools)
|
||||
# - /project/{alias}/mcp → Project-specific (site-locked tools)
|
||||
|
||||
37
CHANGELOG.md
37
CHANGELOG.md
@@ -5,6 +5,43 @@ 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.6.0] — 2026-04-02
|
||||
|
||||
### Gitea Plugin — Public Release (Track F.16)
|
||||
|
||||
Gitea plugin fully reviewed, tested, and published for public use. Two missing tools added, comprehensive test suite created.
|
||||
|
||||
#### Added
|
||||
- **`update_webhook` tool**: Update existing webhook configuration, events, and active status (admin scope)
|
||||
- **`delete_label` tool**: Delete labels from repositories (write scope)
|
||||
- **`tests/test_gitea_plugin.py`**: Comprehensive test suite — 85+ tests covering client init, headers, tool specs, all 5 handler groups, health check, and plugin delegation
|
||||
|
||||
#### Changed
|
||||
- **Gitea plugin now public**: Added `gitea` to `DEFAULT_PUBLIC_PLUGINS` — available to all OAuth users
|
||||
- **Tool count**: 565 → 567 (added update_webhook + delete_label)
|
||||
- **env.example**: Updated default `ENABLED_PLUGINS` to include `gitea`
|
||||
|
||||
---
|
||||
|
||||
## [3.5.0] — 2026-04-02
|
||||
|
||||
### FastMCP Upgrade & Legacy Cleanup (Track F.15)
|
||||
|
||||
Major cleanup release: upgraded to FastMCP 3.x, removed all legacy modules, and simplified the architecture.
|
||||
|
||||
#### Changed
|
||||
- **FastMCP 3.x**: Upgraded from `fastmcp>=2.14.0,<3.0.0` to `fastmcp>=3.0.0,<4.0.0`
|
||||
- **HealthMonitor refactored**: Removed `ProjectManager` dependency — now uses `SiteManager` exclusively for site discovery and health checks
|
||||
- **Endpoint introspection**: Replaced internal `_tool_manager._tools` access with tracked `_tool_counts` dict in `MCPEndpointFactory`
|
||||
|
||||
#### Removed
|
||||
- **`server_multi.py`**: Legacy multi-endpoint server entry point (replaced by unified `server.py` since v3.0)
|
||||
- **`core/project_manager.py`**: Legacy project manager shell — HealthMonitor no longer depends on it
|
||||
- **Legacy exports**: Removed `ProjectManager` and `get_project_manager` from `core/__init__.py`
|
||||
- References from `pyproject.toml` (py-modules, ruff per-file-ignores), community build script, and documentation
|
||||
|
||||
---
|
||||
|
||||
## [3.4.0] — 2026-03-31
|
||||
|
||||
### OpenPanel Plugin — Public Release (Track F.10)
|
||||
|
||||
45
CLAUDE.md
45
CLAUDE.md
@@ -4,7 +4,7 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
|
||||
|
||||
## Project Overview
|
||||
|
||||
**MCP Hub** — a Python MCP (Model Context Protocol) server that manages multiple self-hosted services through a unified plugin architecture. Supports 9 plugin types (WordPress, WooCommerce, WordPress Advanced, Gitea, n8n, Supabase, OpenPanel, Appwrite, Directus) with 565 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
|
||||
|
||||
@@ -69,7 +69,6 @@ All configured in `pyproject.toml`:
|
||||
|
||||
```
|
||||
├── server.py # Primary entry point
|
||||
├── server_multi.py # Alternative multi-endpoint server
|
||||
├── core/ # Layer 1: Core system modules
|
||||
├── plugins/ # Layer 2: Plugin system (9 plugins)
|
||||
├── core/templates/ # Jinja2 templates (dashboard + OAuth)
|
||||
@@ -93,8 +92,6 @@ Layer 3: API & Web UI (server.py + core/dashboard/) — FastMCP server, Starle
|
||||
### Entry Points
|
||||
|
||||
- **`server.py`** (~3500 lines) — Primary entry point. Handles FastMCP server, Starlette routes, middleware, plugins.
|
||||
- **`server_multi.py`** — Alternative multi-endpoint server (legacy, predates unified `server.py` endpoints).
|
||||
|
||||
### Multi-Endpoint Architecture
|
||||
|
||||
```
|
||||
@@ -119,32 +116,23 @@ 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`.
|
||||
|
||||
### Tool Generation
|
||||
|
||||
Tools are dynamically generated at startup:
|
||||
1. `SiteManager` discovers sites from env vars (`{PLUGIN_TYPE}_{SITE_ID}_{CONFIG_KEY}`)
|
||||
2. `ToolGenerator` creates unified tools with a `site` parameter injected
|
||||
3. Tools are registered in `ToolRegistry` and exposed via FastMCP
|
||||
Tools are dynamically generated at startup from plugin specifications:
|
||||
1. `ToolGenerator` creates unified tools with a `site` parameter injected
|
||||
2. Tools are registered in `ToolRegistry` and exposed via FastMCP
|
||||
|
||||
Unified tool pattern: `wordpress_create_post(site="myblog", title="Hello")` — the `site` parameter accepts either a site_id or alias.
|
||||
|
||||
### Site Configuration via Environment Variables
|
||||
### Site Configuration
|
||||
|
||||
Pattern: `{PLUGIN_TYPE}_{SITE_ID}_{CONFIG_KEY}`
|
||||
|
||||
```bash
|
||||
WORDPRESS_SITE1_URL=https://example.com
|
||||
WORDPRESS_SITE1_USERNAME=admin
|
||||
WORDPRESS_SITE1_APP_PASSWORD=xxxx xxxx xxxx xxxx
|
||||
WORDPRESS_SITE1_ALIAS=myblog # optional friendly name
|
||||
WORDPRESS_SITE1_CONTAINER=wp-docker # optional, for WP-CLI
|
||||
```
|
||||
|
||||
Parsed by `core/site_manager.py` into `SiteConfig` (Pydantic model). Sites are auto-discovered on startup.
|
||||
Sites are managed via the web dashboard and stored in SQLite (DB-based).
|
||||
User sites are encrypted with AES-256-GCM and accessed via `core/site_api.py` + `core/database.py`.
|
||||
`SiteManager` (`core/site_manager.py`) provides registration and lookup infrastructure for tool generation.
|
||||
|
||||
### Key Core Modules
|
||||
|
||||
@@ -152,7 +140,7 @@ Parsed by `core/site_manager.py` into `SiteConfig` (Pydantic model). Sites are a
|
||||
|--------|---------|
|
||||
| `core/auth.py` | Master API key validation, request authentication |
|
||||
| `core/api_keys.py` | Per-project API keys with scopes (read/write/admin) |
|
||||
| `core/site_manager.py` | Type-safe site config discovery from env vars |
|
||||
| `core/site_manager.py` | Type-safe site config registration and lookup |
|
||||
| `core/tool_registry.py` | Central tool definitions and lookup |
|
||||
| `core/tool_generator.py` | Dynamic unified tool creation with site injection |
|
||||
| `core/health.py` | Health monitoring, metrics, alerts |
|
||||
@@ -180,10 +168,6 @@ Web UI at the server root, built with Starlette + Jinja2 + HTMX + Tailwind CSS.
|
||||
**Admin pages**: Login, Home, Projects, API Keys, OAuth Clients, Audit Logs, Health, Settings.
|
||||
**User pages**: Login (OAuth), Home, My Sites (list/add/edit/test/delete), Connect (API keys + config snippets + OAuth clients), Profile.
|
||||
|
||||
### Legacy Modules (Deprecated)
|
||||
|
||||
`core/project_manager.py`, `core/site_registry.py`, `core/unified_tools.py` — kept for backward compatibility. New code should use `SiteManager`, `ToolRegistry`, and `ToolGenerator` instead.
|
||||
|
||||
## Commit Style
|
||||
|
||||
```
|
||||
@@ -201,17 +185,16 @@ Types: `feat`, `fix`, `docs`, `style`, `refactor`, `test`, `chore`
|
||||
|
||||
v4 development cycle with 15 phases. See `docs/ROADMAP.md` (Track F) and `.claude/plans/structured-popping-adleman.md` for full plan.
|
||||
|
||||
**Active plugins for public users**: WordPress, WooCommerce, Supabase, OpenPanel (configurable via `ENABLED_PLUGINS` env var)
|
||||
**Active plugins for public users**: WordPress, WooCommerce, Supabase, OpenPanel, Gitea (configurable via `ENABLED_PLUGINS` env var)
|
||||
|
||||
## Gotchas
|
||||
|
||||
- Test files exist in both `tests/` (proper) and root directory (legacy `test_*.py`). Run `pytest tests/` for organized tests only.
|
||||
- `server_multi.py` is the alternative multi-endpoint entry point; `server.py` is the primary
|
||||
- `wordpress-plugin/` contains companion WP plugins (openpanel, airano-mcp-seo-bridge) — these are PHP, not Python
|
||||
- `env.example` has "FUTURE" labels for Supabase/Gitea but both are fully implemented
|
||||
- 4 plugins are tested for public use: WordPress, WooCommerce, Supabase, OpenPanel. Others are admin-only or disabled.
|
||||
- 5 plugins are tested for public use: WordPress, WooCommerce, Supabase, OpenPanel, Gitea. Others are admin-only or disabled.
|
||||
- OAuth Clients (Client ID/Secret) are for MCP endpoint auth, NOT the same as GitHub/Google dashboard login
|
||||
- User sites stored in SQLite (`core/database.py`), admin sites still from env vars
|
||||
- All sites stored in SQLite (`core/database.py`), managed via web dashboard
|
||||
- Dashboard templates live in `core/templates/` (included in pip package as `package_data`)
|
||||
- `ruff` config uses top-level `select` key in pyproject.toml (not `[tool.ruff.lint]` nested format)
|
||||
- The `scripts/` directory has platform-specific setup scripts: `setup.sh` (Linux/Mac), `setup.ps1` (Windows)
|
||||
@@ -225,4 +208,4 @@ v4 development cycle with 15 phases. See `docs/ROADMAP.md` (Track F) and `.claud
|
||||
- Required env vars: `MASTER_API_KEY`, `OAUTH_JWT_SECRET_KEY`, `OAUTH_BASE_URL`
|
||||
- OAuth env vars: `GITHUB_CLIENT_ID`, `GITHUB_CLIENT_SECRET`, `GOOGLE_CLIENT_ID`, `GOOGLE_CLIENT_SECRET`, `PUBLIC_URL`
|
||||
- User limits: `MAX_SITES_PER_USER=10`, `USER_RATE_LIMIT_PER_MIN=30`, `USER_RATE_LIMIT_PER_HR=500`
|
||||
- Plugin visibility: `ENABLED_PLUGINS=wordpress,woocommerce,supabase,openpanel` (default)
|
||||
- Plugin visibility: `ENABLED_PLUGINS=wordpress,woocommerce,supabase,openpanel,gitea` (default)
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
**The AI-native management hub for WordPress, WooCommerce, and self-hosted services.**
|
||||
|
||||
596 tools across 9 plugins. Connect your sites, stores, repos, and databases — manage them all through Claude, ChatGPT, Cursor, or any MCP client.
|
||||
565 tools across 9 plugins. 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.
|
||||
|
||||
@@ -13,14 +13,10 @@
|
||||
```bash
|
||||
# Recommended (auto-generates temp key if omitted — check container logs)
|
||||
MASTER_API_KEY=your-secure-key-here
|
||||
|
||||
# Add at least one WordPress site
|
||||
WORDPRESS_SITE1_URL=https://your-site.com
|
||||
WORDPRESS_SITE1_USERNAME=admin
|
||||
WORDPRESS_SITE1_APP_PASSWORD=xxxx xxxx xxxx xxxx
|
||||
WORDPRESS_SITE1_ALIAS=mysite
|
||||
```
|
||||
|
||||
After starting, open the **web dashboard** to add your sites — no env vars needed for site configuration.
|
||||
|
||||
### 2. Run the container
|
||||
|
||||
```bash
|
||||
@@ -96,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` | 596 | Yes | Admin & discovery only |
|
||||
| `/mcp` | 565 | 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`
|
||||
|
||||
|
||||
104
README.md
104
README.md
@@ -13,7 +13,7 @@ Connect your sites, stores, repos, and databases — manage them all through Cla
|
||||
[](https://www.python.org/)
|
||||
[](https://pypi.org/project/mcphub-server/)
|
||||
[](https://hub.docker.com/r/airano/mcphub)
|
||||
[]()
|
||||
[]()
|
||||
[]()
|
||||
[](https://github.com/airano-ir/mcphub/actions/workflows/ci.yml)
|
||||
|
||||
@@ -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"*
|
||||
>
|
||||
@@ -49,7 +49,7 @@ MCP Hub is the first MCP server that lets you manage WordPress, WooCommerce, and
|
||||
|
||||
---
|
||||
|
||||
## 596 Tools Across 9 Plugins
|
||||
## 565 Tools Across 9 Plugins
|
||||
|
||||
| Plugin | Tools | What You Can Do |
|
||||
|--------|-------|-----------------|
|
||||
@@ -59,9 +59,10 @@ MCP Hub is the first MCP server that lets you manage WordPress, WooCommerce, and
|
||||
| **Gitea** | 56 | Repos, issues, pull requests, releases, webhooks, organizations |
|
||||
| **n8n** | 56 | Workflows, executions, credentials, variables, audit |
|
||||
| **Supabase** | 70 | Database, auth, storage, edge functions, realtime |
|
||||
| **OpenPanel** | 73 | Events, funnels, profiles, dashboards, projects |
|
||||
| **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 |
|
||||
|
||||
@@ -75,14 +76,14 @@ MCP Hub is the first MCP server that lets you manage WordPress, WooCommerce, and
|
||||
git clone https://github.com/airano-ir/mcphub.git
|
||||
cd mcphub
|
||||
cp env.example .env
|
||||
# Edit .env — set MASTER_API_KEY and add your site credentials
|
||||
# Edit .env — set MASTER_API_KEY, then add sites via the web dashboard
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
### Option 2: Docker Hub (No Clone)
|
||||
|
||||
```bash
|
||||
# Create a .env file with your credentials (see "Configure Your Sites" below)
|
||||
# Create a .env file with MASTER_API_KEY (see "Configure Your Sites" below)
|
||||
docker run -d --name mcphub -p 8000:8000 --env-file .env airano/mcphub:latest
|
||||
```
|
||||
|
||||
@@ -93,7 +94,7 @@ git clone https://github.com/airano-ir/mcphub.git
|
||||
cd mcphub
|
||||
pip install -e .
|
||||
cp env.example .env
|
||||
# Edit .env with your site credentials
|
||||
# Edit .env — set MASTER_API_KEY
|
||||
python server.py --transport streamable-http --port 8000
|
||||
```
|
||||
|
||||
@@ -125,28 +126,15 @@ Your personal MCP endpoint: `https://mcp.example.com/u/{your-user-id}/{alias}/mc
|
||||
|
||||
### Configure Your Sites
|
||||
|
||||
Add site credentials to `.env`:
|
||||
Sites are managed via the **web dashboard** — no environment variables needed.
|
||||
|
||||
1. Set `MASTER_API_KEY` in your `.env` file
|
||||
2. Start the server and open the dashboard
|
||||
3. Add sites with their credentials (URL, username, password/token)
|
||||
|
||||
```bash
|
||||
# Master API Key (recommended — auto-generates temp key if omitted)
|
||||
# .env — only system configuration needed
|
||||
MASTER_API_KEY=your-secure-key-here
|
||||
|
||||
# WordPress Site
|
||||
WORDPRESS_SITE1_URL=https://myblog.com
|
||||
WORDPRESS_SITE1_USERNAME=admin
|
||||
WORDPRESS_SITE1_APP_PASSWORD=xxxx xxxx xxxx xxxx
|
||||
WORDPRESS_SITE1_ALIAS=myblog
|
||||
|
||||
# WooCommerce Store
|
||||
WOOCOMMERCE_STORE1_URL=https://mystore.com
|
||||
WOOCOMMERCE_STORE1_CONSUMER_KEY=ck_xxxxx
|
||||
WOOCOMMERCE_STORE1_CONSUMER_SECRET=cs_xxxxx
|
||||
WOOCOMMERCE_STORE1_ALIAS=mystore
|
||||
|
||||
# Gitea Instance
|
||||
GITEA_REPO1_URL=https://git.example.com
|
||||
GITEA_REPO1_TOKEN=your_gitea_token
|
||||
GITEA_REPO1_ALIAS=mygitea
|
||||
```
|
||||
|
||||
<details>
|
||||
@@ -158,45 +146,26 @@ GITEA_REPO1_ALIAS=mygitea
|
||||
|----------|----------|---------|-------------|
|
||||
| `MASTER_API_KEY` | Recommended | Auto-generated | Master API key for admin access |
|
||||
| `LOG_LEVEL` | No | `INFO` | Logging level (DEBUG, INFO, WARNING, ERROR) |
|
||||
| `ENCRYPTION_KEY` | For Live Platform | — | AES-256-GCM key for credential encryption |
|
||||
| `OAUTH_JWT_SECRET_KEY` | For OAuth | — | JWT secret for ChatGPT auto-registration (not needed for Claude/Cursor) |
|
||||
| `OAUTH_BASE_URL` | For OAuth | — | Public URL of your server (not needed for Claude/Cursor) |
|
||||
| `OAUTH_JWT_ALGORITHM` | No | `HS256` | JWT algorithm |
|
||||
| `OAUTH_ACCESS_TOKEN_TTL` | No | `3600` | Access token TTL in seconds |
|
||||
| `OAUTH_REFRESH_TOKEN_TTL` | No | `604800` | Refresh token TTL in seconds |
|
||||
| `OAUTH_STORAGE_TYPE` | No | `json` | Token storage type |
|
||||
| `OAUTH_STORAGE_PATH` | No | `/app/data` | Data directory path |
|
||||
|
||||
> **OAuth** is only needed for ChatGPT Remote MCP auto-registration. For Claude Desktop, Claude Code, Cursor, and VS Code — just use `MASTER_API_KEY` with Bearer token auth.
|
||||
|
||||
**Plugin Site Configuration** — Pattern: `{PLUGIN_TYPE}_{SITE_ID}_{KEY}`
|
||||
**Plugin Credential Reference** — when adding sites via dashboard, you'll need:
|
||||
|
||||
| Plugin | Required Keys | Optional Keys |
|
||||
|--------|--------------|---------------|
|
||||
| `WORDPRESS` | `URL`, `USERNAME`, `APP_PASSWORD` | `ALIAS`, `CONTAINER` |
|
||||
| `WOOCOMMERCE` | `URL`, `CONSUMER_KEY`, `CONSUMER_SECRET` | `ALIAS` |
|
||||
| `WORDPRESS_ADVANCED` | `URL`, `USERNAME`, `APP_PASSWORD`, `CONTAINER` | `ALIAS` |
|
||||
| `GITEA` | `URL`, `TOKEN` | `ALIAS` |
|
||||
| `N8N` | `URL`, `API_KEY` | `ALIAS` |
|
||||
| `SUPABASE` | `URL`, `SERVICE_ROLE_KEY` | `ALIAS` |
|
||||
| `OPENPANEL` | `URL`, `CLIENT_ID`, `CLIENT_SECRET` | `ALIAS` |
|
||||
| `APPWRITE` | `URL`, `API_KEY`, `PROJECT_ID` | `ALIAS` |
|
||||
| `DIRECTUS` | `URL`, `TOKEN` | `ALIAS` |
|
||||
|
||||
> **CONTAINER**: Docker container name of your WordPress site. Optional for WordPress (enables WP-CLI tools like cache flush, transient management). **Required** for WordPress Advanced (all 22 tools use WP-CLI). Find your container: `docker ps --filter name=wordpress`. Also requires Docker socket mount.
|
||||
|
||||
**Example** — Multiple WordPress sites:
|
||||
|
||||
```bash
|
||||
WORDPRESS_BLOG_URL=https://blog.example.com
|
||||
WORDPRESS_BLOG_USERNAME=admin
|
||||
WORDPRESS_BLOG_APP_PASSWORD=xxxx xxxx xxxx xxxx
|
||||
WORDPRESS_BLOG_ALIAS=blog
|
||||
|
||||
WORDPRESS_SHOP_URL=https://shop.example.com
|
||||
WORDPRESS_SHOP_USERNAME=admin
|
||||
WORDPRESS_SHOP_APP_PASSWORD=yyyy yyyy yyyy yyyy
|
||||
WORDPRESS_SHOP_ALIAS=shop
|
||||
```
|
||||
| Plugin | Required Credentials | Notes |
|
||||
|--------|---------------------|-------|
|
||||
| WordPress | URL, Username, App Password | [How to create App Password](https://wordpress.org/documentation/article/application-passwords/) |
|
||||
| WooCommerce | URL, Consumer Key, Consumer Secret | WooCommerce → Settings → Advanced → REST API |
|
||||
| WordPress Advanced | URL, Username, App Password, Container | Container = Docker container name (for WP-CLI) |
|
||||
| Gitea | URL, Token | Settings → Applications → Personal Access Token |
|
||||
| n8n | URL, API Key | Settings → API → Create API Key |
|
||||
| Supabase | URL, Service Role Key | Supabase Dashboard → Settings → API |
|
||||
| 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>
|
||||
|
||||
@@ -298,7 +267,7 @@ MCP Hub supports **Open Dynamic Client Registration** (RFC 7591). ChatGPT can au
|
||||
## Architecture
|
||||
|
||||
```
|
||||
/mcp → Admin endpoint (all 596 tools)
|
||||
/mcp → Admin endpoint (all 565 tools)
|
||||
/system/mcp → System tools only (24 tools)
|
||||
/wordpress/mcp → WordPress tools (67 tools)
|
||||
/woocommerce/mcp → WooCommerce tools (28 tools)
|
||||
@@ -306,9 +275,10 @@ MCP Hub supports **Open Dynamic Client Registration** (RFC 7591). ChatGPT can au
|
||||
/gitea/mcp → Gitea tools (56 tools)
|
||||
/n8n/mcp → n8n tools (56 tools)
|
||||
/supabase/mcp → Supabase tools (70 tools)
|
||||
/openpanel/mcp → OpenPanel tools (73 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)
|
||||
/project/{alias}/mcp → Per-project endpoint (auto-injects site)
|
||||
/u/{user_id}/{alias}/mcp → Per-user endpoint (hosted/OAuth users)
|
||||
```
|
||||
@@ -320,7 +290,7 @@ MCP Hub supports **Open Dynamic Client Registration** (RFC 7591). ChatGPT can au
|
||||
| `/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 | 596 |
|
||||
| `/mcp` | Admin & discovery only | 565 |
|
||||
|
||||
### Security
|
||||
|
||||
@@ -339,21 +309,19 @@ Some MCP Hub tools require companion WordPress plugins:
|
||||
| Tools | Requirement |
|
||||
|-------|-------------|
|
||||
| SEO tools (`wordpress_get_post_seo`, etc.) | [Airano MCP SEO Bridge](https://wordpress.org/plugins/airano-mcp-seo-bridge/) ([GitHub](wordpress-plugin/airano-mcp-seo-bridge/)) + Rank Math or Yoast SEO |
|
||||
| WP-CLI tools (15 tools: `wp_cache_*`, `wp_db_*`, etc.) | Docker socket + `CONTAINER` env var |
|
||||
| WordPress Advanced database/system tools | Docker socket + `CONTAINER` env var |
|
||||
| WP-CLI tools (15 tools: `wp_cache_*`, `wp_db_*`, etc.) | Docker socket + `CONTAINER` config |
|
||||
| WordPress Advanced database/system tools | Docker socket + `CONTAINER` config |
|
||||
| OpenPanel analytics integration | [OpenPanel Self-Hosted](wordpress-plugin/openpanel-self-hosted/) ([Download ZIP](wordpress-plugin/openpanel-self-hosted.zip)) |
|
||||
| WooCommerce tools | WooCommerce plugin (separate `WOOCOMMERCE_` config) |
|
||||
| WooCommerce tools | WooCommerce plugin installed on your WordPress site |
|
||||
|
||||
**Docker socket** is needed for WP-CLI and WordPress Advanced system tools. Add to your docker-compose:
|
||||
|
||||
```yaml
|
||||
volumes:
|
||||
- /var/run/docker.sock:/var/run/docker.sock:ro
|
||||
environment:
|
||||
WORDPRESS_SITE1_CONTAINER: your-wp-container-name
|
||||
```
|
||||
|
||||
Without Docker socket, WP-CLI tools return "not available" but all REST API tools work normally.
|
||||
Set the `container` field when adding a WordPress site in the dashboard. Without Docker socket, WP-CLI tools return "not available" but all REST API tools work normally.
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -37,14 +37,9 @@ from core.health import (
|
||||
initialize_health_monitor,
|
||||
)
|
||||
|
||||
# Project and Site Management
|
||||
from core.project_manager import ProjectManager, get_project_manager
|
||||
|
||||
# Rate Limiting
|
||||
from core.rate_limiter import RateLimitConfig, RateLimiter, get_rate_limiter
|
||||
from core.site_manager import SiteConfig, SiteManager, get_site_manager
|
||||
|
||||
# Legacy (kept for backward compatibility, will be removed in v2.0)
|
||||
from core.tool_generator import ToolGenerator
|
||||
|
||||
# Tool Management (Option B architecture)
|
||||
@@ -56,9 +51,7 @@ __all__ = [
|
||||
"get_auth_manager",
|
||||
"APIKeyManager",
|
||||
"get_api_key_manager",
|
||||
# Project/Site Management
|
||||
"ProjectManager",
|
||||
"get_project_manager",
|
||||
# Site Management
|
||||
"SiteManager",
|
||||
"SiteConfig",
|
||||
"get_site_manager",
|
||||
|
||||
@@ -1512,6 +1512,7 @@ async def get_oauth_clients_data() -> dict:
|
||||
"grant_types": client.grant_types,
|
||||
"allowed_scopes": client.allowed_scopes,
|
||||
"created_at": client.created_at.isoformat() if client.created_at else "",
|
||||
"owner_user_id": getattr(client, "owner_user_id", None),
|
||||
}
|
||||
)
|
||||
|
||||
@@ -1528,10 +1529,26 @@ async def get_oauth_clients_data() -> dict:
|
||||
|
||||
|
||||
async def dashboard_oauth_clients_list(request: Request) -> Response:
|
||||
"""Render OAuth clients list page (admin only)."""
|
||||
session, redirect = _require_admin_session(request)
|
||||
if redirect:
|
||||
return redirect
|
||||
"""Render OAuth clients list page (admin and user)."""
|
||||
# Try admin session first, then user session
|
||||
auth = get_dashboard_auth()
|
||||
session = None
|
||||
is_admin = False
|
||||
user_id = None
|
||||
|
||||
admin_session = auth.get_session_from_request(request)
|
||||
if admin_session and is_admin_session(admin_session):
|
||||
session = admin_session
|
||||
is_admin = True
|
||||
else:
|
||||
user_session = auth.get_user_session_from_request(request)
|
||||
if user_session:
|
||||
session = user_session
|
||||
is_admin = is_admin_session(user_session)
|
||||
user_id = user_session.get("user_id")
|
||||
|
||||
if not session:
|
||||
return RedirectResponse(url="/auth/login", status_code=303)
|
||||
|
||||
# Get language
|
||||
accept_language = request.headers.get("accept-language")
|
||||
@@ -1539,8 +1556,13 @@ async def dashboard_oauth_clients_list(request: Request) -> Response:
|
||||
lang = detect_language(accept_language, query_lang)
|
||||
t = get_translations(lang)
|
||||
|
||||
# Get clients data
|
||||
# Get clients data — admin sees all, user sees own
|
||||
clients_data = await get_oauth_clients_data()
|
||||
if not is_admin and user_id:
|
||||
clients_data["clients"] = [
|
||||
c for c in clients_data["clients"] if c.get("owner_user_id") == user_id
|
||||
]
|
||||
clients_data["total_count"] = len(clients_data["clients"])
|
||||
|
||||
return templates.TemplateResponse(
|
||||
request,
|
||||
@@ -1552,15 +1574,26 @@ async def dashboard_oauth_clients_list(request: Request) -> Response:
|
||||
"clients": clients_data["clients"],
|
||||
"total_count": clients_data["total_count"],
|
||||
"current_page": "oauth_clients",
|
||||
"is_admin": is_admin,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
async def dashboard_oauth_clients_create(request: Request) -> Response:
|
||||
"""API endpoint to create OAuth client (admin only)."""
|
||||
session, redirect = _require_admin_session(request)
|
||||
if redirect:
|
||||
return JSONResponse({"error": "Admin access required"}, status_code=403)
|
||||
"""API endpoint to create OAuth client (admin and user)."""
|
||||
# Accept both admin and user sessions
|
||||
auth = get_dashboard_auth()
|
||||
owner_user_id = None
|
||||
|
||||
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):
|
||||
pass # Admin — no owner_user_id
|
||||
elif user_session:
|
||||
owner_user_id = user_session.get("user_id")
|
||||
else:
|
||||
return JSONResponse({"error": "Authentication required"}, status_code=403)
|
||||
|
||||
try:
|
||||
data = await request.json()
|
||||
@@ -1569,7 +1602,7 @@ async def dashboard_oauth_clients_create(request: Request) -> Response:
|
||||
redirect_uris = data.get("redirect_uris") or []
|
||||
if not redirect_uris and data.get("redirect_uri"):
|
||||
redirect_uris = [data.get("redirect_uri")]
|
||||
scopes = data.get("scopes", ["read"])
|
||||
scopes = data.get("scopes", ["read", "write", "admin"])
|
||||
|
||||
if not client_name or not redirect_uris:
|
||||
return JSONResponse({"error": "Missing required fields"}, status_code=400)
|
||||
@@ -1578,16 +1611,23 @@ async def dashboard_oauth_clients_create(request: Request) -> Response:
|
||||
|
||||
client_registry = get_client_registry()
|
||||
|
||||
client_id, client_secret = client_registry.create_client(
|
||||
client_name=client_name, redirect_uris=redirect_uris, allowed_scopes=scopes
|
||||
)
|
||||
create_kwargs = {
|
||||
"client_name": client_name,
|
||||
"redirect_uris": redirect_uris,
|
||||
"allowed_scopes": scopes,
|
||||
}
|
||||
if owner_user_id:
|
||||
create_kwargs["owner_user_id"] = owner_user_id
|
||||
|
||||
client_id, client_secret = client_registry.create_client(**create_kwargs)
|
||||
|
||||
# Log the action
|
||||
from core.audit_log import get_audit_logger
|
||||
|
||||
audit_logger = get_audit_logger()
|
||||
audit_logger.log_system_event(
|
||||
event=f"OAuth client created: {client_name}", details={"client_id": client_id}
|
||||
event=f"OAuth client created: {client_name}",
|
||||
details={"client_id": client_id, "owner_user_id": owner_user_id},
|
||||
)
|
||||
|
||||
return JSONResponse(
|
||||
@@ -1604,10 +1644,22 @@ async def dashboard_oauth_clients_create(request: Request) -> Response:
|
||||
|
||||
|
||||
async def dashboard_oauth_clients_delete(request: Request) -> Response:
|
||||
"""API endpoint to delete OAuth client (admin only)."""
|
||||
session, redirect = _require_admin_session(request)
|
||||
if redirect:
|
||||
return JSONResponse({"error": "Admin access required"}, status_code=403)
|
||||
"""API endpoint to delete OAuth client (admin and user)."""
|
||||
# Accept both admin and user sessions
|
||||
auth = get_dashboard_auth()
|
||||
is_admin_user = False
|
||||
user_id = None
|
||||
|
||||
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):
|
||||
is_admin_user = True
|
||||
elif user_session:
|
||||
user_id = user_session.get("user_id")
|
||||
is_admin_user = is_admin_session(user_session)
|
||||
else:
|
||||
return JSONResponse({"error": "Authentication required"}, status_code=403)
|
||||
|
||||
try:
|
||||
client_id = request.path_params.get("client_id", "")
|
||||
@@ -1616,6 +1668,14 @@ async def dashboard_oauth_clients_delete(request: Request) -> Response:
|
||||
|
||||
client_registry = get_client_registry()
|
||||
|
||||
# Non-admin users can only delete their own clients
|
||||
if not is_admin_user and user_id:
|
||||
client = client_registry.get_client(client_id)
|
||||
if not client:
|
||||
return JSONResponse({"error": "Client not found"}, status_code=404)
|
||||
if getattr(client, "owner_user_id", None) != user_id:
|
||||
return JSONResponse({"error": "Access denied"}, status_code=403)
|
||||
|
||||
success = client_registry.delete_client(client_id)
|
||||
|
||||
if success:
|
||||
@@ -3131,107 +3191,18 @@ async def api_get_config(request: Request) -> Response:
|
||||
|
||||
|
||||
async def dashboard_user_oauth_clients_list(request: Request) -> Response:
|
||||
"""GET /dashboard/connect/oauth-clients — OAuth user's own OAuth clients."""
|
||||
user_session, redirect = _require_user_session(request)
|
||||
if redirect:
|
||||
return redirect
|
||||
|
||||
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)
|
||||
|
||||
from core.oauth import get_client_registry as _get_client_registry
|
||||
|
||||
registry = _get_client_registry()
|
||||
user_id = user_session["user_id"]
|
||||
user_clients = [c for c in registry.list_clients() if c.owner_user_id == user_id]
|
||||
|
||||
return templates.TemplateResponse(
|
||||
request,
|
||||
"dashboard/user-oauth-clients.html",
|
||||
{
|
||||
"lang": lang,
|
||||
"t": t,
|
||||
"session": user_session,
|
||||
"clients": user_clients,
|
||||
"current_page": "connect",
|
||||
},
|
||||
)
|
||||
"""GET /dashboard/connect/oauth-clients — Redirect to unified OAuth clients page."""
|
||||
return RedirectResponse(url="/dashboard/oauth-clients", status_code=303)
|
||||
|
||||
|
||||
async def dashboard_user_oauth_clients_create(request: Request) -> Response:
|
||||
"""POST /api/dashboard/user-oauth-clients/create — Create OAuth client for OAuth user."""
|
||||
user_session, redirect = _require_user_session(request)
|
||||
if redirect:
|
||||
return JSONResponse({"error": "Unauthorized"}, status_code=401)
|
||||
|
||||
try:
|
||||
body = await request.json()
|
||||
except Exception:
|
||||
return JSONResponse({"error": "Invalid JSON"}, status_code=400)
|
||||
|
||||
client_name = body.get("client_name", "").strip()
|
||||
redirect_uris_raw = body.get("redirect_uris", "")
|
||||
scopes = body.get("scopes", ["read", "write", "admin"])
|
||||
|
||||
if not client_name:
|
||||
return JSONResponse({"error": "Client name required"}, status_code=400)
|
||||
|
||||
if isinstance(redirect_uris_raw, str):
|
||||
redirect_uris = [u.strip() for u in redirect_uris_raw.splitlines() if u.strip()]
|
||||
else:
|
||||
redirect_uris = [u.strip() for u in redirect_uris_raw if u.strip()]
|
||||
|
||||
if not redirect_uris:
|
||||
return JSONResponse({"error": "At least one redirect URI required"}, status_code=400)
|
||||
|
||||
scope_str = " ".join(scopes) if isinstance(scopes, list) else scopes
|
||||
|
||||
from core.oauth import get_client_registry as _get_client_registry
|
||||
|
||||
registry = _get_client_registry()
|
||||
client_id, client_secret = registry.create_client(
|
||||
client_name=client_name,
|
||||
redirect_uris=redirect_uris,
|
||||
allowed_scopes=scope_str.split() if isinstance(scope_str, str) else scopes,
|
||||
owner_user_id=user_session["user_id"],
|
||||
)
|
||||
client = registry.get_client(client_id)
|
||||
|
||||
return JSONResponse(
|
||||
{
|
||||
"client_id": client_id,
|
||||
"client_secret": client_secret,
|
||||
"client_name": client.client_name,
|
||||
"redirect_uris": client.redirect_uris,
|
||||
"scope": client.scope,
|
||||
}
|
||||
)
|
||||
"""POST /api/dashboard/user-oauth-clients/create — Forwards to unified create endpoint."""
|
||||
return await dashboard_oauth_clients_create(request)
|
||||
|
||||
|
||||
async def dashboard_user_oauth_clients_delete(request: Request) -> Response:
|
||||
"""DELETE /api/dashboard/user-oauth-clients/{client_id} — Delete user's own OAuth client."""
|
||||
user_session, redirect = _require_user_session(request)
|
||||
if redirect:
|
||||
return JSONResponse({"error": "Unauthorized"}, status_code=401)
|
||||
|
||||
client_id = request.path_params.get("client_id", "")
|
||||
|
||||
from core.oauth import get_client_registry as _get_client_registry
|
||||
|
||||
registry = _get_client_registry()
|
||||
client = registry.get_client(client_id)
|
||||
|
||||
if not client:
|
||||
return JSONResponse({"error": "Client not found"}, status_code=404)
|
||||
|
||||
# Only allow deleting own clients
|
||||
if client.owner_user_id != user_session["user_id"]:
|
||||
return JSONResponse({"error": "Access denied"}, status_code=403)
|
||||
|
||||
registry.delete_client(client_id)
|
||||
return JSONResponse({"success": True})
|
||||
"""DELETE /api/dashboard/user-oauth-clients/{client_id} — Forwards to unified delete endpoint."""
|
||||
return await dashboard_oauth_clients_delete(request)
|
||||
|
||||
|
||||
async def get_service_page_data(plugin_type: str) -> dict | None:
|
||||
|
||||
@@ -48,6 +48,7 @@ class MCPEndpointFactory:
|
||||
self.tool_registry = tool_registry
|
||||
self.middleware_classes = middleware_classes or []
|
||||
self.endpoints: dict[str, FastMCP] = {}
|
||||
self._tool_counts: dict[str, int] = {}
|
||||
self._tool_handlers: dict[str, Callable] = {}
|
||||
|
||||
def register_tool_handler(self, tool_name: str, handler: Callable):
|
||||
@@ -92,8 +93,9 @@ class MCPEndpointFactory:
|
||||
for middleware in custom_middleware:
|
||||
mcp.add_middleware(middleware)
|
||||
|
||||
# Store endpoint
|
||||
# Store endpoint and tool count
|
||||
self.endpoints[config.path] = mcp
|
||||
self._tool_counts[config.path] = len(tools)
|
||||
|
||||
logger.info(f" - Endpoint {config.path} created successfully")
|
||||
|
||||
@@ -282,9 +284,7 @@ class MCPEndpointFactory:
|
||||
"""
|
||||
info = []
|
||||
for path, mcp in self.endpoints.items():
|
||||
# Get tool count
|
||||
# Note: This requires accessing FastMCP internals
|
||||
tool_count = len(mcp._tool_manager._tools) if hasattr(mcp, "_tool_manager") else 0
|
||||
tool_count = self._tool_counts.get(path, 0)
|
||||
|
||||
info.append(
|
||||
{
|
||||
|
||||
@@ -149,11 +149,7 @@ class EndpointRegistry:
|
||||
tool_count = 0
|
||||
|
||||
if mcp:
|
||||
# Try to get tool count from FastMCP
|
||||
try:
|
||||
tool_count = len(mcp._tool_manager._tools)
|
||||
except AttributeError:
|
||||
pass
|
||||
tool_count = self.factory._tool_counts.get(path, 0)
|
||||
|
||||
endpoints.append(
|
||||
EndpointInfo(
|
||||
|
||||
@@ -23,7 +23,6 @@ from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from core.audit_log import AuditLogger
|
||||
from core.project_manager import ProjectManager
|
||||
from core.site_manager import SiteManager
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -130,7 +129,6 @@ class HealthMonitor:
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
project_manager: ProjectManager,
|
||||
audit_logger: AuditLogger | None = None,
|
||||
metrics_retention_hours: int = 24,
|
||||
max_metrics_per_project: int = 1000,
|
||||
@@ -140,13 +138,11 @@ class HealthMonitor:
|
||||
Initialize health monitor.
|
||||
|
||||
Args:
|
||||
project_manager: Project manager instance
|
||||
audit_logger: Optional audit logger for logging health events
|
||||
metrics_retention_hours: Hours to retain historical metrics
|
||||
max_metrics_per_project: Maximum metrics to store per project
|
||||
site_manager: Optional SiteManager for comprehensive site discovery
|
||||
site_manager: SiteManager for site discovery
|
||||
"""
|
||||
self.project_manager = project_manager
|
||||
self.site_manager = site_manager
|
||||
self.audit_logger = audit_logger
|
||||
self.metrics_retention_hours = metrics_retention_hours
|
||||
@@ -410,15 +406,7 @@ class HealthMonitor:
|
||||
start_time = time.time()
|
||||
|
||||
try:
|
||||
# Get plugin instance from ProjectManager
|
||||
plugin = self.project_manager.projects.get(project_id)
|
||||
|
||||
if plugin:
|
||||
# Perform health check via plugin instance
|
||||
health_result = await plugin.health_check()
|
||||
elif self.site_manager:
|
||||
# Site exists in SiteManager but not legacy ProjectManager
|
||||
# Create a temporary plugin instance for a proper health check
|
||||
if self.site_manager:
|
||||
health_result = await self._site_manager_health_check(project_id)
|
||||
else:
|
||||
return ProjectHealthStatus(
|
||||
@@ -524,7 +512,7 @@ class HealthMonitor:
|
||||
|
||||
async def _site_manager_health_check(self, project_id: str) -> dict[str, Any]:
|
||||
"""
|
||||
Health check for sites managed by SiteManager (not in legacy ProjectManager).
|
||||
Health check for a site via SiteManager.
|
||||
|
||||
Creates a temporary plugin instance and calls its health_check() method,
|
||||
falling back to a basic HTTP check if plugin instantiation fails.
|
||||
@@ -650,8 +638,8 @@ class HealthMonitor:
|
||||
"""
|
||||
health_statuses = {}
|
||||
|
||||
# Collect all known project/site IDs from both sources
|
||||
all_project_ids = set(self.project_manager.projects.keys())
|
||||
# Collect all known site IDs from SiteManager
|
||||
all_project_ids = set()
|
||||
if self.site_manager:
|
||||
for site_info in self.site_manager.list_all_sites():
|
||||
all_project_ids.add(site_info["full_id"])
|
||||
@@ -860,7 +848,6 @@ def get_health_monitor() -> HealthMonitor | None:
|
||||
|
||||
|
||||
def initialize_health_monitor(
|
||||
project_manager: ProjectManager,
|
||||
audit_logger: AuditLogger | None = None,
|
||||
site_manager: SiteManager | None = None,
|
||||
**kwargs,
|
||||
@@ -869,16 +856,13 @@ def initialize_health_monitor(
|
||||
Initialize the global health monitor.
|
||||
|
||||
Args:
|
||||
project_manager: Project manager instance
|
||||
audit_logger: Optional audit logger
|
||||
site_manager: Optional SiteManager for comprehensive site discovery
|
||||
site_manager: SiteManager for site discovery
|
||||
**kwargs: Additional configuration options
|
||||
|
||||
Returns:
|
||||
HealthMonitor instance
|
||||
"""
|
||||
global _health_monitor
|
||||
_health_monitor = HealthMonitor(
|
||||
project_manager, audit_logger, site_manager=site_manager, **kwargs
|
||||
)
|
||||
_health_monitor = HealthMonitor(audit_logger=audit_logger, site_manager=site_manager, **kwargs)
|
||||
return _health_monitor
|
||||
|
||||
@@ -8,14 +8,14 @@ Usage:
|
||||
from core.plugin_visibility import get_public_plugin_types, is_plugin_public
|
||||
|
||||
public_types = get_public_plugin_types() # {"wordpress", "woocommerce", "supabase"}
|
||||
if is_plugin_public("gitea"): # False
|
||||
if is_plugin_public("gitea"): # True
|
||||
...
|
||||
"""
|
||||
|
||||
import os
|
||||
|
||||
# Default plugins available to public (OAuth) users
|
||||
DEFAULT_PUBLIC_PLUGINS = {"wordpress", "woocommerce", "supabase", "openpanel"}
|
||||
DEFAULT_PUBLIC_PLUGINS = {"wordpress", "woocommerce", "supabase", "openpanel", "gitea"}
|
||||
|
||||
|
||||
def _parse_plugins(val: str) -> set[str]:
|
||||
|
||||
@@ -1,259 +0,0 @@
|
||||
"""
|
||||
Project Manager
|
||||
|
||||
Discovers and manages project instances from environment variables.
|
||||
Handles plugin lifecycle and tool registration.
|
||||
"""
|
||||
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
from typing import Any
|
||||
|
||||
from plugins import BasePlugin, registry
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class ProjectManager:
|
||||
"""
|
||||
Manage multiple project instances.
|
||||
|
||||
Projects are discovered from environment variables:
|
||||
- {PLUGIN_TYPE}_{PROJECT_ID}_{CONFIG_KEY}
|
||||
|
||||
Example:
|
||||
WORDPRESS_SITE1_URL=https://example.com
|
||||
WORDPRESS_SITE1_USERNAME=admin
|
||||
WORDPRESS_SITE1_APP_PASSWORD=xxxx
|
||||
WORDPRESS_SITE2_URL=https://other.com
|
||||
...
|
||||
"""
|
||||
|
||||
def __init__(self):
|
||||
"""Initialize project manager."""
|
||||
self.projects: dict[str, BasePlugin] = {}
|
||||
self.logger = logging.getLogger("ProjectManager")
|
||||
|
||||
def discover_projects(self) -> None:
|
||||
"""
|
||||
Discover projects from environment variables.
|
||||
|
||||
Scans environment for project configurations and creates
|
||||
plugin instances.
|
||||
"""
|
||||
self.logger.info("Starting project discovery...")
|
||||
|
||||
# Get all registered plugin types
|
||||
plugin_types = registry.get_registered_types()
|
||||
|
||||
for plugin_type in plugin_types:
|
||||
self._discover_plugin_type(plugin_type)
|
||||
|
||||
self.logger.info(f"Discovery complete. Found {len(self.projects)} projects.")
|
||||
|
||||
def _discover_plugin_type(self, plugin_type: str) -> None:
|
||||
"""
|
||||
Discover all projects of a specific plugin type.
|
||||
|
||||
Args:
|
||||
plugin_type: Type of plugin (e.g., 'wordpress')
|
||||
"""
|
||||
prefix = plugin_type.upper() + "_"
|
||||
|
||||
# Build list of longer prefixes from other plugin types to avoid collisions.
|
||||
# e.g. WORDPRESS_ must not match WORDPRESS_ADVANCED_ env vars.
|
||||
plugin_types = registry.get_registered_types()
|
||||
longer_prefixes = [
|
||||
pt.upper() + "_"
|
||||
for pt in plugin_types
|
||||
if pt != plugin_type and pt.upper().startswith(plugin_type.upper() + "_")
|
||||
]
|
||||
|
||||
# Find all project IDs for this plugin type
|
||||
project_ids = set()
|
||||
env_pattern = re.compile(f"^{prefix}([A-Z0-9_]+?)_(.+)$")
|
||||
|
||||
for env_key in os.environ.keys():
|
||||
# Skip env vars that belong to a more specific plugin type
|
||||
if any(env_key.startswith(lp) for lp in longer_prefixes):
|
||||
continue
|
||||
|
||||
match = env_pattern.match(env_key)
|
||||
if match:
|
||||
project_id = match.group(1).lower()
|
||||
project_ids.add(project_id)
|
||||
|
||||
# Create plugin instance for each project
|
||||
for project_id in project_ids:
|
||||
try:
|
||||
config = self._load_project_config(plugin_type, project_id)
|
||||
if config:
|
||||
self._create_project_instance(plugin_type, project_id, config)
|
||||
except Exception as e:
|
||||
self.logger.debug(f"Legacy ProjectManager: skipped {plugin_type}/{project_id}: {e}")
|
||||
|
||||
def _load_project_config(self, plugin_type: str, project_id: str) -> dict[str, Any] | None:
|
||||
"""
|
||||
Load configuration for a project from environment.
|
||||
|
||||
Args:
|
||||
plugin_type: Plugin type
|
||||
project_id: Project ID
|
||||
|
||||
Returns:
|
||||
Dict with configuration or None if incomplete
|
||||
"""
|
||||
prefix = f"{plugin_type.upper()}_{project_id.upper()}_"
|
||||
config = {}
|
||||
|
||||
# Collect all config keys for this project
|
||||
for env_key, env_value in os.environ.items():
|
||||
if env_key.startswith(prefix):
|
||||
# Extract config key (everything after prefix)
|
||||
config_key = env_key[len(prefix) :].lower()
|
||||
config[config_key] = env_value
|
||||
|
||||
if not config:
|
||||
return None
|
||||
|
||||
self.logger.debug(f"Loaded config for {plugin_type}/{project_id}: {list(config.keys())}")
|
||||
return config
|
||||
|
||||
def _create_project_instance(
|
||||
self, plugin_type: str, project_id: str, config: dict[str, Any]
|
||||
) -> None:
|
||||
"""
|
||||
Create a plugin instance for a project.
|
||||
|
||||
Args:
|
||||
plugin_type: Plugin type
|
||||
project_id: Project ID
|
||||
config: Project configuration
|
||||
"""
|
||||
try:
|
||||
# Create plugin instance
|
||||
plugin = registry.create_instance(plugin_type, project_id, config)
|
||||
|
||||
# Store with full identifier
|
||||
full_id = f"{plugin_type}_{project_id}"
|
||||
self.projects[full_id] = plugin
|
||||
|
||||
self.logger.info(f"Created project: {full_id}")
|
||||
|
||||
except Exception as e:
|
||||
raise Exception(f"Failed to instantiate {plugin_type}/{project_id}: {e}")
|
||||
|
||||
def get_project(self, full_id: str) -> BasePlugin | None:
|
||||
"""
|
||||
Get a project plugin instance.
|
||||
|
||||
Args:
|
||||
full_id: Full project identifier (plugin_type_project_id)
|
||||
|
||||
Returns:
|
||||
Plugin instance or None
|
||||
"""
|
||||
return self.projects.get(full_id)
|
||||
|
||||
def get_all_projects(self) -> dict[str, BasePlugin]:
|
||||
"""Get all project instances."""
|
||||
return self.projects.copy()
|
||||
|
||||
def get_projects_by_type(self, plugin_type: str) -> dict[str, BasePlugin]:
|
||||
"""
|
||||
Get all projects of a specific type.
|
||||
|
||||
Args:
|
||||
plugin_type: Plugin type to filter by
|
||||
|
||||
Returns:
|
||||
Dict of project_id -> plugin
|
||||
"""
|
||||
prefix = plugin_type + "_"
|
||||
return {
|
||||
full_id: plugin
|
||||
for full_id, plugin in self.projects.items()
|
||||
if full_id.startswith(prefix)
|
||||
}
|
||||
|
||||
def get_all_tools(self) -> list[dict[str, Any]]:
|
||||
"""
|
||||
Get all MCP tools from all projects.
|
||||
|
||||
Returns:
|
||||
List of tool definitions
|
||||
"""
|
||||
all_tools = []
|
||||
|
||||
for full_id, plugin in self.projects.items():
|
||||
try:
|
||||
tools = plugin.get_tools()
|
||||
all_tools.extend(tools)
|
||||
self.logger.debug(f"Loaded {len(tools)} tools from {full_id}")
|
||||
except Exception as e:
|
||||
self.logger.error(f"Error loading tools from {full_id}: {e}", exc_info=True)
|
||||
|
||||
self.logger.debug(f"Total tools loaded: {len(all_tools)}")
|
||||
return all_tools
|
||||
|
||||
async def check_all_health(self) -> dict[str, dict[str, Any]]:
|
||||
"""
|
||||
Check health of all projects.
|
||||
|
||||
Returns:
|
||||
Dict mapping project ID to health status
|
||||
"""
|
||||
health_results = {}
|
||||
|
||||
for full_id, plugin in self.projects.items():
|
||||
try:
|
||||
health = await plugin.health_check()
|
||||
health_results[full_id] = health
|
||||
except Exception as e:
|
||||
health_results[full_id] = {
|
||||
"healthy": False,
|
||||
"message": f"Health check failed: {str(e)}",
|
||||
}
|
||||
|
||||
return health_results
|
||||
|
||||
def get_project_info(self, full_id: str) -> dict[str, Any] | None:
|
||||
"""
|
||||
Get information about a specific project.
|
||||
|
||||
Args:
|
||||
full_id: Full project identifier
|
||||
|
||||
Returns:
|
||||
Project info dict or None
|
||||
"""
|
||||
plugin = self.get_project(full_id)
|
||||
if plugin:
|
||||
return plugin.get_project_info()
|
||||
return None
|
||||
|
||||
def list_projects(self) -> list[dict[str, Any]]:
|
||||
"""
|
||||
List all projects with basic information.
|
||||
|
||||
Returns:
|
||||
List of project info dicts
|
||||
"""
|
||||
return [
|
||||
{"id": full_id, "type": plugin.get_plugin_name(), "project_id": plugin.project_id}
|
||||
for full_id, plugin in self.projects.items()
|
||||
]
|
||||
|
||||
|
||||
# Global project manager instance
|
||||
_project_manager: ProjectManager | None = None
|
||||
|
||||
|
||||
def get_project_manager() -> ProjectManager:
|
||||
"""Get the global project manager instance."""
|
||||
global _project_manager
|
||||
if _project_manager is None:
|
||||
_project_manager = ProjectManager()
|
||||
_project_manager.discover_projects()
|
||||
return _project_manager
|
||||
@@ -3,7 +3,7 @@
|
||||
Usage:
|
||||
from core.settings import get_setting
|
||||
|
||||
enabled = await get_setting("ENABLED_PLUGINS", "wordpress,woocommerce,supabase,openpanel")
|
||||
enabled = await get_setting("ENABLED_PLUGINS", "wordpress,woocommerce,supabase,openpanel,gitea")
|
||||
max_sites = int(await get_setting("MAX_SITES_PER_USER", "10"))
|
||||
"""
|
||||
|
||||
@@ -17,7 +17,7 @@ _cached_plugins: set[str] | None = None
|
||||
|
||||
# Default values for all managed settings
|
||||
SETTING_DEFAULTS: dict[str, str] = {
|
||||
"ENABLED_PLUGINS": "wordpress,woocommerce,supabase,openpanel",
|
||||
"ENABLED_PLUGINS": "wordpress,woocommerce,supabase,openpanel,gitea",
|
||||
"MAX_SITES_PER_USER": "10",
|
||||
"USER_RATE_LIMIT_PER_MIN": "30",
|
||||
"USER_RATE_LIMIT_PER_HR": "500",
|
||||
|
||||
@@ -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", "")
|
||||
|
||||
@@ -4,20 +4,12 @@ Site Manager - Type-safe site configuration management
|
||||
Manages site configurations with Pydantic validation.
|
||||
Part of Option B clean architecture refactoring.
|
||||
|
||||
Discovers sites from environment variables:
|
||||
- {PLUGIN_TYPE}_{SITE_ID}_{CONFIG_KEY}
|
||||
- {PLUGIN_TYPE}_{SITE_ID}_ALIAS (optional)
|
||||
|
||||
Example:
|
||||
WORDPRESS_SITE1_URL=https://example.com
|
||||
WORDPRESS_SITE1_USERNAME=admin
|
||||
WORDPRESS_SITE1_APP_PASSWORD=xxxx
|
||||
WORDPRESS_SITE2_ALIAS=myblog
|
||||
Sites are managed via the web dashboard and stored in SQLite (DB-based).
|
||||
The SiteManager provides registration and lookup infrastructure for
|
||||
plugin tool generation and endpoint routing.
|
||||
"""
|
||||
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
from typing import Any
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, Field, ValidationInfo, field_validator
|
||||
@@ -111,7 +103,8 @@ class SiteManager:
|
||||
"""
|
||||
Manage site configurations with type safety.
|
||||
|
||||
Discovers, validates, and provides access to site configurations.
|
||||
Provides registration and lookup of site configurations.
|
||||
Sites are registered programmatically (e.g., from database) via register_site().
|
||||
|
||||
Attributes:
|
||||
sites: Dictionary mapping plugin_type to site configurations
|
||||
@@ -120,7 +113,7 @@ class SiteManager:
|
||||
|
||||
Examples:
|
||||
>>> manager = SiteManager()
|
||||
>>> manager.discover_sites(['wordpress', 'gitea'])
|
||||
>>> manager.register_site(config)
|
||||
>>> config = manager.get_site_config('wordpress', 'myblog')
|
||||
>>> sites = manager.list_sites('wordpress')
|
||||
"""
|
||||
@@ -136,167 +129,6 @@ class SiteManager:
|
||||
self.logger = logging.getLogger("SiteManager")
|
||||
self.logger.info("SiteManager initialized")
|
||||
|
||||
def discover_sites(self, plugin_types: list[str]) -> int:
|
||||
"""
|
||||
Discover sites from environment variables.
|
||||
|
||||
Scans environment for site configurations and registers them.
|
||||
|
||||
Args:
|
||||
plugin_types: List of plugin types to discover (e.g., ['wordpress'])
|
||||
|
||||
Returns:
|
||||
Number of sites discovered
|
||||
|
||||
Examples:
|
||||
>>> count = manager.discover_sites(['wordpress', 'gitea'])
|
||||
>>> print(f"Discovered {count} sites")
|
||||
"""
|
||||
self.logger.info(f"Starting site discovery for: {', '.join(plugin_types)}")
|
||||
|
||||
total_discovered = 0
|
||||
for plugin_type in plugin_types:
|
||||
count = self._discover_plugin_sites(plugin_type)
|
||||
total_discovered += count
|
||||
|
||||
self.logger.info(
|
||||
f"Discovery complete. Found {total_discovered} sites "
|
||||
f"across {len(plugin_types)} plugin types."
|
||||
)
|
||||
|
||||
return total_discovered
|
||||
|
||||
# Reserved words that should NOT be interpreted as site IDs
|
||||
RESERVED_SITE_WORDS = {
|
||||
"limit",
|
||||
"rate",
|
||||
"config",
|
||||
"debug",
|
||||
"log",
|
||||
"level",
|
||||
"mode",
|
||||
"timeout",
|
||||
"retry",
|
||||
"max",
|
||||
"min",
|
||||
"default",
|
||||
"global",
|
||||
"enabled",
|
||||
"disabled",
|
||||
"host",
|
||||
"port",
|
||||
"path",
|
||||
"key",
|
||||
"secret",
|
||||
"token",
|
||||
"advanced",
|
||||
"basic",
|
||||
"simple",
|
||||
"pro",
|
||||
"premium",
|
||||
"standard",
|
||||
}
|
||||
|
||||
def _discover_plugin_sites(self, plugin_type: str) -> int:
|
||||
"""
|
||||
Discover all sites for a specific plugin type.
|
||||
|
||||
Args:
|
||||
plugin_type: Type of plugin (e.g., 'wordpress')
|
||||
|
||||
Returns:
|
||||
Number of sites discovered for this plugin type
|
||||
|
||||
Examples:
|
||||
>>> count = manager._discover_plugin_sites('wordpress')
|
||||
"""
|
||||
prefix = plugin_type.upper() + "_"
|
||||
|
||||
# Build list of longer prefixes from other plugin types to avoid collisions.
|
||||
# e.g. WORDPRESS_ must not match WORDPRESS_ADVANCED_ env vars.
|
||||
from plugins import registry as plugin_registry
|
||||
|
||||
all_plugin_types = plugin_registry.get_registered_types()
|
||||
longer_prefixes = [
|
||||
pt.upper() + "_"
|
||||
for pt in all_plugin_types
|
||||
if pt != plugin_type and pt.upper().startswith(plugin_type.upper() + "_")
|
||||
]
|
||||
|
||||
# Pattern to match: WORDPRESS_SITE1_URL, WORDPRESS_SITE2_USERNAME, etc.
|
||||
env_pattern = re.compile(f"^{prefix}([A-Z0-9_]+?)_(.+)$")
|
||||
|
||||
# Find all unique site IDs
|
||||
site_ids = set()
|
||||
for env_key in os.environ.keys():
|
||||
# Skip env vars that belong to a more specific plugin type
|
||||
if any(env_key.startswith(lp) for lp in longer_prefixes):
|
||||
continue
|
||||
|
||||
match = env_pattern.match(env_key)
|
||||
if match:
|
||||
site_id = match.group(1).lower()
|
||||
# Skip reserved words that are not real site IDs
|
||||
if site_id not in self.RESERVED_SITE_WORDS:
|
||||
site_ids.add(site_id)
|
||||
|
||||
# Load configuration for each site
|
||||
discovered_count = 0
|
||||
for site_id in site_ids:
|
||||
try:
|
||||
config = self._load_site_config(plugin_type, site_id)
|
||||
if config:
|
||||
self.register_site(config)
|
||||
discovered_count += 1
|
||||
except Exception as e:
|
||||
self.logger.error(
|
||||
f"Failed to load {plugin_type} site '{site_id}': {e}", exc_info=True
|
||||
)
|
||||
|
||||
return discovered_count
|
||||
|
||||
def _load_site_config(self, plugin_type: str, site_id: str) -> SiteConfig | None:
|
||||
"""
|
||||
Load configuration for a site from environment.
|
||||
|
||||
Args:
|
||||
plugin_type: Plugin type
|
||||
site_id: Site ID
|
||||
|
||||
Returns:
|
||||
SiteConfig if successful, None if incomplete
|
||||
|
||||
Examples:
|
||||
>>> config = manager._load_site_config('wordpress', 'site1')
|
||||
"""
|
||||
prefix = f"{plugin_type.upper()}_{site_id.upper()}_"
|
||||
config_data = {"site_id": site_id, "plugin_type": plugin_type}
|
||||
|
||||
# Collect all config keys for this site
|
||||
for env_key, env_value in os.environ.items():
|
||||
if env_key.startswith(prefix):
|
||||
# Extract config key (everything after prefix)
|
||||
config_key = env_key[len(prefix) :].lower()
|
||||
config_data[config_key] = env_value
|
||||
|
||||
# Must have at least some configuration beyond site_id and plugin_type
|
||||
if len(config_data) <= 2:
|
||||
return None
|
||||
|
||||
try:
|
||||
# Create and validate SiteConfig
|
||||
config = SiteConfig(**config_data)
|
||||
|
||||
self.logger.debug(
|
||||
f"Loaded config for {plugin_type}/{site_id}: " f"{list(config_data.keys())}"
|
||||
)
|
||||
|
||||
return config
|
||||
|
||||
except Exception as e:
|
||||
self.logger.error(f"Validation failed for {plugin_type}/{site_id}: {e}", exc_info=True)
|
||||
return None
|
||||
|
||||
def register_site(self, config: SiteConfig) -> None:
|
||||
"""
|
||||
Register a site configuration.
|
||||
@@ -356,7 +188,7 @@ class SiteManager:
|
||||
# SECURITY: Don't reveal available plugin types in multi-tenant environment
|
||||
raise ValueError(
|
||||
f"No sites configured for plugin type: {plugin_type}. "
|
||||
f"Please check your environment variables."
|
||||
f"Please add a site via the dashboard."
|
||||
)
|
||||
|
||||
# Try direct lookup
|
||||
@@ -373,7 +205,7 @@ class SiteManager:
|
||||
)
|
||||
raise ValueError(
|
||||
f"Site '{site}' not configured for {plugin_type}. "
|
||||
f"Please verify the site alias/ID and check environment variables."
|
||||
f"Please verify the site alias/ID in the dashboard."
|
||||
)
|
||||
|
||||
def list_sites(self, plugin_type: str) -> list[str]:
|
||||
@@ -545,7 +377,7 @@ def get_site_manager() -> SiteManager:
|
||||
|
||||
Examples:
|
||||
>>> manager = get_site_manager()
|
||||
>>> manager.discover_sites(['wordpress'])
|
||||
>>> manager.register_site(config)
|
||||
"""
|
||||
global _site_manager
|
||||
if _site_manager is None:
|
||||
|
||||
@@ -138,6 +138,9 @@
|
||||
('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'),
|
||||
('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'),
|
||||
] %}
|
||||
|
||||
{# ── Admin-only nav items ── #}
|
||||
@@ -146,9 +149,6 @@
|
||||
'/dashboard/projects'),
|
||||
('api_keys', t.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/api-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'),
|
||||
('audit_logs', t.audit_logs, 'M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2
|
||||
2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2m-3 7h3m-3 4h3m-6-4h.01M9 16h.01',
|
||||
'/dashboard/audit-logs'),
|
||||
|
||||
@@ -159,10 +159,10 @@
|
||||
<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/connect/oauth-clients{% if lang and lang != 'en' %}?lang={{ lang }}{% endif %}" class="underline">OAuth Client</a> اختیاری است.
|
||||
ساخت <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/connect/oauth-clients{% if lang and lang != 'en' %}?lang={{ lang }}{% endif %}" class="underline">OAuth Client</a> is optional.
|
||||
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>
|
||||
|
||||
@@ -228,6 +228,14 @@
|
||||
<p class="text-gray-500 text-sm mt-2">
|
||||
{% if lang == 'fa' %}فیلترها را پاک کنید{% else %}Try clearing the filters{% endif %}
|
||||
</p>
|
||||
{% else %}
|
||||
<p class="text-gray-500 text-sm mt-2">
|
||||
{% if lang == 'fa' %}
|
||||
سایتها از طریق <a href="/dashboard/sites{% if lang and lang != 'en' %}?lang={{ lang }}{% endif %}" class="text-primary-400 hover:underline">صفحه سایتها</a> مدیریت میشوند.
|
||||
{% else %}
|
||||
Sites are managed via the <a href="/dashboard/sites" class="text-primary-400 hover:underline">My Sites</a> page.
|
||||
{% endif %}
|
||||
</p>
|
||||
{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
@@ -50,15 +50,9 @@ services:
|
||||
- USER_RATE_LIMIT_PER_MIN=${USER_RATE_LIMIT_PER_MIN:-30}
|
||||
- USER_RATE_LIMIT_PER_HR=${USER_RATE_LIMIT_PER_HR:-500}
|
||||
|
||||
# === ADMIN-MANAGED SITES (optional) ===
|
||||
# Add WordPress/Gitea/n8n/etc. sites here if you want admin-level access.
|
||||
# Users can add their own sites via the dashboard without these.
|
||||
# Format: {PLUGIN_TYPE}_{SITE_ID}_{CONFIG_KEY}
|
||||
#
|
||||
# - WORDPRESS_SITE1_URL=${WORDPRESS_SITE1_URL:-}
|
||||
# - WORDPRESS_SITE1_USERNAME=${WORDPRESS_SITE1_USERNAME:-}
|
||||
# - WORDPRESS_SITE1_APP_PASSWORD=${WORDPRESS_SITE1_APP_PASSWORD:-}
|
||||
# - WORDPRESS_SITE1_ALIAS=${WORDPRESS_SITE1_ALIAS:-}
|
||||
# === SITES ===
|
||||
# Sites are managed via the web dashboard (DB-based).
|
||||
# After deployment, open the dashboard to add sites.
|
||||
|
||||
healthcheck:
|
||||
test: ["CMD", "wget", "-qO-", "http://localhost:8000/health"]
|
||||
|
||||
@@ -248,12 +248,11 @@ Without Docker socket:
|
||||
- WordPress Advanced database/system tools are unavailable
|
||||
- All REST API tools (bulk operations, content management) work normally
|
||||
|
||||
### Environment Variable Reference
|
||||
### Site Configuration
|
||||
|
||||
All site configuration follows the pattern: `{PLUGIN_TYPE}_{SITE_ID}_{CONFIG_KEY}=value`
|
||||
Sites are managed via the web dashboard. After starting the server, open the dashboard to add sites.
|
||||
|
||||
- `SITE_ID` can be any alphanumeric identifier (e.g., `SITE1`, `PROD`, `MYBLOG`)
|
||||
- Multiple sites: change `SITE1` to `SITE2`, `SITE3`, etc.
|
||||
Each plugin requires specific credentials (see below for reference).
|
||||
|
||||
#### WordPress
|
||||
```env
|
||||
@@ -358,7 +357,7 @@ After starting (via Docker or locally), wait ~30 seconds for the server to initi
|
||||
|
||||
```bash
|
||||
curl http://localhost:8000/health
|
||||
# Expected: {"status": "ok", "tools_loaded": 596, ...}
|
||||
# Expected: {"status": "ok", "tools_loaded": 565, ...}
|
||||
```
|
||||
|
||||
**2. Open the web dashboard:**
|
||||
@@ -392,7 +391,7 @@ docker compose logs -f mcphub
|
||||
| Port 8000 already in use | Change port in docker-compose.yaml: `"8001:8000"` |
|
||||
| Health check shows "unhealthy" | Wait 60 seconds, then check logs for startup errors |
|
||||
| Dashboard login fails | Make sure you're using the `MASTER_API_KEY` value from your `.env` |
|
||||
| Sites not showing up | Restart after adding new env vars: `docker compose restart` |
|
||||
| Sites not showing up | Add sites via the web dashboard, then check the connect page |
|
||||
|
||||
---
|
||||
|
||||
@@ -482,7 +481,7 @@ MCP Hub supports **Open Dynamic Client Registration** (RFC 7591). ChatGPT can au
|
||||
|
||||
## Using MCP Tools
|
||||
|
||||
### 596 Tools Across 9 Plugins
|
||||
### 565 Tools Across 9 Plugins
|
||||
|
||||
| Plugin | Tools | Env Prefix |
|
||||
|--------|-------|------------|
|
||||
@@ -492,7 +491,7 @@ MCP Hub supports **Open Dynamic Client Registration** (RFC 7591). ChatGPT can au
|
||||
| Gitea | 56 | `GITEA_` |
|
||||
| n8n | 56 | `N8N_` |
|
||||
| Supabase | 70 | `SUPABASE_` |
|
||||
| OpenPanel | 73 | `OPENPANEL_` |
|
||||
| OpenPanel | 42 | `OPENPANEL_` |
|
||||
| Appwrite | 100 | `APPWRITE_` |
|
||||
| Directus | 100 | `DIRECTUS_` |
|
||||
| System | 24 | (no config needed) |
|
||||
@@ -519,9 +518,9 @@ Use the most specific endpoint for your use case to minimize token usage:
|
||||
| `/project/{alias}/mcp` | Single-site workflow | 22-100 | No (pre-scoped) |
|
||||
| `/{plugin}/mcp` | Multi-site management | 23-101 | Yes |
|
||||
| `/system/mcp` | System administration | 24 | N/A |
|
||||
| `/mcp` | Admin & discovery only | 596 | Yes |
|
||||
| `/mcp` | Admin & discovery only | 565 | Yes |
|
||||
|
||||
> **Recommendation**: Always use the most specific endpoint. Using `/mcp` (596 tools) wastes context tokens and degrades AI response quality.
|
||||
> **Recommendation**: Always use the most specific endpoint. Using `/mcp` (565 tools) wastes context tokens and degrades AI response quality.
|
||||
|
||||
**Available plugin endpoints:**
|
||||
|
||||
@@ -533,7 +532,7 @@ Use the most specific endpoint for your use case to minimize token usage:
|
||||
| `/gitea/mcp` | Gitea | 56 |
|
||||
| `/n8n/mcp` | n8n | 56 |
|
||||
| `/supabase/mcp` | Supabase | 70 |
|
||||
| `/openpanel/mcp` | OpenPanel | 73 |
|
||||
| `/openpanel/mcp` | OpenPanel | 42 |
|
||||
| `/appwrite/mcp` | Appwrite | 100 |
|
||||
| `/directus/mcp` | Directus | 100 |
|
||||
| `/system/mcp` | System Management | 24 |
|
||||
@@ -610,18 +609,16 @@ docker compose up --build -d
|
||||
|
||||
### Step 3: Configure Environment Variables
|
||||
|
||||
Add all required environment variables in Coolify's environment variable UI:
|
||||
Add the required environment variables in Coolify's environment variable UI:
|
||||
|
||||
```
|
||||
MASTER_API_KEY=your-secure-key-here
|
||||
OAUTH_JWT_SECRET_KEY=your-jwt-secret
|
||||
OAUTH_BASE_URL=https://your-domain.com
|
||||
WORDPRESS_SITE1_URL=https://example.com
|
||||
WORDPRESS_SITE1_USERNAME=admin
|
||||
WORDPRESS_SITE1_APP_PASSWORD=xxxx xxxx xxxx xxxx
|
||||
ENCRYPTION_KEY=your-base64-encryption-key
|
||||
```
|
||||
|
||||
The server auto-discovers all `WORDPRESS_*`, `WOOCOMMERCE_*`, `GITEA_*`, and other plugin environment variables at startup.
|
||||
After deployment, add sites via the web dashboard at `https://your-domain.com/dashboard`.
|
||||
|
||||
### Step 4: Configure Health Check
|
||||
|
||||
@@ -641,7 +638,7 @@ The server auto-discovers all `WORDPRESS_*`, `WOOCOMMERCE_*`, `GITEA_*`, and oth
|
||||
|
||||
## Next Steps
|
||||
|
||||
1. **Explore the full tool list**: See the [README](../README.md) for all 596 tools
|
||||
1. **Explore the full tool list**: See the [README](../README.md) for all 565 tools
|
||||
2. **Set up API keys**: [API Keys Guide](API_KEYS_GUIDE.md) for per-project access control
|
||||
3. **Configure OAuth**: [OAuth Guide](OAUTH_GUIDE.md) for Claude/ChatGPT auto-registration
|
||||
4. **Monitor health**: Use `check_all_projects_health` tool or visit the web dashboard
|
||||
|
||||
90
docs/prompts/F16-gitea-public-release.md
Normal file
90
docs/prompts/F16-gitea-public-release.md
Normal file
@@ -0,0 +1,90 @@
|
||||
# F.16 — Gitea Plugin Review, Test & Public Release
|
||||
|
||||
> Session prompt. Copy and paste into a new Claude Code conversation.
|
||||
|
||||
---
|
||||
|
||||
## Prompt:
|
||||
|
||||
```
|
||||
از مهارت project-ops برای آشنایی با محیط استفاده کن. پروژه mcphub-internal روی branch Phase-1 است.
|
||||
فایلهای مرجع:
|
||||
- docs/plans/2026-03-25-v4-development-cycle.md
|
||||
- CLAUDE.md
|
||||
- CHANGELOG.md
|
||||
|
||||
## وضعیت فعلی
|
||||
- نسخه: v3.5.0
|
||||
- 565 ابزار در 9 پلاگین (4 پلاگین عمومی: WordPress, WooCommerce, Supabase, OpenPanel)
|
||||
- FastMCP: `>=3.0.0,<4.0.0`
|
||||
- CI سبز
|
||||
- F.15 تکمیل شده — FastMCP 3.x upgrade + legacy cleanup
|
||||
|
||||
## هدف: فاز F.16 — Gitea Plugin Review, Test & Public Enablement
|
||||
|
||||
مشابه F.10 (OpenPanel) — بررسی، تست و فعالسازی عمومی پلاگین Gitea.
|
||||
|
||||
### بخش 1: بررسی و تحلیل
|
||||
1. **ساختار پلاگین**: `plugins/gitea/` شامل 56 ابزار در 5 هندلر:
|
||||
- `handlers/repositories.py` — 16 tools (CRUD repos, branches, tags, files)
|
||||
- `handlers/issues.py` — 12 tools (issues, comments, labels, milestones)
|
||||
- `handlers/pull_requests.py` — 15 tools (PRs, reviews, merge, diff)
|
||||
- `handlers/users.py` — 8 tools (users, orgs, teams)
|
||||
- `handlers/webhooks.py` — 5 tools (CRUD webhooks, test)
|
||||
2. **مشکلات شناساییشده**:
|
||||
- `update_webhook` در client.py هست ولی به عنوان tool expose نشده
|
||||
- هیچ تست اختصاصیای ندارد (0 تست!)
|
||||
- فعلاً admin-only هست (نه در ENABLED_PLUGINS)
|
||||
3. **تست واقعی**: سایت Gitea از MCPHub MCP endpoint:
|
||||
- از ابزارهای Supabase MCP (`mcp.example.com`) استفاده کنید تا سایت Gitea موجود را پیدا کنید
|
||||
- یا یک Gitea instance جدید از طریق داشبورد اضافه کنید
|
||||
- هر دسته tool را با API واقعی تست کنید
|
||||
|
||||
### بخش 2: تست نوشتن
|
||||
مشابه `tests/test_openpanel_plugin.py` (الگو — 767 خط, 62 تست):
|
||||
1. `tests/test_gitea_plugin.py` بنویسید:
|
||||
- Client initialization + health check
|
||||
- Tool spec validation (نامها، پارامترها، type ها)
|
||||
- Handler delegation (mock client)
|
||||
- Error handling
|
||||
- Pagination
|
||||
2. هدف: حداقل 80 تست (56 tool + edge cases)
|
||||
|
||||
### بخش 3: رفع مشکلات
|
||||
1. `update_webhook` را به عنوان tool expose کنید
|
||||
2. هر tool که در تست واقعی مشکل دارد را fix کنید
|
||||
3. توضیحات service page برای Gitea بنویسید
|
||||
4. health check endpoint را بررسی کنید
|
||||
|
||||
### بخش 4: فعالسازی عمومی
|
||||
1. `core/plugin_visibility.py`: اضافه کردن `gitea` به `DEFAULT_PUBLIC_PLUGINS`
|
||||
2. `env.example`: آپدیت ENABLED_PLUGINS default
|
||||
3. `glama.json`: آپدیت description اگر لازمه
|
||||
|
||||
### بخش 5: Release
|
||||
1. Version bump: `3.5.0` → `3.6.0`
|
||||
2. CHANGELOG update
|
||||
3. Lint: `uvx --python 3.12 black .` && `uvx ruff check --fix .`
|
||||
4. Commit + push Phase-1
|
||||
5. Sync: `python3.11 scripts/community-build/sync.py --output ../mcphub/`
|
||||
6. بعد از sync حتماً `uvx --python 3.12 black .` در public repo هم بزنید!
|
||||
7. Commit + push public repo
|
||||
|
||||
### مراحل پیشنهادی
|
||||
1. **تحلیل**: خواندن کامل هر handler + client + schemas
|
||||
2. **تست واقعی**: اتصال به Gitea instance و تست ابزارها
|
||||
3. **پلن اجرایی**: ارائه پلن و تایید قبل از شروع
|
||||
4. **تست نوشتن**: test_gitea_plugin.py
|
||||
5. **رفع مشکلات**: fix هر tool مشکلدار
|
||||
6. **فعالسازی**: ENABLED_PLUGINS update
|
||||
7. **Release**: v3.6.0
|
||||
|
||||
### نکات فنی
|
||||
- Private repo: /config/workspace/mcphub-internal (branch Phase-1)
|
||||
- Public repo: /config/workspace/mcphub (branch main)
|
||||
- Lint: `uvx --python 3.12 black .` && `uvx ruff check --fix .`
|
||||
- Sync: `python3.11 scripts/community-build/sync.py --output ../mcphub/`
|
||||
- ایمیل git عمومی: hi.airano@gmail.com
|
||||
- **مهم**: اول پلن بده، بدون تایید شروع نکن
|
||||
- **الگوی مرجع**: F.10 (OpenPanel) — بخش "Phase F.10" در plan و `tests/test_openpanel_plugin.py`
|
||||
```
|
||||
95
docs/prompts/next-session-coolify-f17.md
Normal file
95
docs/prompts/next-session-coolify-f17.md
Normal 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
|
||||
```
|
||||
77
docs/prompts/next-session-coolify-test.md
Normal file
77
docs/prompts/next-session-coolify-test.md
Normal 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
|
||||
```
|
||||
93
docs/prompts/next-session.md
Normal file
93
docs/prompts/next-session.md
Normal 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
|
||||
```
|
||||
129
env.example
129
env.example
@@ -2,7 +2,7 @@
|
||||
# MCP Hub — Environment Configuration
|
||||
# ===================================
|
||||
#
|
||||
# MINIMUM TO START: Just set MASTER_API_KEY and one site below.
|
||||
# MINIMUM TO START: Set MASTER_API_KEY, then add sites via the web dashboard.
|
||||
# Full docs: https://github.com/airano-ir/mcphub/blob/main/docs/getting-started.md
|
||||
#
|
||||
# After editing, run:
|
||||
@@ -32,8 +32,8 @@ DASHBOARD_SESSION_SECRET=
|
||||
# ============================================
|
||||
# Comma-separated list of plugins visible to public (OAuth) users.
|
||||
# Admin users always see all plugins regardless of this setting.
|
||||
# Default (if not set): wordpress,woocommerce,supabase,openpanel
|
||||
# ENABLED_PLUGINS=wordpress,woocommerce,supabase,openpanel
|
||||
# Default (if not set): wordpress,woocommerce,supabase,openpanel,gitea
|
||||
# ENABLED_PLUGINS=wordpress,woocommerce,supabase,openpanel,gitea
|
||||
|
||||
# ============================================
|
||||
# ADMIN SYSTEM (Track F.4)
|
||||
@@ -49,117 +49,14 @@ DASHBOARD_SESSION_SECRET=
|
||||
# DISABLE_MASTER_KEY_LOGIN=false
|
||||
|
||||
# ============================================
|
||||
# WORDPRESS SITES
|
||||
# SITES
|
||||
# ============================================
|
||||
# Pattern: WORDPRESS_{SITE_ID}_{CONFIG_KEY}
|
||||
# Add as many sites as you want (SITE1, SITE2, SITE3, ...)
|
||||
# Sites are managed via the web dashboard (DB-based).
|
||||
# After starting the server, open the dashboard to add sites:
|
||||
# http://localhost:8000/dashboard
|
||||
#
|
||||
# How to create a WordPress Application Password:
|
||||
# 1. Log into WordPress admin → Users → Your Profile
|
||||
# 2. Scroll to "Application Passwords" section
|
||||
# 3. Enter name "MCP Hub" → click "Add New"
|
||||
# 4. Copy the generated password (format: xxxx xxxx xxxx xxxx)
|
||||
|
||||
WORDPRESS_SITE1_URL=https://your-site.com
|
||||
WORDPRESS_SITE1_USERNAME=admin
|
||||
WORDPRESS_SITE1_APP_PASSWORD=xxxx xxxx xxxx xxxx
|
||||
WORDPRESS_SITE1_ALIAS=mysite
|
||||
|
||||
# Optional: Docker container name for WP-CLI tools (cache flush, transients, etc.)
|
||||
# Find your container name with: docker ps --filter name=wordpress
|
||||
# This lets MCP Hub run wp-cli commands inside your WordPress container.
|
||||
# WORDPRESS_SITE1_CONTAINER=wordpress-container-name
|
||||
|
||||
# Second site example (uncomment to use):
|
||||
# WORDPRESS_SITE2_URL=https://another-site.com
|
||||
# WORDPRESS_SITE2_USERNAME=admin
|
||||
# WORDPRESS_SITE2_APP_PASSWORD=yyyy yyyy yyyy yyyy
|
||||
# WORDPRESS_SITE2_ALIAS=blog
|
||||
|
||||
# ============================================
|
||||
# WORDPRESS ADVANCED (database, bulk, system)
|
||||
# ============================================
|
||||
# Pattern: WORDPRESS_ADVANCED_{SITE_ID}_{CONFIG_KEY}
|
||||
# Provides database operations, bulk operations, and system management.
|
||||
# REQUIRES Docker socket AND container name — all tools use WP-CLI.
|
||||
#
|
||||
# Uses the same credentials as WordPress, but the CONTAINER key is REQUIRED.
|
||||
# WORDPRESS_ADVANCED_SITE1_URL=https://your-site.com
|
||||
# WORDPRESS_ADVANCED_SITE1_USERNAME=admin
|
||||
# WORDPRESS_ADVANCED_SITE1_APP_PASSWORD=xxxx xxxx xxxx xxxx
|
||||
# WORDPRESS_ADVANCED_SITE1_CONTAINER=wordpress-container-name
|
||||
# WORDPRESS_ADVANCED_SITE1_ALIAS=mysite-advanced
|
||||
|
||||
# ============================================
|
||||
# WOOCOMMERCE STORES
|
||||
# ============================================
|
||||
# Pattern: WOOCOMMERCE_{STORE_ID}_{CONFIG_KEY}
|
||||
# WooCommerce is a separate plugin — uses Consumer Key/Secret, not App Passwords.
|
||||
#
|
||||
# Create keys: WooCommerce → Settings → Advanced → REST API → Add Key
|
||||
|
||||
# WOOCOMMERCE_STORE1_URL=https://your-store.com
|
||||
# WOOCOMMERCE_STORE1_CONSUMER_KEY=ck_xxxxx
|
||||
# WOOCOMMERCE_STORE1_CONSUMER_SECRET=cs_xxxxx
|
||||
# WOOCOMMERCE_STORE1_ALIAS=mystore
|
||||
|
||||
# ============================================
|
||||
# OTHER PLUGINS (uncomment to use)
|
||||
# ============================================
|
||||
# All plugins auto-discover their environment variables at startup.
|
||||
# Pattern: {PLUGIN_TYPE}_{INSTANCE_ID}_{CONFIG_KEY}
|
||||
|
||||
# --- Gitea ---
|
||||
# GITEA_REPO1_URL=https://git.example.com
|
||||
# GITEA_REPO1_TOKEN=your_gitea_token
|
||||
# GITEA_REPO1_ALIAS=mygitea
|
||||
|
||||
# --- n8n ---
|
||||
# N8N_INSTANCE1_URL=https://n8n.example.com
|
||||
# N8N_INSTANCE1_API_KEY=your_n8n_api_key
|
||||
# N8N_INSTANCE1_ALIAS=myn8n
|
||||
|
||||
# --- Supabase (Self-Hosted or Cloud) ---
|
||||
# Works with both self-hosted Supabase and supabase.com cloud projects.
|
||||
# For cloud: URL is https://xxxx.supabase.co
|
||||
# For self-hosted: URL is your Kong gateway (e.g., http://your-server:8000)
|
||||
#
|
||||
# SERVICE_ROLE_KEY is required (bypasses RLS, full admin access).
|
||||
# ANON_KEY is optional — if omitted, SERVICE_ROLE_KEY is used for all calls.
|
||||
#
|
||||
# Note: postgres-meta tools (list_tables, execute_sql, get_table_schema, etc.)
|
||||
# require the /pg/ Kong route or a direct META_URL. These tools do NOT work
|
||||
# on supabase.com cloud (postgres-meta is internal only on cloud).
|
||||
# PostgREST/Auth/Storage/Functions tools work on both cloud and self-hosted.
|
||||
#
|
||||
# SUPABASE_PROJECT1_URL=https://xxxx.supabase.co
|
||||
# SUPABASE_PROJECT1_SERVICE_ROLE_KEY=your_service_role_key
|
||||
# SUPABASE_PROJECT1_ANON_KEY=your_anon_key
|
||||
# SUPABASE_PROJECT1_ALIAS=mysupabase
|
||||
#
|
||||
# Optional: direct postgres-meta URL for self-hosted when /pg/ is not via Kong:
|
||||
# SUPABASE_PROJECT1_META_URL=http://localhost:5555
|
||||
#
|
||||
# Optional: Basic Auth for postgres-meta when exposed via a public URL.
|
||||
# Format: username:password — not needed for internal Docker networks or Kong.
|
||||
# SUPABASE_PROJECT1_META_AUTH=admin:secretpassword
|
||||
|
||||
# --- OpenPanel ---
|
||||
# OPENPANEL_INSTANCE1_URL=https://openpanel.example.com
|
||||
# OPENPANEL_INSTANCE1_CLIENT_ID=your_client_id
|
||||
# OPENPANEL_INSTANCE1_CLIENT_SECRET=your_client_secret
|
||||
# OPENPANEL_INSTANCE1_ALIAS=myopenpanel
|
||||
|
||||
# --- Appwrite ---
|
||||
# APPWRITE_PROJECT1_URL=https://appwrite.example.com
|
||||
# APPWRITE_PROJECT1_API_KEY=your_appwrite_api_key
|
||||
# APPWRITE_PROJECT1_PROJECT_ID=your_project_id
|
||||
# APPWRITE_PROJECT1_ALIAS=myappwrite
|
||||
|
||||
# --- Directus ---
|
||||
# DIRECTUS_INSTANCE1_URL=https://directus.example.com
|
||||
# DIRECTUS_INSTANCE1_TOKEN=your_directus_token
|
||||
# DIRECTUS_INSTANCE1_ALIAS=mydirectus
|
||||
# Supported plugins: WordPress, WooCommerce, Supabase, OpenPanel,
|
||||
# Gitea, n8n, Appwrite, Directus, WordPress Advanced
|
||||
|
||||
# ============================================
|
||||
# OAUTH (optional — for ChatGPT/Claude auto-registration)
|
||||
@@ -192,6 +89,14 @@ WORDPRESS_SITE1_ALIAS=mysite
|
||||
# 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)
|
||||
# ============================================
|
||||
|
||||
36
glama.json
Normal file
36
glama.json
Normal file
@@ -0,0 +1,36 @@
|
||||
{
|
||||
"$schema": "https://glama.ai/mcp/schemas/server.json",
|
||||
"maintainers": ["airano-ir"],
|
||||
"name": "mcphub",
|
||||
"display_name": "MCP Hub",
|
||||
"description": "Unified MCP server for managing WordPress, WooCommerce, Gitea, n8n, Supabase, OpenPanel, Appwrite, and Directus with 567 tools, multi-site support, and OAuth 2.1 authentication.",
|
||||
"repository": "https://github.com/airano-ir/mcphub",
|
||||
"homepage": "https://mcp.palebluedot.live",
|
||||
"version": "3.6.0",
|
||||
"license": "MIT",
|
||||
"runtime": "python",
|
||||
"transport": ["stdio", "streamable-http"],
|
||||
"categories": ["developer-tools", "cms", "databases", "automation"],
|
||||
"keywords": ["mcp", "wordpress", "woocommerce", "supabase", "gitea", "n8n", "openpanel", "appwrite", "directus", "self-hosted", "multi-site"],
|
||||
"author": {
|
||||
"name": "airano-ir",
|
||||
"url": "https://github.com/airano-ir"
|
||||
},
|
||||
"capabilities": {
|
||||
"tools": true,
|
||||
"prompts": false,
|
||||
"resources": false
|
||||
},
|
||||
"relatedServers": [
|
||||
{
|
||||
"name": "WordPress MCP",
|
||||
"url": "https://glama.ai/mcp/servers/developer-in-world/wordpress-mcp",
|
||||
"description": "WordPress REST API MCP server"
|
||||
},
|
||||
{
|
||||
"name": "Supabase MCP",
|
||||
"url": "https://glama.ai/mcp/servers/supabase-community/supabase-mcp",
|
||||
"description": "Official Supabase MCP server"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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",
|
||||
]
|
||||
|
||||
5
plugins/coolify/__init__.py
Normal file
5
plugins/coolify/__init__.py
Normal 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
269
plugins/coolify/client.py
Normal 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")
|
||||
13
plugins/coolify/handlers/__init__.py
Normal file
13
plugins/coolify/handlers/__init__.py
Normal file
@@ -0,0 +1,13 @@
|
||||
"""
|
||||
Coolify Plugin Handlers
|
||||
|
||||
All tool handlers for Coolify operations.
|
||||
"""
|
||||
|
||||
from . import applications, deployments, servers
|
||||
|
||||
__all__ = [
|
||||
"applications",
|
||||
"deployments",
|
||||
"servers",
|
||||
]
|
||||
860
plugins/coolify/handlers/applications.py
Normal file
860
plugins/coolify/handlers/applications.py
Normal 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)
|
||||
162
plugins/coolify/handlers/deployments.py
Normal file
162
plugins/coolify/handlers/deployments.py
Normal 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)
|
||||
286
plugins/coolify/handlers/servers.py
Normal file
286
plugins/coolify/handlers/servers.py
Normal 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
106
plugins/coolify/plugin.py
Normal 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)}"}
|
||||
1
plugins/coolify/schemas/__init__.py
Normal file
1
plugins/coolify/schemas/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
"""Coolify Plugin Schemas"""
|
||||
55
plugins/coolify/schemas/common.py
Normal file
55
plugins/coolify/schemas/common.py
Normal 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
|
||||
@@ -295,6 +295,25 @@ def get_tool_specifications() -> list[dict[str, Any]]:
|
||||
},
|
||||
"scope": "write",
|
||||
},
|
||||
{
|
||||
"name": "delete_label",
|
||||
"method_name": "delete_label",
|
||||
"description": "Delete a label from a repository.",
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"owner": {"type": "string", "description": "Repository owner", "minLength": 1},
|
||||
"repo": {"type": "string", "description": "Repository name", "minLength": 1},
|
||||
"label_id": {
|
||||
"type": "integer",
|
||||
"description": "Label ID to delete",
|
||||
"minimum": 1,
|
||||
},
|
||||
},
|
||||
"required": ["owner", "repo", "label_id"],
|
||||
},
|
||||
"scope": "write",
|
||||
},
|
||||
# === MILESTONES ===
|
||||
{
|
||||
"name": "list_milestones",
|
||||
@@ -499,6 +518,13 @@ async def create_label(
|
||||
return json.dumps(result, indent=2)
|
||||
|
||||
|
||||
async def delete_label(client: GiteaClient, owner: str, repo: str, label_id: int) -> str:
|
||||
"""Delete a label"""
|
||||
await client.delete_label(owner, repo, label_id)
|
||||
result = {"success": True, "message": f"Label {label_id} deleted successfully"}
|
||||
return json.dumps(result, indent=2)
|
||||
|
||||
|
||||
# Milestone operations
|
||||
async def list_milestones(
|
||||
client: GiteaClient, owner: str, repo: str, state: str | None = None
|
||||
|
||||
@@ -526,12 +526,13 @@ async def merge_pull_request(
|
||||
delete_branch_after_merge: bool = False,
|
||||
) -> str:
|
||||
"""Merge a pull request"""
|
||||
data = {
|
||||
"Do": method,
|
||||
"MergeTitleField": title,
|
||||
"MergeMessageField": message,
|
||||
"delete_branch_after_merge": delete_branch_after_merge,
|
||||
}
|
||||
data: dict = {"Do": method}
|
||||
if title is not None:
|
||||
data["MergeTitleField"] = title
|
||||
if message is not None:
|
||||
data["MergeMessageField"] = message
|
||||
if delete_branch_after_merge:
|
||||
data["delete_branch_after_merge"] = True
|
||||
merge_result = await client.merge_pull_request(owner, repo, pr_number, data)
|
||||
result = {
|
||||
"success": True,
|
||||
|
||||
@@ -159,6 +159,79 @@ def get_tool_specifications() -> list[dict[str, Any]]:
|
||||
},
|
||||
"scope": "read",
|
||||
},
|
||||
{
|
||||
"name": "update_webhook",
|
||||
"method_name": "update_webhook",
|
||||
"description": "Update an existing webhook's configuration, events, or active status.",
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"owner": {"type": "string", "description": "Repository owner", "minLength": 1},
|
||||
"repo": {"type": "string", "description": "Repository name", "minLength": 1},
|
||||
"webhook_id": {
|
||||
"type": "integer",
|
||||
"description": "Webhook ID to update",
|
||||
"minimum": 1,
|
||||
},
|
||||
"url": {
|
||||
"anyOf": [{"type": "string", "format": "uri"}, {"type": "null"}],
|
||||
"description": "New webhook URL",
|
||||
},
|
||||
"events": {
|
||||
"anyOf": [
|
||||
{
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"create",
|
||||
"delete",
|
||||
"fork",
|
||||
"push",
|
||||
"issues",
|
||||
"issue_assign",
|
||||
"issue_label",
|
||||
"issue_milestone",
|
||||
"issue_comment",
|
||||
"pull_request",
|
||||
"pull_request_assign",
|
||||
"pull_request_label",
|
||||
"pull_request_milestone",
|
||||
"pull_request_comment",
|
||||
"pull_request_review_approved",
|
||||
"pull_request_review_rejected",
|
||||
"pull_request_review_comment",
|
||||
"pull_request_sync",
|
||||
"wiki",
|
||||
"repository",
|
||||
"release",
|
||||
],
|
||||
},
|
||||
},
|
||||
{"type": "null"},
|
||||
],
|
||||
"description": "Updated list of events to trigger webhook",
|
||||
},
|
||||
"content_type": {
|
||||
"anyOf": [
|
||||
{"type": "string", "enum": ["json", "form"]},
|
||||
{"type": "null"},
|
||||
],
|
||||
"description": "Content type for webhook payload",
|
||||
},
|
||||
"secret": {
|
||||
"anyOf": [{"type": "string"}, {"type": "null"}],
|
||||
"description": "Secret for webhook signature verification",
|
||||
},
|
||||
"active": {
|
||||
"anyOf": [{"type": "boolean"}, {"type": "null"}],
|
||||
"description": "Activate or deactivate webhook",
|
||||
},
|
||||
},
|
||||
"required": ["owner", "repo", "webhook_id"],
|
||||
},
|
||||
"scope": "admin",
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
@@ -220,3 +293,40 @@ async def get_webhook(client: GiteaClient, owner: str, repo: str, webhook_id: in
|
||||
webhook = await client.get_webhook(owner, repo, webhook_id)
|
||||
result = {"success": True, "webhook": webhook}
|
||||
return json.dumps(result, indent=2)
|
||||
|
||||
|
||||
async def update_webhook(
|
||||
client: GiteaClient,
|
||||
owner: str,
|
||||
repo: str,
|
||||
webhook_id: int,
|
||||
url: str | None = None,
|
||||
events: list[str] | None = None,
|
||||
content_type: str | None = None,
|
||||
secret: str | None = None,
|
||||
active: bool | None = None,
|
||||
) -> str:
|
||||
"""Update a webhook"""
|
||||
data: dict = {}
|
||||
if events is not None:
|
||||
data["events"] = events
|
||||
if active is not None:
|
||||
data["active"] = active
|
||||
|
||||
config: dict = {}
|
||||
if url is not None:
|
||||
config["url"] = url
|
||||
if content_type is not None:
|
||||
config["content_type"] = content_type
|
||||
if secret is not None:
|
||||
config["secret"] = secret
|
||||
if config:
|
||||
data["config"] = config
|
||||
|
||||
webhook = await client.update_webhook(owner, repo, webhook_id, data)
|
||||
result = {
|
||||
"success": True,
|
||||
"message": f"Webhook {webhook_id} updated successfully",
|
||||
"webhook": webhook,
|
||||
}
|
||||
return json.dumps(result, indent=2)
|
||||
|
||||
@@ -4,8 +4,8 @@ Uses REST APIs:
|
||||
- Export API (GET /export/events, /export/charts) for raw data export
|
||||
- Insights API (GET /insights/:projectId/*) for analytics queries
|
||||
|
||||
Note: project_id is optional if configured in environment variables.
|
||||
When not provided, the default project_id from OPENPANEL_SITE1_PROJECT_ID is used.
|
||||
Note: project_id is optional if configured in the site settings.
|
||||
When not provided, the default project_id from the site configuration is used.
|
||||
|
||||
Requires 'read' or 'root' mode client for Export API.
|
||||
"""
|
||||
|
||||
@@ -23,6 +23,6 @@ def get_project_id(client: OpenPanelClient, project_id: str | None) -> str:
|
||||
return client.default_project_id
|
||||
raise ValueError(
|
||||
"project_id is required. Either provide it as a parameter or configure "
|
||||
"OPENPANEL_SITE1_PROJECT_ID in environment variables. "
|
||||
"the project_id field when adding the site in the dashboard. "
|
||||
"You can find your Project ID in OpenPanel Dashboard → Project Settings."
|
||||
)
|
||||
|
||||
@@ -67,18 +67,17 @@ class WordPressClient:
|
||||
# Validate required parameters
|
||||
if not site_url:
|
||||
raise ConfigurationError(
|
||||
"Site URL is not configured. "
|
||||
"Please set the URL environment variable (e.g., WORDPRESS_SITE1_URL)."
|
||||
"Site URL is not configured. " "Please add or update the site in the dashboard."
|
||||
)
|
||||
if not username:
|
||||
raise ConfigurationError(
|
||||
"Username is not configured. "
|
||||
"Please set the USERNAME environment variable (e.g., WORDPRESS_SITE1_USERNAME)."
|
||||
"Please update the site credentials in the dashboard."
|
||||
)
|
||||
if not app_password:
|
||||
raise ConfigurationError(
|
||||
"App password is not configured. "
|
||||
"Please set the APP_PASSWORD environment variable (e.g., WORDPRESS_SITE1_APP_PASSWORD)."
|
||||
"Please update the site credentials in the dashboard."
|
||||
)
|
||||
|
||||
self.site_url = site_url.rstrip("/")
|
||||
|
||||
@@ -67,7 +67,7 @@ class WordPressAdvancedPlugin(BasePlugin):
|
||||
if not container_name:
|
||||
raise ValueError(
|
||||
"WordPress Advanced plugin requires 'container' configuration. "
|
||||
"Please set WORDPRESS_ADVANCED_SITE1_CONTAINER in environment variables."
|
||||
"Please set the 'container' field when adding the site in the dashboard."
|
||||
)
|
||||
|
||||
# Import WP-CLI manager
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[project]
|
||||
name = "mcphub-server"
|
||||
version = "3.4.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"}
|
||||
@@ -28,7 +28,7 @@ classifiers = [
|
||||
]
|
||||
|
||||
dependencies = [
|
||||
"fastmcp>=2.14.0,<3.0.0",
|
||||
"fastmcp>=3.0.0,<4.0.0",
|
||||
"httpx>=0.25.0",
|
||||
"aiohttp>=3.9.0",
|
||||
"pydantic>=2.5.0",
|
||||
@@ -69,7 +69,7 @@ requires = ["setuptools>=68.0", "wheel"]
|
||||
build-backend = "setuptools.build_meta"
|
||||
|
||||
[tool.setuptools]
|
||||
py-modules = ["server", "server_multi"]
|
||||
py-modules = ["server"]
|
||||
|
||||
[tool.setuptools.packages.find]
|
||||
include = ["core*", "plugins*"]
|
||||
@@ -208,7 +208,6 @@ unfixable = []
|
||||
"tests/*" = ["S101", "B007"]
|
||||
"tests/legacy/*" = ["E402", "B007", "F401"]
|
||||
"server.py" = ["E402", "F405", "F403", "UP045", "B023"] # Late imports + star imports + dynamic types
|
||||
"server_multi.py" = ["E402", "F405", "F403", "UP045"]
|
||||
"core/__init__.py" = ["F401", "F403"] # Re-exports
|
||||
"plugins/__init__.py" = ["F401"]
|
||||
"plugins/*/__init__.py" = ["F401", "F403", "F405"]
|
||||
|
||||
76
server.py
76
server.py
@@ -14,8 +14,9 @@ Usage:
|
||||
|
||||
Environment Variables:
|
||||
MASTER_API_KEY: Master API key for authentication
|
||||
{PLUGIN_TYPE}_{PROJECT_ID}_{CONFIG_KEY}: Project configurations
|
||||
LOG_LEVEL: Logging level (DEBUG, INFO, WARNING, ERROR)
|
||||
|
||||
Sites are managed via the web dashboard and stored in SQLite (DB-based).
|
||||
"""
|
||||
|
||||
import base64
|
||||
@@ -50,7 +51,6 @@ from core import (
|
||||
get_api_key_manager,
|
||||
get_audit_logger,
|
||||
get_auth_manager,
|
||||
get_project_manager,
|
||||
get_rate_limiter,
|
||||
# Core architecture modules
|
||||
get_site_manager,
|
||||
@@ -298,7 +298,6 @@ logger.info("Jinja2 template engine initialized")
|
||||
# Initialize managers
|
||||
auth_manager = get_auth_manager()
|
||||
api_key_manager = get_api_key_manager()
|
||||
project_manager = get_project_manager()
|
||||
audit_logger = get_audit_logger()
|
||||
csrf_manager = get_csrf_manager()
|
||||
|
||||
@@ -308,7 +307,6 @@ from plugins import registry as plugin_registry
|
||||
|
||||
site_manager = get_site_manager()
|
||||
plugin_types = plugin_registry.get_registered_types()
|
||||
site_manager.discover_sites(plugin_types)
|
||||
# Initialize tool registry (central tool management)
|
||||
tool_registry = get_tool_registry()
|
||||
|
||||
@@ -323,24 +321,8 @@ if auth_manager._is_temporary_key:
|
||||
logger.info(f"Master API Key (temporary): {_mk}")
|
||||
else:
|
||||
logger.info(f"Master API Key: {_mk[:8]}***{_mk[-4:]}")
|
||||
logger.info(f"Discovered {len(project_manager.projects)} per-site project instances (legacy)")
|
||||
logger.info(
|
||||
f"Discovered {site_manager.get_count()} unique sites across {len(plugin_types)} plugin types"
|
||||
)
|
||||
logger.info(f"Site breakdown: {site_manager.get_count_by_type()}")
|
||||
|
||||
# Log discovered projects
|
||||
for full_id, plugin in project_manager.projects.items():
|
||||
logger.info(f" - {full_id} ({plugin.get_plugin_name()})")
|
||||
|
||||
# Log discovered sites
|
||||
logger.info("\nDiscovered sites:")
|
||||
for site_info in site_manager.list_all_sites():
|
||||
alias_display = (
|
||||
f" (alias: {site_info['alias']})" if site_info["alias"] != site_info["site_id"] else ""
|
||||
)
|
||||
logger.info(f" - {site_info['full_id']}{alias_display}")
|
||||
|
||||
logger.info(f"Plugin types registered: {', '.join(plugin_types)}")
|
||||
logger.info("Sites are managed via the web dashboard (DB-based)")
|
||||
logger.info("=" * 60)
|
||||
|
||||
|
||||
@@ -1122,7 +1104,6 @@ from core import initialize_health_monitor
|
||||
|
||||
# Initialize health monitor
|
||||
health_monitor = initialize_health_monitor(
|
||||
project_manager=project_manager,
|
||||
audit_logger=audit_logger,
|
||||
site_manager=site_manager,
|
||||
metrics_retention_hours=24,
|
||||
@@ -1401,15 +1382,11 @@ async def get_project_info(project_id: str) -> str:
|
||||
import json
|
||||
|
||||
try:
|
||||
# Try legacy ProjectManager first
|
||||
info = project_manager.get_project_info(project_id)
|
||||
|
||||
# Fallback to SiteManager if legacy finds nothing
|
||||
if info is None:
|
||||
for site_data in site_manager.list_all_sites():
|
||||
if site_data["full_id"] == project_id:
|
||||
info = site_data
|
||||
break
|
||||
info = None
|
||||
for site_data in site_manager.list_all_sites():
|
||||
if site_data["full_id"] == project_id:
|
||||
info = site_data
|
||||
break
|
||||
|
||||
if info is None:
|
||||
return f"Project '{project_id}' not found. Use list_projects to see available projects."
|
||||
@@ -1908,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
|
||||
@@ -3799,12 +3794,11 @@ Use get_endpoints() to see all available MCP endpoints."""
|
||||
import json
|
||||
|
||||
try:
|
||||
info = project_manager.get_project_info(project_id)
|
||||
if info is None:
|
||||
for site_data in site_manager.list_all_sites():
|
||||
if site_data["full_id"] == project_id:
|
||||
info = site_data
|
||||
break
|
||||
info = None
|
||||
for site_data in site_manager.list_all_sites():
|
||||
if site_data["full_id"] == project_id:
|
||||
info = site_data
|
||||
break
|
||||
if info is None:
|
||||
return f"Project '{project_id}' not found. Use list_projects to see available projects."
|
||||
return json.dumps(info, indent=2)
|
||||
@@ -5032,12 +5026,6 @@ def main():
|
||||
logger.info(f"Host: {args.host}")
|
||||
logger.info(f"Port: {args.port}")
|
||||
|
||||
# Check if any sites were discovered (SiteManager is the primary discovery system)
|
||||
if site_manager.get_count() == 0:
|
||||
logger.warning("No sites discovered! Check your environment variables.")
|
||||
logger.warning("Expected format: {PLUGIN_TYPE}_{SITE_ID}_{CONFIG_KEY}=value")
|
||||
logger.warning("Example: WORDPRESS_SITE1_URL=https://example.com")
|
||||
|
||||
try:
|
||||
if args.transport == "stdio":
|
||||
# stdio doesn't support multi-endpoint
|
||||
|
||||
1153
server_multi.py
1153
server_multi.py
File diff suppressed because it is too large
Load Diff
@@ -24,7 +24,7 @@ if sys.platform == "win32":
|
||||
# Add project root to path
|
||||
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
||||
|
||||
from core import AuditLogger, ProjectManager, initialize_health_monitor
|
||||
from core import AuditLogger, initialize_health_monitor
|
||||
|
||||
|
||||
async def test_health_monitor():
|
||||
@@ -35,11 +35,9 @@ async def test_health_monitor():
|
||||
|
||||
# Initialize components
|
||||
print("\n1. Initializing components...")
|
||||
project_manager = ProjectManager()
|
||||
audit_logger = AuditLogger()
|
||||
|
||||
health_monitor = initialize_health_monitor(
|
||||
project_manager=project_manager,
|
||||
audit_logger=audit_logger,
|
||||
metrics_retention_hours=24,
|
||||
max_metrics_per_project=1000,
|
||||
|
||||
452
tests/test_coolify.py
Normal file
452
tests/test_coolify.py
Normal 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"]
|
||||
1135
tests/test_gitea_plugin.py
Normal file
1135
tests/test_gitea_plugin.py
Normal file
File diff suppressed because it is too large
Load Diff
@@ -78,11 +78,11 @@ class TestIsPluginPublic:
|
||||
os.environ.pop("ENABLED_PLUGINS", None)
|
||||
assert is_plugin_public("supabase") is True
|
||||
|
||||
def test_default_gitea_not_public(self):
|
||||
"""Gitea is not public by default."""
|
||||
def test_default_gitea_public(self):
|
||||
"""Gitea is public by default (since F.16)."""
|
||||
with patch.dict(os.environ, {}, clear=False):
|
||||
os.environ.pop("ENABLED_PLUGINS", None)
|
||||
assert is_plugin_public("gitea") is False
|
||||
assert is_plugin_public("gitea") is True
|
||||
|
||||
def test_default_wordpress_advanced_not_public(self):
|
||||
"""WordPress Advanced is not public by default."""
|
||||
@@ -118,8 +118,9 @@ class TestSiteApiIntegration:
|
||||
assert "wordpress" in fields
|
||||
assert "woocommerce" in fields
|
||||
assert "supabase" in fields
|
||||
assert "gitea" in fields
|
||||
assert "openpanel" in fields
|
||||
assert "wordpress_advanced" not in fields
|
||||
assert "gitea" not in fields
|
||||
assert "n8n" not in fields
|
||||
|
||||
def test_get_user_plugin_names_filtered(self):
|
||||
@@ -133,8 +134,9 @@ class TestSiteApiIntegration:
|
||||
assert "wordpress" in names
|
||||
assert "woocommerce" in names
|
||||
assert "supabase" in names
|
||||
assert "gitea" in names
|
||||
assert "openpanel" in names
|
||||
assert "wordpress_advanced" not in names
|
||||
assert "gitea" not in names
|
||||
|
||||
def test_custom_env_changes_fields(self):
|
||||
"""Custom ENABLED_PLUGINS changes which credential fields are returned."""
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
"""Tests for Site Manager (core/site_manager.py)."""
|
||||
"""Tests for Site Manager (core/site_manager.py) — registration and lookup."""
|
||||
|
||||
import pytest
|
||||
|
||||
@@ -127,69 +127,6 @@ class TestSiteManagerRegistration:
|
||||
assert manager.list_sites("nonexistent") == []
|
||||
|
||||
|
||||
class TestSiteManagerDiscovery:
|
||||
"""Test site discovery from environment variables."""
|
||||
|
||||
def test_discover_wordpress_sites(self, monkeypatch):
|
||||
"""Should discover sites from WORDPRESS_SITE1_* env vars."""
|
||||
monkeypatch.setenv("WORDPRESS_SITE1_URL", "https://wp1.example.com")
|
||||
monkeypatch.setenv("WORDPRESS_SITE1_USERNAME", "admin")
|
||||
monkeypatch.setenv("WORDPRESS_SITE1_APP_PASSWORD", "xxxx")
|
||||
monkeypatch.setenv("WORDPRESS_SITE1_ALIAS", "myblog")
|
||||
|
||||
manager = SiteManager()
|
||||
count = manager.discover_sites(["wordpress"])
|
||||
|
||||
assert count == 1
|
||||
config = manager.get_site_config("wordpress", "site1")
|
||||
assert config.url == "https://wp1.example.com"
|
||||
assert config.username == "admin"
|
||||
assert config.alias == "myblog"
|
||||
|
||||
def test_discover_multiple_sites(self, monkeypatch):
|
||||
"""Should discover multiple sites for same plugin type."""
|
||||
monkeypatch.setenv("WORDPRESS_SITE1_URL", "https://wp1.example.com")
|
||||
monkeypatch.setenv("WORDPRESS_SITE1_USERNAME", "admin")
|
||||
monkeypatch.setenv("WORDPRESS_SITE2_URL", "https://wp2.example.com")
|
||||
monkeypatch.setenv("WORDPRESS_SITE2_USERNAME", "editor")
|
||||
|
||||
manager = SiteManager()
|
||||
count = manager.discover_sites(["wordpress"])
|
||||
|
||||
assert count == 2
|
||||
|
||||
def test_discover_across_plugin_types(self, monkeypatch):
|
||||
"""Should discover sites across different plugin types."""
|
||||
monkeypatch.setenv("WORDPRESS_SITE1_URL", "https://wp.example.com")
|
||||
monkeypatch.setenv("WORDPRESS_SITE1_USERNAME", "admin")
|
||||
monkeypatch.setenv("GITEA_SITE1_URL", "https://git.example.com")
|
||||
monkeypatch.setenv("GITEA_SITE1_TOKEN", "tok_123")
|
||||
|
||||
manager = SiteManager()
|
||||
count = manager.discover_sites(["wordpress", "gitea"])
|
||||
|
||||
assert count == 2
|
||||
|
||||
def test_reserved_words_skipped(self, monkeypatch):
|
||||
"""Reserved words like LIMIT, RATE should not become site IDs."""
|
||||
monkeypatch.setenv("WORDPRESS_LIMIT_PER_MINUTE", "100")
|
||||
monkeypatch.setenv("WORDPRESS_RATE_PER_HOUR", "500")
|
||||
|
||||
manager = SiteManager()
|
||||
count = manager.discover_sites(["wordpress"])
|
||||
|
||||
assert count == 0
|
||||
|
||||
def test_incomplete_config_skipped(self, monkeypatch):
|
||||
"""Sites with no config data (only prefix match) should be skipped."""
|
||||
# WORDPRESS_SITE1_ exists as prefix but no actual config keys
|
||||
# This is hard to test directly since env vars always have a value
|
||||
# Instead, test that a site with only site_id and plugin_type (no config) is skipped
|
||||
manager = SiteManager()
|
||||
count = manager.discover_sites(["wordpress"])
|
||||
assert count == 0
|
||||
|
||||
|
||||
class TestSiteManagerCounts:
|
||||
"""Test counting and listing methods."""
|
||||
|
||||
|
||||
Reference in New Issue
Block a user