Compare commits
3 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 1615daf1a7 | |||
| c47ec7fd28 | |||
| 3815f5a6d5 |
13
.env.example
13
.env.example
@@ -2,16 +2,19 @@
|
|||||||
# MCP Hub - Environment Configuration
|
# MCP Hub - Environment Configuration
|
||||||
# ==============================================================================
|
# ==============================================================================
|
||||||
#
|
#
|
||||||
# Version: 3.0.0
|
# Version: 3.4.0
|
||||||
# Last Updated: 2026-02-17
|
# 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.
|
# 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!
|
# SECURITY NOTE: Never commit .env file to version control!
|
||||||
#
|
#
|
||||||
# Multi-Endpoint Architecture (v3.0.0):
|
# 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)
|
# - /system/mcp → System (17 tools, Master Key)
|
||||||
# - /wordpress/mcp → WordPress Core (65 tools)
|
# - /wordpress/mcp → WordPress Core (65 tools)
|
||||||
# - /woocommerce/mcp → WooCommerce (28 tools)
|
# - /woocommerce/mcp → WooCommerce (28 tools)
|
||||||
@@ -19,7 +22,7 @@
|
|||||||
# - /gitea/mcp → Gitea (56 tools)
|
# - /gitea/mcp → Gitea (56 tools)
|
||||||
# - /n8n/mcp → n8n Automation (56 tools)
|
# - /n8n/mcp → n8n Automation (56 tools)
|
||||||
# - /supabase/mcp → Supabase Self-Hosted (70 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)
|
# - /appwrite/mcp → Appwrite Backend (100 tools)
|
||||||
# - /directus/mcp → Directus CMS (100 tools)
|
# - /directus/mcp → Directus CMS (100 tools)
|
||||||
# - /project/{alias}/mcp → Project-specific (site-locked tools)
|
# - /project/{alias}/mcp → Project-specific (site-locked tools)
|
||||||
|
|||||||
19
CHANGELOG.md
19
CHANGELOG.md
@@ -5,6 +5,25 @@ 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/),
|
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).
|
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||||
|
|
||||||
|
## [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
|
## [3.4.0] — 2026-03-31
|
||||||
|
|
||||||
### OpenPanel Plugin — Public Release (Track F.10)
|
### OpenPanel Plugin — Public Release (Track F.10)
|
||||||
|
|||||||
35
CLAUDE.md
35
CLAUDE.md
@@ -69,7 +69,6 @@ All configured in `pyproject.toml`:
|
|||||||
|
|
||||||
```
|
```
|
||||||
├── server.py # Primary entry point
|
├── server.py # Primary entry point
|
||||||
├── server_multi.py # Alternative multi-endpoint server
|
|
||||||
├── core/ # Layer 1: Core system modules
|
├── core/ # Layer 1: Core system modules
|
||||||
├── plugins/ # Layer 2: Plugin system (9 plugins)
|
├── plugins/ # Layer 2: Plugin system (9 plugins)
|
||||||
├── core/templates/ # Jinja2 templates (dashboard + OAuth)
|
├── core/templates/ # Jinja2 templates (dashboard + OAuth)
|
||||||
@@ -93,8 +92,6 @@ Layer 3: API & Web UI (server.py + core/dashboard/) — FastMCP server, Starle
|
|||||||
### Entry Points
|
### Entry Points
|
||||||
|
|
||||||
- **`server.py`** (~3500 lines) — Primary entry point. Handles FastMCP server, Starlette routes, middleware, plugins.
|
- **`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
|
### Multi-Endpoint Architecture
|
||||||
|
|
||||||
```
|
```
|
||||||
@@ -125,26 +122,17 @@ plugins/{name}/
|
|||||||
|
|
||||||
### Tool Generation
|
### Tool Generation
|
||||||
|
|
||||||
Tools are dynamically generated at startup:
|
Tools are dynamically generated at startup from plugin specifications:
|
||||||
1. `SiteManager` discovers sites from env vars (`{PLUGIN_TYPE}_{SITE_ID}_{CONFIG_KEY}`)
|
1. `ToolGenerator` creates unified tools with a `site` parameter injected
|
||||||
2. `ToolGenerator` creates unified tools with a `site` parameter injected
|
2. Tools are registered in `ToolRegistry` and exposed via FastMCP
|
||||||
3. 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.
|
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}`
|
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`.
|
||||||
```bash
|
`SiteManager` (`core/site_manager.py`) provides registration and lookup infrastructure for tool generation.
|
||||||
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.
|
|
||||||
|
|
||||||
### Key Core Modules
|
### 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/auth.py` | Master API key validation, request authentication |
|
||||||
| `core/api_keys.py` | Per-project API keys with scopes (read/write/admin) |
|
| `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_registry.py` | Central tool definitions and lookup |
|
||||||
| `core/tool_generator.py` | Dynamic unified tool creation with site injection |
|
| `core/tool_generator.py` | Dynamic unified tool creation with site injection |
|
||||||
| `core/health.py` | Health monitoring, metrics, alerts |
|
| `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.
|
**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.
|
**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
|
## Commit Style
|
||||||
|
|
||||||
```
|
```
|
||||||
@@ -206,12 +190,11 @@ v4 development cycle with 15 phases. See `docs/ROADMAP.md` (Track F) and `.claud
|
|||||||
## Gotchas
|
## Gotchas
|
||||||
|
|
||||||
- Test files exist in both `tests/` (proper) and root directory (legacy `test_*.py`). Run `pytest tests/` for organized tests only.
|
- 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
|
- `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
|
- `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.
|
- 4 plugins are tested for public use: WordPress, WooCommerce, Supabase, OpenPanel. Others are admin-only or disabled.
|
||||||
- OAuth Clients (Client ID/Secret) are for MCP endpoint auth, NOT the same as GitHub/Google dashboard login
|
- 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`)
|
- 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)
|
- `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)
|
- The `scripts/` directory has platform-specific setup scripts: `setup.sh` (Linux/Mac), `setup.ps1` (Windows)
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
**The AI-native management hub for WordPress, WooCommerce, and self-hosted services.**
|
**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.
|
> **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
|
```bash
|
||||||
# Recommended (auto-generates temp key if omitted — check container logs)
|
# Recommended (auto-generates temp key if omitted — check container logs)
|
||||||
MASTER_API_KEY=your-secure-key-here
|
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
|
### 2. Run the container
|
||||||
|
|
||||||
```bash
|
```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 |
|
| `/u/{user_id}/{alias}/mcp` | 22-100 | No (pre-scoped) | Hosted/OAuth users |
|
||||||
| `/project/{alias}/mcp` | 22-100 | No (pre-scoped) | Single-site workflow |
|
| `/project/{alias}/mcp` | 22-100 | No (pre-scoped) | Single-site workflow |
|
||||||
| `/{plugin}/mcp` | 23-101 | Yes | Multi-site management |
|
| `/{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`
|
Available plugin endpoints: `/wordpress/mcp`, `/woocommerce/mcp`, `/wordpress-advanced/mcp`, `/gitea/mcp`, `/n8n/mcp`, `/supabase/mcp`, `/openpanel/mcp`, `/appwrite/mcp`, `/directus/mcp`, `/system/mcp`
|
||||||
|
|
||||||
|
|||||||
103
README.md
103
README.md
@@ -14,7 +14,7 @@ Connect your sites, stores, repos, and databases — manage them all through Cla
|
|||||||
[](https://pypi.org/project/mcphub-server/)
|
[](https://pypi.org/project/mcphub-server/)
|
||||||
[](https://hub.docker.com/r/airano/mcphub)
|
[](https://hub.docker.com/r/airano/mcphub)
|
||||||
[]()
|
[]()
|
||||||
[]()
|
[]()
|
||||||
[](https://github.com/airano-ir/mcphub/actions/workflows/ci.yml)
|
[](https://github.com/airano-ir/mcphub/actions/workflows/ci.yml)
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
@@ -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 |
|
| Plugin | Tools | What You Can Do |
|
||||||
|--------|-------|-----------------|
|
|--------|-------|-----------------|
|
||||||
@@ -59,11 +59,11 @@ MCP Hub is the first MCP server that lets you manage WordPress, WooCommerce, and
|
|||||||
| **Gitea** | 56 | Repos, issues, pull requests, releases, webhooks, organizations |
|
| **Gitea** | 56 | Repos, issues, pull requests, releases, webhooks, organizations |
|
||||||
| **n8n** | 56 | Workflows, executions, credentials, variables, audit |
|
| **n8n** | 56 | Workflows, executions, credentials, variables, audit |
|
||||||
| **Supabase** | 70 | Database, auth, storage, edge functions, realtime |
|
| **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 |
|
| **Appwrite** | 100 | Databases, auth, storage, functions, teams, messaging |
|
||||||
| **Directus** | 100 | Collections, items, users, files, flows, permissions |
|
| **Directus** | 100 | Collections, items, users, files, flows, permissions |
|
||||||
| **System** | 24 | Health monitoring, API keys, OAuth management, audit |
|
| **System** | 24 | Health monitoring, API keys, OAuth management, audit |
|
||||||
| **Total** | **596** | Constant count — scales to unlimited sites |
|
| **Total** | **565** | Constant count — scales to unlimited sites |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -75,14 +75,14 @@ MCP Hub is the first MCP server that lets you manage WordPress, WooCommerce, and
|
|||||||
git clone https://github.com/airano-ir/mcphub.git
|
git clone https://github.com/airano-ir/mcphub.git
|
||||||
cd mcphub
|
cd mcphub
|
||||||
cp env.example .env
|
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
|
docker compose up -d
|
||||||
```
|
```
|
||||||
|
|
||||||
### Option 2: Docker Hub (No Clone)
|
### Option 2: Docker Hub (No Clone)
|
||||||
|
|
||||||
```bash
|
```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
|
docker run -d --name mcphub -p 8000:8000 --env-file .env airano/mcphub:latest
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -93,7 +93,7 @@ git clone https://github.com/airano-ir/mcphub.git
|
|||||||
cd mcphub
|
cd mcphub
|
||||||
pip install -e .
|
pip install -e .
|
||||||
cp env.example .env
|
cp env.example .env
|
||||||
# Edit .env with your site credentials
|
# Edit .env — set MASTER_API_KEY
|
||||||
python server.py --transport streamable-http --port 8000
|
python server.py --transport streamable-http --port 8000
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -125,28 +125,15 @@ Your personal MCP endpoint: `https://mcp.example.com/u/{your-user-id}/{alias}/mc
|
|||||||
|
|
||||||
### Configure Your Sites
|
### 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
|
```bash
|
||||||
# Master API Key (recommended — auto-generates temp key if omitted)
|
# .env — only system configuration needed
|
||||||
MASTER_API_KEY=your-secure-key-here
|
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>
|
<details>
|
||||||
@@ -158,45 +145,25 @@ GITEA_REPO1_ALIAS=mygitea
|
|||||||
|----------|----------|---------|-------------|
|
|----------|----------|---------|-------------|
|
||||||
| `MASTER_API_KEY` | Recommended | Auto-generated | Master API key for admin access |
|
| `MASTER_API_KEY` | Recommended | Auto-generated | Master API key for admin access |
|
||||||
| `LOG_LEVEL` | No | `INFO` | Logging level (DEBUG, INFO, WARNING, ERROR) |
|
| `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_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_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.
|
> **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 |
|
| Plugin | Required Credentials | Notes |
|
||||||
|--------|--------------|---------------|
|
|--------|---------------------|-------|
|
||||||
| `WORDPRESS` | `URL`, `USERNAME`, `APP_PASSWORD` | `ALIAS`, `CONTAINER` |
|
| WordPress | URL, Username, App Password | [How to create App Password](https://wordpress.org/documentation/article/application-passwords/) |
|
||||||
| `WOOCOMMERCE` | `URL`, `CONSUMER_KEY`, `CONSUMER_SECRET` | `ALIAS` |
|
| WooCommerce | URL, Consumer Key, Consumer Secret | WooCommerce → Settings → Advanced → REST API |
|
||||||
| `WORDPRESS_ADVANCED` | `URL`, `USERNAME`, `APP_PASSWORD`, `CONTAINER` | `ALIAS` |
|
| WordPress Advanced | URL, Username, App Password, Container | Container = Docker container name (for WP-CLI) |
|
||||||
| `GITEA` | `URL`, `TOKEN` | `ALIAS` |
|
| Gitea | URL, Token | Settings → Applications → Personal Access Token |
|
||||||
| `N8N` | `URL`, `API_KEY` | `ALIAS` |
|
| n8n | URL, API Key | Settings → API → Create API Key |
|
||||||
| `SUPABASE` | `URL`, `SERVICE_ROLE_KEY` | `ALIAS` |
|
| Supabase | URL, Service Role Key | Supabase Dashboard → Settings → API |
|
||||||
| `OPENPANEL` | `URL`, `CLIENT_ID`, `CLIENT_SECRET` | `ALIAS` |
|
| OpenPanel | URL, Client ID, Client Secret | OpenPanel Dashboard → Project Settings |
|
||||||
| `APPWRITE` | `URL`, `API_KEY`, `PROJECT_ID` | `ALIAS` |
|
| Appwrite | URL, API Key, Project ID | Appwrite Console → Settings → API Keys |
|
||||||
| `DIRECTUS` | `URL`, `TOKEN` | `ALIAS` |
|
| Directus | URL, Static Token | Directus Admin → Settings |
|
||||||
|
|
||||||
> **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
|
|
||||||
```
|
|
||||||
|
|
||||||
</details>
|
</details>
|
||||||
|
|
||||||
@@ -298,7 +265,7 @@ MCP Hub supports **Open Dynamic Client Registration** (RFC 7591). ChatGPT can au
|
|||||||
## Architecture
|
## Architecture
|
||||||
|
|
||||||
```
|
```
|
||||||
/mcp → Admin endpoint (all 596 tools)
|
/mcp → Admin endpoint (all 565 tools)
|
||||||
/system/mcp → System tools only (24 tools)
|
/system/mcp → System tools only (24 tools)
|
||||||
/wordpress/mcp → WordPress tools (67 tools)
|
/wordpress/mcp → WordPress tools (67 tools)
|
||||||
/woocommerce/mcp → WooCommerce tools (28 tools)
|
/woocommerce/mcp → WooCommerce tools (28 tools)
|
||||||
@@ -306,21 +273,21 @@ MCP Hub supports **Open Dynamic Client Registration** (RFC 7591). ChatGPT can au
|
|||||||
/gitea/mcp → Gitea tools (56 tools)
|
/gitea/mcp → Gitea tools (56 tools)
|
||||||
/n8n/mcp → n8n tools (56 tools)
|
/n8n/mcp → n8n tools (56 tools)
|
||||||
/supabase/mcp → Supabase tools (70 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)
|
/appwrite/mcp → Appwrite tools (100 tools)
|
||||||
/directus/mcp → Directus tools (100 tools)
|
/directus/mcp → Directus tools (100 tools)
|
||||||
/project/{alias}/mcp → Per-project endpoint (auto-injects site)
|
/project/{alias}/mcp → Per-project endpoint (auto-injects site)
|
||||||
/u/{user_id}/{alias}/mcp → Per-user endpoint (hosted/OAuth users)
|
/u/{user_id}/{alias}/mcp → Per-user endpoint (hosted/OAuth users)
|
||||||
```
|
```
|
||||||
|
|
||||||
**Recommendation**: Use plugin-specific endpoints instead of `/mcp` (596 tools) to minimize token usage.
|
**Recommendation**: Use plugin-specific endpoints instead of `/mcp` (565 tools) to minimize token usage.
|
||||||
|
|
||||||
| Endpoint | Use Case | Tools |
|
| Endpoint | Use Case | Tools |
|
||||||
|----------|----------|------:|
|
|----------|----------|------:|
|
||||||
| `/u/{user_id}/{alias}/mcp` | Hosted users (OAuth login) | 22-100 |
|
| `/u/{user_id}/{alias}/mcp` | Hosted users (OAuth login) | 22-100 |
|
||||||
| `/project/{alias}/mcp` | Single-site workflow (recommended) | 22-100 |
|
| `/project/{alias}/mcp` | Single-site workflow (recommended) | 22-100 |
|
||||||
| `/{plugin}/mcp` | Multi-site management | 23-101 |
|
| `/{plugin}/mcp` | Multi-site management | 23-101 |
|
||||||
| `/mcp` | Admin & discovery only | 596 |
|
| `/mcp` | Admin & discovery only | 565 |
|
||||||
|
|
||||||
### Security
|
### Security
|
||||||
|
|
||||||
@@ -339,21 +306,19 @@ Some MCP Hub tools require companion WordPress plugins:
|
|||||||
| Tools | Requirement |
|
| 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 |
|
| 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 |
|
| WP-CLI tools (15 tools: `wp_cache_*`, `wp_db_*`, etc.) | Docker socket + `CONTAINER` config |
|
||||||
| WordPress Advanced database/system tools | Docker socket + `CONTAINER` env var |
|
| 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)) |
|
| 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:
|
**Docker socket** is needed for WP-CLI and WordPress Advanced system tools. Add to your docker-compose:
|
||||||
|
|
||||||
```yaml
|
```yaml
|
||||||
volumes:
|
volumes:
|
||||||
- /var/run/docker.sock:/var/run/docker.sock:ro
|
- /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,
|
initialize_health_monitor,
|
||||||
)
|
)
|
||||||
|
|
||||||
# Project and Site Management
|
|
||||||
from core.project_manager import ProjectManager, get_project_manager
|
|
||||||
|
|
||||||
# Rate Limiting
|
# Rate Limiting
|
||||||
from core.rate_limiter import RateLimitConfig, RateLimiter, get_rate_limiter
|
from core.rate_limiter import RateLimitConfig, RateLimiter, get_rate_limiter
|
||||||
from core.site_manager import SiteConfig, SiteManager, get_site_manager
|
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
|
from core.tool_generator import ToolGenerator
|
||||||
|
|
||||||
# Tool Management (Option B architecture)
|
# Tool Management (Option B architecture)
|
||||||
@@ -56,9 +51,7 @@ __all__ = [
|
|||||||
"get_auth_manager",
|
"get_auth_manager",
|
||||||
"APIKeyManager",
|
"APIKeyManager",
|
||||||
"get_api_key_manager",
|
"get_api_key_manager",
|
||||||
# Project/Site Management
|
# Site Management
|
||||||
"ProjectManager",
|
|
||||||
"get_project_manager",
|
|
||||||
"SiteManager",
|
"SiteManager",
|
||||||
"SiteConfig",
|
"SiteConfig",
|
||||||
"get_site_manager",
|
"get_site_manager",
|
||||||
|
|||||||
@@ -850,12 +850,7 @@ async def get_all_projects(
|
|||||||
is_admin = False
|
is_admin = False
|
||||||
current_user_id = None
|
current_user_id = None
|
||||||
if user_session:
|
if user_session:
|
||||||
if (
|
if hasattr(user_session, "user_type") and user_session.user_type == "master" or isinstance(user_session, dict) and user_session.get("role") == "admin":
|
||||||
hasattr(user_session, "user_type")
|
|
||||||
and user_session.user_type == "master"
|
|
||||||
or isinstance(user_session, dict)
|
|
||||||
and user_session.get("role") == "admin"
|
|
||||||
):
|
|
||||||
is_admin = True
|
is_admin = True
|
||||||
elif isinstance(user_session, dict) and "user_id" in user_session:
|
elif isinstance(user_session, dict) and "user_id" in user_session:
|
||||||
current_user_id = user_session["user_id"]
|
current_user_id = user_session["user_id"]
|
||||||
@@ -1512,6 +1507,7 @@ async def get_oauth_clients_data() -> dict:
|
|||||||
"grant_types": client.grant_types,
|
"grant_types": client.grant_types,
|
||||||
"allowed_scopes": client.allowed_scopes,
|
"allowed_scopes": client.allowed_scopes,
|
||||||
"created_at": client.created_at.isoformat() if client.created_at else "",
|
"created_at": client.created_at.isoformat() if client.created_at else "",
|
||||||
|
"owner_user_id": getattr(client, "owner_user_id", None),
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -1528,10 +1524,26 @@ async def get_oauth_clients_data() -> dict:
|
|||||||
|
|
||||||
|
|
||||||
async def dashboard_oauth_clients_list(request: Request) -> Response:
|
async def dashboard_oauth_clients_list(request: Request) -> Response:
|
||||||
"""Render OAuth clients list page (admin only)."""
|
"""Render OAuth clients list page (admin and user)."""
|
||||||
session, redirect = _require_admin_session(request)
|
# Try admin session first, then user session
|
||||||
if redirect:
|
auth = get_dashboard_auth()
|
||||||
return redirect
|
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
|
# Get language
|
||||||
accept_language = request.headers.get("accept-language")
|
accept_language = request.headers.get("accept-language")
|
||||||
@@ -1539,8 +1551,13 @@ async def dashboard_oauth_clients_list(request: Request) -> Response:
|
|||||||
lang = detect_language(accept_language, query_lang)
|
lang = detect_language(accept_language, query_lang)
|
||||||
t = get_translations(lang)
|
t = get_translations(lang)
|
||||||
|
|
||||||
# Get clients data
|
# Get clients data — admin sees all, user sees own
|
||||||
clients_data = await get_oauth_clients_data()
|
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(
|
return templates.TemplateResponse(
|
||||||
request,
|
request,
|
||||||
@@ -1552,15 +1569,26 @@ async def dashboard_oauth_clients_list(request: Request) -> Response:
|
|||||||
"clients": clients_data["clients"],
|
"clients": clients_data["clients"],
|
||||||
"total_count": clients_data["total_count"],
|
"total_count": clients_data["total_count"],
|
||||||
"current_page": "oauth_clients",
|
"current_page": "oauth_clients",
|
||||||
|
"is_admin": is_admin,
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
async def dashboard_oauth_clients_create(request: Request) -> Response:
|
async def dashboard_oauth_clients_create(request: Request) -> Response:
|
||||||
"""API endpoint to create OAuth client (admin only)."""
|
"""API endpoint to create OAuth client (admin and user)."""
|
||||||
session, redirect = _require_admin_session(request)
|
# Accept both admin and user sessions
|
||||||
if redirect:
|
auth = get_dashboard_auth()
|
||||||
return JSONResponse({"error": "Admin access required"}, status_code=403)
|
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:
|
try:
|
||||||
data = await request.json()
|
data = await request.json()
|
||||||
@@ -1569,7 +1597,7 @@ async def dashboard_oauth_clients_create(request: Request) -> Response:
|
|||||||
redirect_uris = data.get("redirect_uris") or []
|
redirect_uris = data.get("redirect_uris") or []
|
||||||
if not redirect_uris and data.get("redirect_uri"):
|
if not redirect_uris and data.get("redirect_uri"):
|
||||||
redirect_uris = [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:
|
if not client_name or not redirect_uris:
|
||||||
return JSONResponse({"error": "Missing required fields"}, status_code=400)
|
return JSONResponse({"error": "Missing required fields"}, status_code=400)
|
||||||
@@ -1578,16 +1606,23 @@ async def dashboard_oauth_clients_create(request: Request) -> Response:
|
|||||||
|
|
||||||
client_registry = get_client_registry()
|
client_registry = get_client_registry()
|
||||||
|
|
||||||
client_id, client_secret = client_registry.create_client(
|
create_kwargs = {
|
||||||
client_name=client_name, redirect_uris=redirect_uris, allowed_scopes=scopes
|
"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
|
# Log the action
|
||||||
from core.audit_log import get_audit_logger
|
from core.audit_log import get_audit_logger
|
||||||
|
|
||||||
audit_logger = get_audit_logger()
|
audit_logger = get_audit_logger()
|
||||||
audit_logger.log_system_event(
|
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(
|
return JSONResponse(
|
||||||
@@ -1604,10 +1639,22 @@ async def dashboard_oauth_clients_create(request: Request) -> Response:
|
|||||||
|
|
||||||
|
|
||||||
async def dashboard_oauth_clients_delete(request: Request) -> Response:
|
async def dashboard_oauth_clients_delete(request: Request) -> Response:
|
||||||
"""API endpoint to delete OAuth client (admin only)."""
|
"""API endpoint to delete OAuth client (admin and user)."""
|
||||||
session, redirect = _require_admin_session(request)
|
# Accept both admin and user sessions
|
||||||
if redirect:
|
auth = get_dashboard_auth()
|
||||||
return JSONResponse({"error": "Admin access required"}, status_code=403)
|
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:
|
try:
|
||||||
client_id = request.path_params.get("client_id", "")
|
client_id = request.path_params.get("client_id", "")
|
||||||
@@ -1616,6 +1663,14 @@ async def dashboard_oauth_clients_delete(request: Request) -> Response:
|
|||||||
|
|
||||||
client_registry = get_client_registry()
|
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)
|
success = client_registry.delete_client(client_id)
|
||||||
|
|
||||||
if success:
|
if success:
|
||||||
@@ -3131,107 +3186,18 @@ async def api_get_config(request: Request) -> Response:
|
|||||||
|
|
||||||
|
|
||||||
async def dashboard_user_oauth_clients_list(request: Request) -> Response:
|
async def dashboard_user_oauth_clients_list(request: Request) -> Response:
|
||||||
"""GET /dashboard/connect/oauth-clients — OAuth user's own OAuth clients."""
|
"""GET /dashboard/connect/oauth-clients — Redirect to unified OAuth clients page."""
|
||||||
user_session, redirect = _require_user_session(request)
|
return RedirectResponse(url="/dashboard/oauth-clients", status_code=303)
|
||||||
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",
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
async def dashboard_user_oauth_clients_create(request: Request) -> Response:
|
async def dashboard_user_oauth_clients_create(request: Request) -> Response:
|
||||||
"""POST /api/dashboard/user-oauth-clients/create — Create OAuth client for OAuth user."""
|
"""POST /api/dashboard/user-oauth-clients/create — Forwards to unified create endpoint."""
|
||||||
user_session, redirect = _require_user_session(request)
|
return await dashboard_oauth_clients_create(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,
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
async def dashboard_user_oauth_clients_delete(request: Request) -> Response:
|
async def dashboard_user_oauth_clients_delete(request: Request) -> Response:
|
||||||
"""DELETE /api/dashboard/user-oauth-clients/{client_id} — Delete user's own OAuth client."""
|
"""DELETE /api/dashboard/user-oauth-clients/{client_id} — Forwards to unified delete endpoint."""
|
||||||
user_session, redirect = _require_user_session(request)
|
return await dashboard_oauth_clients_delete(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})
|
|
||||||
|
|
||||||
|
|
||||||
async def get_service_page_data(plugin_type: str) -> dict | None:
|
async def get_service_page_data(plugin_type: str) -> dict | None:
|
||||||
|
|||||||
@@ -48,6 +48,7 @@ class MCPEndpointFactory:
|
|||||||
self.tool_registry = tool_registry
|
self.tool_registry = tool_registry
|
||||||
self.middleware_classes = middleware_classes or []
|
self.middleware_classes = middleware_classes or []
|
||||||
self.endpoints: dict[str, FastMCP] = {}
|
self.endpoints: dict[str, FastMCP] = {}
|
||||||
|
self._tool_counts: dict[str, int] = {}
|
||||||
self._tool_handlers: dict[str, Callable] = {}
|
self._tool_handlers: dict[str, Callable] = {}
|
||||||
|
|
||||||
def register_tool_handler(self, tool_name: str, handler: Callable):
|
def register_tool_handler(self, tool_name: str, handler: Callable):
|
||||||
@@ -92,8 +93,9 @@ class MCPEndpointFactory:
|
|||||||
for middleware in custom_middleware:
|
for middleware in custom_middleware:
|
||||||
mcp.add_middleware(middleware)
|
mcp.add_middleware(middleware)
|
||||||
|
|
||||||
# Store endpoint
|
# Store endpoint and tool count
|
||||||
self.endpoints[config.path] = mcp
|
self.endpoints[config.path] = mcp
|
||||||
|
self._tool_counts[config.path] = len(tools)
|
||||||
|
|
||||||
logger.info(f" - Endpoint {config.path} created successfully")
|
logger.info(f" - Endpoint {config.path} created successfully")
|
||||||
|
|
||||||
@@ -282,9 +284,7 @@ class MCPEndpointFactory:
|
|||||||
"""
|
"""
|
||||||
info = []
|
info = []
|
||||||
for path, mcp in self.endpoints.items():
|
for path, mcp in self.endpoints.items():
|
||||||
# Get tool count
|
tool_count = self._tool_counts.get(path, 0)
|
||||||
# Note: This requires accessing FastMCP internals
|
|
||||||
tool_count = len(mcp._tool_manager._tools) if hasattr(mcp, "_tool_manager") else 0
|
|
||||||
|
|
||||||
info.append(
|
info.append(
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -149,11 +149,7 @@ class EndpointRegistry:
|
|||||||
tool_count = 0
|
tool_count = 0
|
||||||
|
|
||||||
if mcp:
|
if mcp:
|
||||||
# Try to get tool count from FastMCP
|
tool_count = self.factory._tool_counts.get(path, 0)
|
||||||
try:
|
|
||||||
tool_count = len(mcp._tool_manager._tools)
|
|
||||||
except AttributeError:
|
|
||||||
pass
|
|
||||||
|
|
||||||
endpoints.append(
|
endpoints.append(
|
||||||
EndpointInfo(
|
EndpointInfo(
|
||||||
|
|||||||
@@ -23,7 +23,6 @@ from pathlib import Path
|
|||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
from core.audit_log import AuditLogger
|
from core.audit_log import AuditLogger
|
||||||
from core.project_manager import ProjectManager
|
|
||||||
from core.site_manager import SiteManager
|
from core.site_manager import SiteManager
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
@@ -130,7 +129,6 @@ class HealthMonitor:
|
|||||||
|
|
||||||
def __init__(
|
def __init__(
|
||||||
self,
|
self,
|
||||||
project_manager: ProjectManager,
|
|
||||||
audit_logger: AuditLogger | None = None,
|
audit_logger: AuditLogger | None = None,
|
||||||
metrics_retention_hours: int = 24,
|
metrics_retention_hours: int = 24,
|
||||||
max_metrics_per_project: int = 1000,
|
max_metrics_per_project: int = 1000,
|
||||||
@@ -140,13 +138,11 @@ class HealthMonitor:
|
|||||||
Initialize health monitor.
|
Initialize health monitor.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
project_manager: Project manager instance
|
|
||||||
audit_logger: Optional audit logger for logging health events
|
audit_logger: Optional audit logger for logging health events
|
||||||
metrics_retention_hours: Hours to retain historical metrics
|
metrics_retention_hours: Hours to retain historical metrics
|
||||||
max_metrics_per_project: Maximum metrics to store per project
|
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.site_manager = site_manager
|
||||||
self.audit_logger = audit_logger
|
self.audit_logger = audit_logger
|
||||||
self.metrics_retention_hours = metrics_retention_hours
|
self.metrics_retention_hours = metrics_retention_hours
|
||||||
@@ -410,15 +406,7 @@ class HealthMonitor:
|
|||||||
start_time = time.time()
|
start_time = time.time()
|
||||||
|
|
||||||
try:
|
try:
|
||||||
# Get plugin instance from ProjectManager
|
if self.site_manager:
|
||||||
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
|
|
||||||
health_result = await self._site_manager_health_check(project_id)
|
health_result = await self._site_manager_health_check(project_id)
|
||||||
else:
|
else:
|
||||||
return ProjectHealthStatus(
|
return ProjectHealthStatus(
|
||||||
@@ -524,7 +512,7 @@ class HealthMonitor:
|
|||||||
|
|
||||||
async def _site_manager_health_check(self, project_id: str) -> dict[str, Any]:
|
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,
|
Creates a temporary plugin instance and calls its health_check() method,
|
||||||
falling back to a basic HTTP check if plugin instantiation fails.
|
falling back to a basic HTTP check if plugin instantiation fails.
|
||||||
@@ -650,8 +638,8 @@ class HealthMonitor:
|
|||||||
"""
|
"""
|
||||||
health_statuses = {}
|
health_statuses = {}
|
||||||
|
|
||||||
# Collect all known project/site IDs from both sources
|
# Collect all known site IDs from SiteManager
|
||||||
all_project_ids = set(self.project_manager.projects.keys())
|
all_project_ids = set()
|
||||||
if self.site_manager:
|
if self.site_manager:
|
||||||
for site_info in self.site_manager.list_all_sites():
|
for site_info in self.site_manager.list_all_sites():
|
||||||
all_project_ids.add(site_info["full_id"])
|
all_project_ids.add(site_info["full_id"])
|
||||||
@@ -860,7 +848,6 @@ def get_health_monitor() -> HealthMonitor | None:
|
|||||||
|
|
||||||
|
|
||||||
def initialize_health_monitor(
|
def initialize_health_monitor(
|
||||||
project_manager: ProjectManager,
|
|
||||||
audit_logger: AuditLogger | None = None,
|
audit_logger: AuditLogger | None = None,
|
||||||
site_manager: SiteManager | None = None,
|
site_manager: SiteManager | None = None,
|
||||||
**kwargs,
|
**kwargs,
|
||||||
@@ -869,16 +856,13 @@ def initialize_health_monitor(
|
|||||||
Initialize the global health monitor.
|
Initialize the global health monitor.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
project_manager: Project manager instance
|
|
||||||
audit_logger: Optional audit logger
|
audit_logger: Optional audit logger
|
||||||
site_manager: Optional SiteManager for comprehensive site discovery
|
site_manager: SiteManager for site discovery
|
||||||
**kwargs: Additional configuration options
|
**kwargs: Additional configuration options
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
HealthMonitor instance
|
HealthMonitor instance
|
||||||
"""
|
"""
|
||||||
global _health_monitor
|
global _health_monitor
|
||||||
_health_monitor = HealthMonitor(
|
_health_monitor = HealthMonitor(audit_logger=audit_logger, site_manager=site_manager, **kwargs)
|
||||||
project_manager, audit_logger, site_manager=site_manager, **kwargs
|
|
||||||
)
|
|
||||||
return _health_monitor
|
return _health_monitor
|
||||||
|
|||||||
@@ -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
|
|
||||||
@@ -4,20 +4,12 @@ Site Manager - Type-safe site configuration management
|
|||||||
Manages site configurations with Pydantic validation.
|
Manages site configurations with Pydantic validation.
|
||||||
Part of Option B clean architecture refactoring.
|
Part of Option B clean architecture refactoring.
|
||||||
|
|
||||||
Discovers sites from environment variables:
|
Sites are managed via the web dashboard and stored in SQLite (DB-based).
|
||||||
- {PLUGIN_TYPE}_{SITE_ID}_{CONFIG_KEY}
|
The SiteManager provides registration and lookup infrastructure for
|
||||||
- {PLUGIN_TYPE}_{SITE_ID}_ALIAS (optional)
|
plugin tool generation and endpoint routing.
|
||||||
|
|
||||||
Example:
|
|
||||||
WORDPRESS_SITE1_URL=https://example.com
|
|
||||||
WORDPRESS_SITE1_USERNAME=admin
|
|
||||||
WORDPRESS_SITE1_APP_PASSWORD=xxxx
|
|
||||||
WORDPRESS_SITE2_ALIAS=myblog
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
import os
|
|
||||||
import re
|
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
from pydantic import BaseModel, ConfigDict, Field, ValidationInfo, field_validator
|
from pydantic import BaseModel, ConfigDict, Field, ValidationInfo, field_validator
|
||||||
@@ -111,7 +103,8 @@ class SiteManager:
|
|||||||
"""
|
"""
|
||||||
Manage site configurations with type safety.
|
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:
|
Attributes:
|
||||||
sites: Dictionary mapping plugin_type to site configurations
|
sites: Dictionary mapping plugin_type to site configurations
|
||||||
@@ -120,7 +113,7 @@ class SiteManager:
|
|||||||
|
|
||||||
Examples:
|
Examples:
|
||||||
>>> manager = SiteManager()
|
>>> manager = SiteManager()
|
||||||
>>> manager.discover_sites(['wordpress', 'gitea'])
|
>>> manager.register_site(config)
|
||||||
>>> config = manager.get_site_config('wordpress', 'myblog')
|
>>> config = manager.get_site_config('wordpress', 'myblog')
|
||||||
>>> sites = manager.list_sites('wordpress')
|
>>> sites = manager.list_sites('wordpress')
|
||||||
"""
|
"""
|
||||||
@@ -136,167 +129,6 @@ class SiteManager:
|
|||||||
self.logger = logging.getLogger("SiteManager")
|
self.logger = logging.getLogger("SiteManager")
|
||||||
self.logger.info("SiteManager initialized")
|
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:
|
def register_site(self, config: SiteConfig) -> None:
|
||||||
"""
|
"""
|
||||||
Register a site configuration.
|
Register a site configuration.
|
||||||
@@ -356,7 +188,7 @@ class SiteManager:
|
|||||||
# SECURITY: Don't reveal available plugin types in multi-tenant environment
|
# SECURITY: Don't reveal available plugin types in multi-tenant environment
|
||||||
raise ValueError(
|
raise ValueError(
|
||||||
f"No sites configured for plugin type: {plugin_type}. "
|
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
|
# Try direct lookup
|
||||||
@@ -373,7 +205,7 @@ class SiteManager:
|
|||||||
)
|
)
|
||||||
raise ValueError(
|
raise ValueError(
|
||||||
f"Site '{site}' not configured for {plugin_type}. "
|
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]:
|
def list_sites(self, plugin_type: str) -> list[str]:
|
||||||
@@ -545,7 +377,7 @@ def get_site_manager() -> SiteManager:
|
|||||||
|
|
||||||
Examples:
|
Examples:
|
||||||
>>> manager = get_site_manager()
|
>>> manager = get_site_manager()
|
||||||
>>> manager.discover_sites(['wordpress'])
|
>>> manager.register_site(config)
|
||||||
"""
|
"""
|
||||||
global _site_manager
|
global _site_manager
|
||||||
if _site_manager is None:
|
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'),
|
('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
|
('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'),
|
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 ── #}
|
{# ── Admin-only nav items ── #}
|
||||||
@@ -146,9 +149,6 @@
|
|||||||
'/dashboard/projects'),
|
'/dashboard/projects'),
|
||||||
('api_keys', t.api_keys, 'M15 7a2 2 0 012 2m4 0a6 6 0 01-7.743 5.743L11 17H9v2H7v2H4a1 1 0
|
('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'),
|
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
|
('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',
|
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'),
|
'/dashboard/audit-logs'),
|
||||||
|
|||||||
@@ -159,10 +159,10 @@
|
|||||||
<p class="text-sm text-green-700 dark:text-green-400">
|
<p class="text-sm text-green-700 dark:text-green-400">
|
||||||
{% if lang == 'fa' %}
|
{% if lang == 'fa' %}
|
||||||
<strong>نکته:</strong> فقط آدرس URL کافی است. هنگام اتصال میتوانید با <strong>API Key</strong> یا <strong>GitHub/Google</strong> احراز هویت کنید.
|
<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 %}
|
{% 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>.
|
<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 %}
|
{% endif %}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -228,6 +228,14 @@
|
|||||||
<p class="text-gray-500 text-sm mt-2">
|
<p class="text-gray-500 text-sm mt-2">
|
||||||
{% if lang == 'fa' %}فیلترها را پاک کنید{% else %}Try clearing the filters{% endif %}
|
{% if lang == 'fa' %}فیلترها را پاک کنید{% else %}Try clearing the filters{% endif %}
|
||||||
</p>
|
</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 %}
|
{% endif %}
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
|
|||||||
@@ -50,15 +50,9 @@ services:
|
|||||||
- USER_RATE_LIMIT_PER_MIN=${USER_RATE_LIMIT_PER_MIN:-30}
|
- USER_RATE_LIMIT_PER_MIN=${USER_RATE_LIMIT_PER_MIN:-30}
|
||||||
- USER_RATE_LIMIT_PER_HR=${USER_RATE_LIMIT_PER_HR:-500}
|
- USER_RATE_LIMIT_PER_HR=${USER_RATE_LIMIT_PER_HR:-500}
|
||||||
|
|
||||||
# === ADMIN-MANAGED SITES (optional) ===
|
# === SITES ===
|
||||||
# Add WordPress/Gitea/n8n/etc. sites here if you want admin-level access.
|
# Sites are managed via the web dashboard (DB-based).
|
||||||
# Users can add their own sites via the dashboard without these.
|
# After deployment, open the dashboard to add sites.
|
||||||
# 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:-}
|
|
||||||
|
|
||||||
healthcheck:
|
healthcheck:
|
||||||
test: ["CMD", "wget", "-qO-", "http://localhost:8000/health"]
|
test: ["CMD", "wget", "-qO-", "http://localhost:8000/health"]
|
||||||
|
|||||||
@@ -248,12 +248,11 @@ Without Docker socket:
|
|||||||
- WordPress Advanced database/system tools are unavailable
|
- WordPress Advanced database/system tools are unavailable
|
||||||
- All REST API tools (bulk operations, content management) work normally
|
- 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`)
|
Each plugin requires specific credentials (see below for reference).
|
||||||
- Multiple sites: change `SITE1` to `SITE2`, `SITE3`, etc.
|
|
||||||
|
|
||||||
#### WordPress
|
#### WordPress
|
||||||
```env
|
```env
|
||||||
@@ -358,7 +357,7 @@ After starting (via Docker or locally), wait ~30 seconds for the server to initi
|
|||||||
|
|
||||||
```bash
|
```bash
|
||||||
curl http://localhost:8000/health
|
curl http://localhost:8000/health
|
||||||
# Expected: {"status": "ok", "tools_loaded": 596, ...}
|
# Expected: {"status": "ok", "tools_loaded": 565, ...}
|
||||||
```
|
```
|
||||||
|
|
||||||
**2. Open the web dashboard:**
|
**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"` |
|
| 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 |
|
| 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` |
|
| 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
|
## Using MCP Tools
|
||||||
|
|
||||||
### 596 Tools Across 9 Plugins
|
### 565 Tools Across 9 Plugins
|
||||||
|
|
||||||
| Plugin | Tools | Env Prefix |
|
| Plugin | Tools | Env Prefix |
|
||||||
|--------|-------|------------|
|
|--------|-------|------------|
|
||||||
@@ -492,7 +491,7 @@ MCP Hub supports **Open Dynamic Client Registration** (RFC 7591). ChatGPT can au
|
|||||||
| Gitea | 56 | `GITEA_` |
|
| Gitea | 56 | `GITEA_` |
|
||||||
| n8n | 56 | `N8N_` |
|
| n8n | 56 | `N8N_` |
|
||||||
| Supabase | 70 | `SUPABASE_` |
|
| Supabase | 70 | `SUPABASE_` |
|
||||||
| OpenPanel | 73 | `OPENPANEL_` |
|
| OpenPanel | 42 | `OPENPANEL_` |
|
||||||
| Appwrite | 100 | `APPWRITE_` |
|
| Appwrite | 100 | `APPWRITE_` |
|
||||||
| Directus | 100 | `DIRECTUS_` |
|
| Directus | 100 | `DIRECTUS_` |
|
||||||
| System | 24 | (no config needed) |
|
| 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) |
|
| `/project/{alias}/mcp` | Single-site workflow | 22-100 | No (pre-scoped) |
|
||||||
| `/{plugin}/mcp` | Multi-site management | 23-101 | Yes |
|
| `/{plugin}/mcp` | Multi-site management | 23-101 | Yes |
|
||||||
| `/system/mcp` | System administration | 24 | N/A |
|
| `/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:**
|
**Available plugin endpoints:**
|
||||||
|
|
||||||
@@ -533,7 +532,7 @@ Use the most specific endpoint for your use case to minimize token usage:
|
|||||||
| `/gitea/mcp` | Gitea | 56 |
|
| `/gitea/mcp` | Gitea | 56 |
|
||||||
| `/n8n/mcp` | n8n | 56 |
|
| `/n8n/mcp` | n8n | 56 |
|
||||||
| `/supabase/mcp` | Supabase | 70 |
|
| `/supabase/mcp` | Supabase | 70 |
|
||||||
| `/openpanel/mcp` | OpenPanel | 73 |
|
| `/openpanel/mcp` | OpenPanel | 42 |
|
||||||
| `/appwrite/mcp` | Appwrite | 100 |
|
| `/appwrite/mcp` | Appwrite | 100 |
|
||||||
| `/directus/mcp` | Directus | 100 |
|
| `/directus/mcp` | Directus | 100 |
|
||||||
| `/system/mcp` | System Management | 24 |
|
| `/system/mcp` | System Management | 24 |
|
||||||
@@ -610,18 +609,16 @@ docker compose up --build -d
|
|||||||
|
|
||||||
### Step 3: Configure Environment Variables
|
### 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
|
MASTER_API_KEY=your-secure-key-here
|
||||||
OAUTH_JWT_SECRET_KEY=your-jwt-secret
|
OAUTH_JWT_SECRET_KEY=your-jwt-secret
|
||||||
OAUTH_BASE_URL=https://your-domain.com
|
OAUTH_BASE_URL=https://your-domain.com
|
||||||
WORDPRESS_SITE1_URL=https://example.com
|
ENCRYPTION_KEY=your-base64-encryption-key
|
||||||
WORDPRESS_SITE1_USERNAME=admin
|
|
||||||
WORDPRESS_SITE1_APP_PASSWORD=xxxx xxxx xxxx xxxx
|
|
||||||
```
|
```
|
||||||
|
|
||||||
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
|
### Step 4: Configure Health Check
|
||||||
|
|
||||||
@@ -641,7 +638,7 @@ The server auto-discovers all `WORDPRESS_*`, `WOOCOMMERCE_*`, `GITEA_*`, and oth
|
|||||||
|
|
||||||
## Next Steps
|
## 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
|
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
|
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
|
4. **Monitor health**: Use `check_all_projects_health` tool or visit the web dashboard
|
||||||
|
|||||||
117
env.example
117
env.example
@@ -2,7 +2,7 @@
|
|||||||
# MCP Hub — Environment Configuration
|
# 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
|
# Full docs: https://github.com/airano-ir/mcphub/blob/main/docs/getting-started.md
|
||||||
#
|
#
|
||||||
# After editing, run:
|
# After editing, run:
|
||||||
@@ -49,117 +49,14 @@ DASHBOARD_SESSION_SECRET=
|
|||||||
# DISABLE_MASTER_KEY_LOGIN=false
|
# DISABLE_MASTER_KEY_LOGIN=false
|
||||||
|
|
||||||
# ============================================
|
# ============================================
|
||||||
# WORDPRESS SITES
|
# SITES
|
||||||
# ============================================
|
# ============================================
|
||||||
# Pattern: WORDPRESS_{SITE_ID}_{CONFIG_KEY}
|
# Sites are managed via the web dashboard (DB-based).
|
||||||
# Add as many sites as you want (SITE1, SITE2, SITE3, ...)
|
# After starting the server, open the dashboard to add sites:
|
||||||
|
# http://localhost:8000/dashboard
|
||||||
#
|
#
|
||||||
# How to create a WordPress Application Password:
|
# Supported plugins: WordPress, WooCommerce, Supabase, OpenPanel,
|
||||||
# 1. Log into WordPress admin → Users → Your Profile
|
# Gitea, n8n, Appwrite, Directus, WordPress Advanced
|
||||||
# 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
|
|
||||||
|
|
||||||
# ============================================
|
# ============================================
|
||||||
# OAUTH (optional — for ChatGPT/Claude auto-registration)
|
# OAUTH (optional — for ChatGPT/Claude auto-registration)
|
||||||
|
|||||||
@@ -4,8 +4,8 @@ Uses REST APIs:
|
|||||||
- Export API (GET /export/events, /export/charts) for raw data export
|
- Export API (GET /export/events, /export/charts) for raw data export
|
||||||
- Insights API (GET /insights/:projectId/*) for analytics queries
|
- Insights API (GET /insights/:projectId/*) for analytics queries
|
||||||
|
|
||||||
Note: project_id is optional if configured in environment variables.
|
Note: project_id is optional if configured in the site settings.
|
||||||
When not provided, the default project_id from OPENPANEL_SITE1_PROJECT_ID is used.
|
When not provided, the default project_id from the site configuration is used.
|
||||||
|
|
||||||
Requires 'read' or 'root' mode client for Export API.
|
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
|
return client.default_project_id
|
||||||
raise ValueError(
|
raise ValueError(
|
||||||
"project_id is required. Either provide it as a parameter or configure "
|
"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."
|
"You can find your Project ID in OpenPanel Dashboard → Project Settings."
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -67,18 +67,17 @@ class WordPressClient:
|
|||||||
# Validate required parameters
|
# Validate required parameters
|
||||||
if not site_url:
|
if not site_url:
|
||||||
raise ConfigurationError(
|
raise ConfigurationError(
|
||||||
"Site URL is not configured. "
|
"Site URL is not configured. " "Please add or update the site in the dashboard."
|
||||||
"Please set the URL environment variable (e.g., WORDPRESS_SITE1_URL)."
|
|
||||||
)
|
)
|
||||||
if not username:
|
if not username:
|
||||||
raise ConfigurationError(
|
raise ConfigurationError(
|
||||||
"Username is not configured. "
|
"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:
|
if not app_password:
|
||||||
raise ConfigurationError(
|
raise ConfigurationError(
|
||||||
"App password is not configured. "
|
"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("/")
|
self.site_url = site_url.rstrip("/")
|
||||||
|
|||||||
@@ -67,7 +67,7 @@ class WordPressAdvancedPlugin(BasePlugin):
|
|||||||
if not container_name:
|
if not container_name:
|
||||||
raise ValueError(
|
raise ValueError(
|
||||||
"WordPress Advanced plugin requires 'container' configuration. "
|
"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
|
# Import WP-CLI manager
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
[project]
|
[project]
|
||||||
name = "mcphub-server"
|
name = "mcphub-server"
|
||||||
version = "3.4.0"
|
version = "3.5.0"
|
||||||
description = "AI-native management hub for WordPress, WooCommerce, and self-hosted services via Model Context Protocol (MCP)"
|
description = "AI-native management hub for WordPress, WooCommerce, and self-hosted services via Model Context Protocol (MCP)"
|
||||||
authors = [
|
authors = [
|
||||||
{name = "MCP Hub", email = "contact@mcphub.dev"}
|
{name = "MCP Hub", email = "contact@mcphub.dev"}
|
||||||
@@ -28,7 +28,7 @@ classifiers = [
|
|||||||
]
|
]
|
||||||
|
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"fastmcp>=2.14.0,<3.0.0",
|
"fastmcp>=3.0.0,<4.0.0",
|
||||||
"httpx>=0.25.0",
|
"httpx>=0.25.0",
|
||||||
"aiohttp>=3.9.0",
|
"aiohttp>=3.9.0",
|
||||||
"pydantic>=2.5.0",
|
"pydantic>=2.5.0",
|
||||||
@@ -69,7 +69,7 @@ requires = ["setuptools>=68.0", "wheel"]
|
|||||||
build-backend = "setuptools.build_meta"
|
build-backend = "setuptools.build_meta"
|
||||||
|
|
||||||
[tool.setuptools]
|
[tool.setuptools]
|
||||||
py-modules = ["server", "server_multi"]
|
py-modules = ["server"]
|
||||||
|
|
||||||
[tool.setuptools.packages.find]
|
[tool.setuptools.packages.find]
|
||||||
include = ["core*", "plugins*"]
|
include = ["core*", "plugins*"]
|
||||||
@@ -208,7 +208,6 @@ unfixable = []
|
|||||||
"tests/*" = ["S101", "B007"]
|
"tests/*" = ["S101", "B007"]
|
||||||
"tests/legacy/*" = ["E402", "B007", "F401"]
|
"tests/legacy/*" = ["E402", "B007", "F401"]
|
||||||
"server.py" = ["E402", "F405", "F403", "UP045", "B023"] # Late imports + star imports + dynamic types
|
"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
|
"core/__init__.py" = ["F401", "F403"] # Re-exports
|
||||||
"plugins/__init__.py" = ["F401"]
|
"plugins/__init__.py" = ["F401"]
|
||||||
"plugins/*/__init__.py" = ["F401", "F403", "F405"]
|
"plugins/*/__init__.py" = ["F401", "F403", "F405"]
|
||||||
|
|||||||
42
server.py
42
server.py
@@ -14,8 +14,9 @@ Usage:
|
|||||||
|
|
||||||
Environment Variables:
|
Environment Variables:
|
||||||
MASTER_API_KEY: Master API key for authentication
|
MASTER_API_KEY: Master API key for authentication
|
||||||
{PLUGIN_TYPE}_{PROJECT_ID}_{CONFIG_KEY}: Project configurations
|
|
||||||
LOG_LEVEL: Logging level (DEBUG, INFO, WARNING, ERROR)
|
LOG_LEVEL: Logging level (DEBUG, INFO, WARNING, ERROR)
|
||||||
|
|
||||||
|
Sites are managed via the web dashboard and stored in SQLite (DB-based).
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import base64
|
import base64
|
||||||
@@ -50,7 +51,6 @@ from core import (
|
|||||||
get_api_key_manager,
|
get_api_key_manager,
|
||||||
get_audit_logger,
|
get_audit_logger,
|
||||||
get_auth_manager,
|
get_auth_manager,
|
||||||
get_project_manager,
|
|
||||||
get_rate_limiter,
|
get_rate_limiter,
|
||||||
# Core architecture modules
|
# Core architecture modules
|
||||||
get_site_manager,
|
get_site_manager,
|
||||||
@@ -298,7 +298,6 @@ logger.info("Jinja2 template engine initialized")
|
|||||||
# Initialize managers
|
# Initialize managers
|
||||||
auth_manager = get_auth_manager()
|
auth_manager = get_auth_manager()
|
||||||
api_key_manager = get_api_key_manager()
|
api_key_manager = get_api_key_manager()
|
||||||
project_manager = get_project_manager()
|
|
||||||
audit_logger = get_audit_logger()
|
audit_logger = get_audit_logger()
|
||||||
csrf_manager = get_csrf_manager()
|
csrf_manager = get_csrf_manager()
|
||||||
|
|
||||||
@@ -308,7 +307,6 @@ from plugins import registry as plugin_registry
|
|||||||
|
|
||||||
site_manager = get_site_manager()
|
site_manager = get_site_manager()
|
||||||
plugin_types = plugin_registry.get_registered_types()
|
plugin_types = plugin_registry.get_registered_types()
|
||||||
site_manager.discover_sites(plugin_types)
|
|
||||||
# Initialize tool registry (central tool management)
|
# Initialize tool registry (central tool management)
|
||||||
tool_registry = get_tool_registry()
|
tool_registry = get_tool_registry()
|
||||||
|
|
||||||
@@ -323,24 +321,8 @@ if auth_manager._is_temporary_key:
|
|||||||
logger.info(f"Master API Key (temporary): {_mk}")
|
logger.info(f"Master API Key (temporary): {_mk}")
|
||||||
else:
|
else:
|
||||||
logger.info(f"Master API Key: {_mk[:8]}***{_mk[-4:]}")
|
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"Plugin types registered: {', '.join(plugin_types)}")
|
||||||
logger.info(
|
logger.info("Sites are managed via the web dashboard (DB-based)")
|
||||||
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("=" * 60)
|
logger.info("=" * 60)
|
||||||
|
|
||||||
|
|
||||||
@@ -1122,7 +1104,6 @@ from core import initialize_health_monitor
|
|||||||
|
|
||||||
# Initialize health monitor
|
# Initialize health monitor
|
||||||
health_monitor = initialize_health_monitor(
|
health_monitor = initialize_health_monitor(
|
||||||
project_manager=project_manager,
|
|
||||||
audit_logger=audit_logger,
|
audit_logger=audit_logger,
|
||||||
site_manager=site_manager,
|
site_manager=site_manager,
|
||||||
metrics_retention_hours=24,
|
metrics_retention_hours=24,
|
||||||
@@ -1401,11 +1382,7 @@ async def get_project_info(project_id: str) -> str:
|
|||||||
import json
|
import json
|
||||||
|
|
||||||
try:
|
try:
|
||||||
# Try legacy ProjectManager first
|
info = None
|
||||||
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():
|
for site_data in site_manager.list_all_sites():
|
||||||
if site_data["full_id"] == project_id:
|
if site_data["full_id"] == project_id:
|
||||||
info = site_data
|
info = site_data
|
||||||
@@ -3799,8 +3776,7 @@ Use get_endpoints() to see all available MCP endpoints."""
|
|||||||
import json
|
import json
|
||||||
|
|
||||||
try:
|
try:
|
||||||
info = project_manager.get_project_info(project_id)
|
info = None
|
||||||
if info is None:
|
|
||||||
for site_data in site_manager.list_all_sites():
|
for site_data in site_manager.list_all_sites():
|
||||||
if site_data["full_id"] == project_id:
|
if site_data["full_id"] == project_id:
|
||||||
info = site_data
|
info = site_data
|
||||||
@@ -5032,12 +5008,6 @@ def main():
|
|||||||
logger.info(f"Host: {args.host}")
|
logger.info(f"Host: {args.host}")
|
||||||
logger.info(f"Port: {args.port}")
|
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:
|
try:
|
||||||
if args.transport == "stdio":
|
if args.transport == "stdio":
|
||||||
# stdio doesn't support multi-endpoint
|
# 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
|
# Add project root to path
|
||||||
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
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():
|
async def test_health_monitor():
|
||||||
@@ -35,11 +35,9 @@ async def test_health_monitor():
|
|||||||
|
|
||||||
# Initialize components
|
# Initialize components
|
||||||
print("\n1. Initializing components...")
|
print("\n1. Initializing components...")
|
||||||
project_manager = ProjectManager()
|
|
||||||
audit_logger = AuditLogger()
|
audit_logger = AuditLogger()
|
||||||
|
|
||||||
health_monitor = initialize_health_monitor(
|
health_monitor = initialize_health_monitor(
|
||||||
project_manager=project_manager,
|
|
||||||
audit_logger=audit_logger,
|
audit_logger=audit_logger,
|
||||||
metrics_retention_hours=24,
|
metrics_retention_hours=24,
|
||||||
max_metrics_per_project=1000,
|
max_metrics_per_project=1000,
|
||||||
|
|||||||
@@ -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
|
import pytest
|
||||||
|
|
||||||
@@ -127,69 +127,6 @@ class TestSiteManagerRegistration:
|
|||||||
assert manager.list_sites("nonexistent") == []
|
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:
|
class TestSiteManagerCounts:
|
||||||
"""Test counting and listing methods."""
|
"""Test counting and listing methods."""
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user