3 Commits

Author SHA1 Message Date
1615daf1a7 chore(F.15): FastMCP 3.x upgrade + legacy cleanup — v3.5.0
Some checks failed
Release / Test before release (push) Has been cancelled
Release / Publish to PyPI (push) Has been cancelled
Release / Publish to Docker Hub (push) Has been cancelled
Release / Create GitHub Release (push) Has been cancelled
- Upgrade fastmcp from >=2.14.0,<3.0.0 to >=3.0.0,<4.0.0
- Remove server_multi.py (legacy multi-endpoint server)
- Remove core/project_manager.py (legacy project manager)
- Refactor HealthMonitor to use SiteManager exclusively
- Replace _tool_manager._tools with tracked _tool_counts dict
- Clean core/__init__.py exports
- Update CHANGELOG, CLAUDE.md, tests
- Auto-format fixes (black + ruff)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-02 03:29:38 +02:00
c47ec7fd28 docs: fix tool counts (596→565), update live URL, clean Docker README
- Fix tool count: 596→565 (OpenPanel reduced from 73→42 in v3.4.0)
- Update DOCKER_README: dashboard-first site configuration
- Update .env.example version to 3.4.0

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-02 00:38:46 +02:00
3815f5a6d5 chore(F.4e): remove env-based site loading, unify OAuth Clients, update docs
- Remove env-based site discovery from SiteManager and ProjectManager
- Unify admin and user OAuth Clients into single role-based page
- Update all documentation to reflect DB-based site management
- Clean up env.example, README, ARCHITECTURE docs
- Improve Projects page empty state with link to My Sites
- Remove obsolete env discovery tests

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-02 00:24:33 +02:00
27 changed files with 244 additions and 2120 deletions

View File

@@ -2,16 +2,19 @@
# MCP Hub - Environment Configuration
# ==============================================================================
#
# Version: 3.0.0
# Last Updated: 2026-02-17
# Version: 3.4.0
# Last Updated: 2026-04-02
#
# This file contains all environment variables needed to run the MCP server.
# This file contains environment variables needed to run the MCP server.
# Copy this file to .env and fill in your actual values.
#
# SITES: Sites are managed via the web dashboard (DB-based), not env vars.
# The plugin sections below are kept as credential reference only.
#
# SECURITY NOTE: Never commit .env file to version control!
#
# Multi-Endpoint Architecture (v3.0.0):
# - /mcp → Admin (all 587 tools, Master API Key required)
# - /mcp → Admin (all 565 tools, Master API Key required)
# - /system/mcp → System (17 tools, Master Key)
# - /wordpress/mcp → WordPress Core (65 tools)
# - /woocommerce/mcp → WooCommerce (28 tools)
@@ -19,7 +22,7 @@
# - /gitea/mcp → Gitea (56 tools)
# - /n8n/mcp → n8n Automation (56 tools)
# - /supabase/mcp → Supabase Self-Hosted (70 tools)
# - /openpanel/mcp → OpenPanel Analytics (73 tools)
# - /openpanel/mcp → OpenPanel Analytics (42 tools)
# - /appwrite/mcp → Appwrite Backend (100 tools)
# - /directus/mcp → Directus CMS (100 tools)
# - /project/{alias}/mcp → Project-specific (site-locked tools)

View File

@@ -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/),
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
### OpenPanel Plugin — Public Release (Track F.10)

View File

@@ -69,7 +69,6 @@ All configured in `pyproject.toml`:
```
├── server.py # Primary entry point
├── server_multi.py # Alternative multi-endpoint server
├── core/ # Layer 1: Core system modules
├── plugins/ # Layer 2: Plugin system (9 plugins)
├── core/templates/ # Jinja2 templates (dashboard + OAuth)
@@ -93,8 +92,6 @@ Layer 3: API & Web UI (server.py + core/dashboard/) — FastMCP server, Starle
### Entry Points
- **`server.py`** (~3500 lines) — Primary entry point. Handles FastMCP server, Starlette routes, middleware, plugins.
- **`server_multi.py`** — Alternative multi-endpoint server (legacy, predates unified `server.py` endpoints).
### Multi-Endpoint Architecture
```
@@ -125,26 +122,17 @@ plugins/{name}/
### Tool Generation
Tools are dynamically generated at startup:
1. `SiteManager` discovers sites from env vars (`{PLUGIN_TYPE}_{SITE_ID}_{CONFIG_KEY}`)
2. `ToolGenerator` creates unified tools with a `site` parameter injected
3. Tools are registered in `ToolRegistry` and exposed via FastMCP
Tools are dynamically generated at startup from plugin specifications:
1. `ToolGenerator` creates unified tools with a `site` parameter injected
2. Tools are registered in `ToolRegistry` and exposed via FastMCP
Unified tool pattern: `wordpress_create_post(site="myblog", title="Hello")` — the `site` parameter accepts either a site_id or alias.
### Site Configuration via Environment Variables
### Site Configuration
Pattern: `{PLUGIN_TYPE}_{SITE_ID}_{CONFIG_KEY}`
```bash
WORDPRESS_SITE1_URL=https://example.com
WORDPRESS_SITE1_USERNAME=admin
WORDPRESS_SITE1_APP_PASSWORD=xxxx xxxx xxxx xxxx
WORDPRESS_SITE1_ALIAS=myblog # optional friendly name
WORDPRESS_SITE1_CONTAINER=wp-docker # optional, for WP-CLI
```
Parsed by `core/site_manager.py` into `SiteConfig` (Pydantic model). Sites are auto-discovered on startup.
Sites are managed via the web dashboard and stored in SQLite (DB-based).
User sites are encrypted with AES-256-GCM and accessed via `core/site_api.py` + `core/database.py`.
`SiteManager` (`core/site_manager.py`) provides registration and lookup infrastructure for tool generation.
### Key Core Modules
@@ -152,7 +140,7 @@ Parsed by `core/site_manager.py` into `SiteConfig` (Pydantic model). Sites are a
|--------|---------|
| `core/auth.py` | Master API key validation, request authentication |
| `core/api_keys.py` | Per-project API keys with scopes (read/write/admin) |
| `core/site_manager.py` | Type-safe site config discovery from env vars |
| `core/site_manager.py` | Type-safe site config registration and lookup |
| `core/tool_registry.py` | Central tool definitions and lookup |
| `core/tool_generator.py` | Dynamic unified tool creation with site injection |
| `core/health.py` | Health monitoring, metrics, alerts |
@@ -180,10 +168,6 @@ Web UI at the server root, built with Starlette + Jinja2 + HTMX + Tailwind CSS.
**Admin pages**: Login, Home, Projects, API Keys, OAuth Clients, Audit Logs, Health, Settings.
**User pages**: Login (OAuth), Home, My Sites (list/add/edit/test/delete), Connect (API keys + config snippets + OAuth clients), Profile.
### Legacy Modules (Deprecated)
`core/project_manager.py`, `core/site_registry.py`, `core/unified_tools.py` — kept for backward compatibility. New code should use `SiteManager`, `ToolRegistry`, and `ToolGenerator` instead.
## Commit Style
```
@@ -206,12 +190,11 @@ v4 development cycle with 15 phases. See `docs/ROADMAP.md` (Track F) and `.claud
## Gotchas
- Test files exist in both `tests/` (proper) and root directory (legacy `test_*.py`). Run `pytest tests/` for organized tests only.
- `server_multi.py` is the alternative multi-endpoint entry point; `server.py` is the primary
- `wordpress-plugin/` contains companion WP plugins (openpanel, airano-mcp-seo-bridge) — these are PHP, not Python
- `env.example` has "FUTURE" labels for Supabase/Gitea but both are fully implemented
- 4 plugins are tested for public use: WordPress, WooCommerce, Supabase, OpenPanel. Others are admin-only or disabled.
- OAuth Clients (Client ID/Secret) are for MCP endpoint auth, NOT the same as GitHub/Google dashboard login
- User sites stored in SQLite (`core/database.py`), admin sites still from env vars
- All sites stored in SQLite (`core/database.py`), managed via web dashboard
- Dashboard templates live in `core/templates/` (included in pip package as `package_data`)
- `ruff` config uses top-level `select` key in pyproject.toml (not `[tool.ruff.lint]` nested format)
- The `scripts/` directory has platform-specific setup scripts: `setup.sh` (Linux/Mac), `setup.ps1` (Windows)

View File

@@ -2,7 +2,7 @@
**The AI-native management hub for WordPress, WooCommerce, and self-hosted services.**
596 tools across 9 plugins. Connect your sites, stores, repos, and databases — manage them all through Claude, ChatGPT, Cursor, or any MCP client.
565 tools across 9 plugins. Connect your sites, stores, repos, and databases — manage them all through Claude, ChatGPT, Cursor, or any MCP client.
> **Don't want to self-host?** Try the hosted instance at **[mcp.palebluedot.live](https://mcp.palebluedot.live)** — log in with GitHub or Google, add your sites, and connect your AI client in minutes.
@@ -13,14 +13,10 @@
```bash
# Recommended (auto-generates temp key if omitted — check container logs)
MASTER_API_KEY=your-secure-key-here
# Add at least one WordPress site
WORDPRESS_SITE1_URL=https://your-site.com
WORDPRESS_SITE1_USERNAME=admin
WORDPRESS_SITE1_APP_PASSWORD=xxxx xxxx xxxx xxxx
WORDPRESS_SITE1_ALIAS=mysite
```
After starting, open the **web dashboard** to add your sites — no env vars needed for site configuration.
### 2. Run the container
```bash
@@ -96,7 +92,7 @@ Use the most specific endpoint for your use case:
| `/u/{user_id}/{alias}/mcp` | 22-100 | No (pre-scoped) | Hosted/OAuth users |
| `/project/{alias}/mcp` | 22-100 | No (pre-scoped) | Single-site workflow |
| `/{plugin}/mcp` | 23-101 | Yes | Multi-site management |
| `/mcp` | 596 | Yes | Admin & discovery only |
| `/mcp` | 565 | Yes | Admin & discovery only |
Available plugin endpoints: `/wordpress/mcp`, `/woocommerce/mcp`, `/wordpress-advanced/mcp`, `/gitea/mcp`, `/n8n/mcp`, `/supabase/mcp`, `/openpanel/mcp`, `/appwrite/mcp`, `/directus/mcp`, `/system/mcp`

103
README.md
View File

@@ -14,7 +14,7 @@ Connect your sites, stores, repos, and databases — manage them all through Cla
[![PyPI](https://img.shields.io/pypi/v/mcphub-server.svg)](https://pypi.org/project/mcphub-server/)
[![Docker](https://img.shields.io/docker/v/airano/mcphub?label=docker)](https://hub.docker.com/r/airano/mcphub)
[![Tests: 481 passing](https://img.shields.io/badge/tests-481%20passing-brightgreen.svg)]()
[![Tools: 596](https://img.shields.io/badge/tools-596-orange.svg)]()
[![Tools: 565](https://img.shields.io/badge/tools-565-orange.svg)]()
[![CI](https://github.com/airano-ir/mcphub/actions/workflows/ci.yml/badge.svg)](https://github.com/airano-ir/mcphub/actions/workflows/ci.yml)
</div>
@@ -49,7 +49,7 @@ MCP Hub is the first MCP server that lets you manage WordPress, WooCommerce, and
---
## 596 Tools Across 9 Plugins
## 565 Tools Across 9 Plugins
| Plugin | Tools | What You Can Do |
|--------|-------|-----------------|
@@ -59,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 |
| **n8n** | 56 | Workflows, executions, credentials, variables, audit |
| **Supabase** | 70 | Database, auth, storage, edge functions, realtime |
| **OpenPanel** | 73 | Events, funnels, profiles, dashboards, projects |
| **OpenPanel** | 42 | Events, export, insights, profiles, projects, system |
| **Appwrite** | 100 | Databases, auth, storage, functions, teams, messaging |
| **Directus** | 100 | Collections, items, users, files, flows, permissions |
| **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
cd mcphub
cp env.example .env
# Edit .env — set MASTER_API_KEY and add your site credentials
# Edit .env — set MASTER_API_KEY, then add sites via the web dashboard
docker compose up -d
```
### Option 2: Docker Hub (No Clone)
```bash
# Create a .env file with your credentials (see "Configure Your Sites" below)
# Create a .env file with MASTER_API_KEY (see "Configure Your Sites" below)
docker run -d --name mcphub -p 8000:8000 --env-file .env airano/mcphub:latest
```
@@ -93,7 +93,7 @@ git clone https://github.com/airano-ir/mcphub.git
cd mcphub
pip install -e .
cp env.example .env
# Edit .env with your site credentials
# Edit .env — set MASTER_API_KEY
python server.py --transport streamable-http --port 8000
```
@@ -125,28 +125,15 @@ Your personal MCP endpoint: `https://mcp.example.com/u/{your-user-id}/{alias}/mc
### Configure Your Sites
Add site credentials to `.env`:
Sites are managed via the **web dashboard** — no environment variables needed.
1. Set `MASTER_API_KEY` in your `.env` file
2. Start the server and open the dashboard
3. Add sites with their credentials (URL, username, password/token)
```bash
# Master API Key (recommended — auto-generates temp key if omitted)
# .env — only system configuration needed
MASTER_API_KEY=your-secure-key-here
# WordPress Site
WORDPRESS_SITE1_URL=https://myblog.com
WORDPRESS_SITE1_USERNAME=admin
WORDPRESS_SITE1_APP_PASSWORD=xxxx xxxx xxxx xxxx
WORDPRESS_SITE1_ALIAS=myblog
# WooCommerce Store
WOOCOMMERCE_STORE1_URL=https://mystore.com
WOOCOMMERCE_STORE1_CONSUMER_KEY=ck_xxxxx
WOOCOMMERCE_STORE1_CONSUMER_SECRET=cs_xxxxx
WOOCOMMERCE_STORE1_ALIAS=mystore
# Gitea Instance
GITEA_REPO1_URL=https://git.example.com
GITEA_REPO1_TOKEN=your_gitea_token
GITEA_REPO1_ALIAS=mygitea
```
<details>
@@ -158,45 +145,25 @@ GITEA_REPO1_ALIAS=mygitea
|----------|----------|---------|-------------|
| `MASTER_API_KEY` | Recommended | Auto-generated | Master API key for admin access |
| `LOG_LEVEL` | No | `INFO` | Logging level (DEBUG, INFO, WARNING, ERROR) |
| `ENCRYPTION_KEY` | For Live Platform | — | AES-256-GCM key for credential encryption |
| `OAUTH_JWT_SECRET_KEY` | For OAuth | — | JWT secret for ChatGPT auto-registration (not needed for Claude/Cursor) |
| `OAUTH_BASE_URL` | For OAuth | — | Public URL of your server (not needed for Claude/Cursor) |
| `OAUTH_JWT_ALGORITHM` | No | `HS256` | JWT algorithm |
| `OAUTH_ACCESS_TOKEN_TTL` | No | `3600` | Access token TTL in seconds |
| `OAUTH_REFRESH_TOKEN_TTL` | No | `604800` | Refresh token TTL in seconds |
| `OAUTH_STORAGE_TYPE` | No | `json` | Token storage type |
| `OAUTH_STORAGE_PATH` | No | `/app/data` | Data directory path |
> **OAuth** is only needed for ChatGPT Remote MCP auto-registration. For Claude Desktop, Claude Code, Cursor, and VS Code — just use `MASTER_API_KEY` with Bearer token auth.
**Plugin Site Configuration** — Pattern: `{PLUGIN_TYPE}_{SITE_ID}_{KEY}`
**Plugin Credential Reference** — when adding sites via dashboard, you'll need:
| Plugin | Required Keys | Optional Keys |
|--------|--------------|---------------|
| `WORDPRESS` | `URL`, `USERNAME`, `APP_PASSWORD` | `ALIAS`, `CONTAINER` |
| `WOOCOMMERCE` | `URL`, `CONSUMER_KEY`, `CONSUMER_SECRET` | `ALIAS` |
| `WORDPRESS_ADVANCED` | `URL`, `USERNAME`, `APP_PASSWORD`, `CONTAINER` | `ALIAS` |
| `GITEA` | `URL`, `TOKEN` | `ALIAS` |
| `N8N` | `URL`, `API_KEY` | `ALIAS` |
| `SUPABASE` | `URL`, `SERVICE_ROLE_KEY` | `ALIAS` |
| `OPENPANEL` | `URL`, `CLIENT_ID`, `CLIENT_SECRET` | `ALIAS` |
| `APPWRITE` | `URL`, `API_KEY`, `PROJECT_ID` | `ALIAS` |
| `DIRECTUS` | `URL`, `TOKEN` | `ALIAS` |
> **CONTAINER**: Docker container name of your WordPress site. Optional for WordPress (enables WP-CLI tools like cache flush, transient management). **Required** for WordPress Advanced (all 22 tools use WP-CLI). Find your container: `docker ps --filter name=wordpress`. Also requires Docker socket mount.
**Example** — Multiple WordPress sites:
```bash
WORDPRESS_BLOG_URL=https://blog.example.com
WORDPRESS_BLOG_USERNAME=admin
WORDPRESS_BLOG_APP_PASSWORD=xxxx xxxx xxxx xxxx
WORDPRESS_BLOG_ALIAS=blog
WORDPRESS_SHOP_URL=https://shop.example.com
WORDPRESS_SHOP_USERNAME=admin
WORDPRESS_SHOP_APP_PASSWORD=yyyy yyyy yyyy yyyy
WORDPRESS_SHOP_ALIAS=shop
```
| Plugin | Required Credentials | Notes |
|--------|---------------------|-------|
| WordPress | URL, Username, App Password | [How to create App Password](https://wordpress.org/documentation/article/application-passwords/) |
| WooCommerce | URL, Consumer Key, Consumer Secret | WooCommerce → Settings → Advanced → REST API |
| WordPress Advanced | URL, Username, App Password, Container | Container = Docker container name (for WP-CLI) |
| Gitea | URL, Token | Settings → Applications → Personal Access Token |
| n8n | URL, API Key | Settings → API → Create API Key |
| Supabase | URL, Service Role Key | Supabase Dashboard → Settings → API |
| OpenPanel | URL, Client ID, Client Secret | OpenPanel Dashboard → Project Settings |
| Appwrite | URL, API Key, Project ID | Appwrite Console → Settings → API Keys |
| Directus | URL, Static Token | Directus Admin → Settings |
</details>
@@ -298,7 +265,7 @@ MCP Hub supports **Open Dynamic Client Registration** (RFC 7591). ChatGPT can au
## Architecture
```
/mcp → Admin endpoint (all 596 tools)
/mcp → Admin endpoint (all 565 tools)
/system/mcp → System tools only (24 tools)
/wordpress/mcp → WordPress tools (67 tools)
/woocommerce/mcp → WooCommerce tools (28 tools)
@@ -306,21 +273,21 @@ MCP Hub supports **Open Dynamic Client Registration** (RFC 7591). ChatGPT can au
/gitea/mcp → Gitea tools (56 tools)
/n8n/mcp → n8n tools (56 tools)
/supabase/mcp → Supabase tools (70 tools)
/openpanel/mcp → OpenPanel tools (73 tools)
/openpanel/mcp → OpenPanel tools (42 tools)
/appwrite/mcp → Appwrite tools (100 tools)
/directus/mcp → Directus tools (100 tools)
/project/{alias}/mcp → Per-project endpoint (auto-injects site)
/u/{user_id}/{alias}/mcp → Per-user endpoint (hosted/OAuth users)
```
**Recommendation**: Use plugin-specific endpoints instead of `/mcp` (596 tools) to minimize token usage.
**Recommendation**: Use plugin-specific endpoints instead of `/mcp` (565 tools) to minimize token usage.
| Endpoint | Use Case | Tools |
|----------|----------|------:|
| `/u/{user_id}/{alias}/mcp` | Hosted users (OAuth login) | 22-100 |
| `/project/{alias}/mcp` | Single-site workflow (recommended) | 22-100 |
| `/{plugin}/mcp` | Multi-site management | 23-101 |
| `/mcp` | Admin & discovery only | 596 |
| `/mcp` | Admin & discovery only | 565 |
### Security
@@ -339,21 +306,19 @@ Some MCP Hub tools require companion WordPress plugins:
| Tools | Requirement |
|-------|-------------|
| SEO tools (`wordpress_get_post_seo`, etc.) | [Airano MCP SEO Bridge](https://wordpress.org/plugins/airano-mcp-seo-bridge/) ([GitHub](wordpress-plugin/airano-mcp-seo-bridge/)) + Rank Math or Yoast SEO |
| WP-CLI tools (15 tools: `wp_cache_*`, `wp_db_*`, etc.) | Docker socket + `CONTAINER` env var |
| WordPress Advanced database/system tools | Docker socket + `CONTAINER` env var |
| WP-CLI tools (15 tools: `wp_cache_*`, `wp_db_*`, etc.) | Docker socket + `CONTAINER` config |
| WordPress Advanced database/system tools | Docker socket + `CONTAINER` config |
| OpenPanel analytics integration | [OpenPanel Self-Hosted](wordpress-plugin/openpanel-self-hosted/) ([Download ZIP](wordpress-plugin/openpanel-self-hosted.zip)) |
| WooCommerce tools | WooCommerce plugin (separate `WOOCOMMERCE_` config) |
| WooCommerce tools | WooCommerce plugin installed on your WordPress site |
**Docker socket** is needed for WP-CLI and WordPress Advanced system tools. Add to your docker-compose:
```yaml
volumes:
- /var/run/docker.sock:/var/run/docker.sock:ro
environment:
WORDPRESS_SITE1_CONTAINER: your-wp-container-name
```
Without Docker socket, WP-CLI tools return "not available" but all REST API tools work normally.
Set the `container` field when adding a WordPress site in the dashboard. Without Docker socket, WP-CLI tools return "not available" but all REST API tools work normally.
---

View File

@@ -37,14 +37,9 @@ from core.health import (
initialize_health_monitor,
)
# Project and Site Management
from core.project_manager import ProjectManager, get_project_manager
# Rate Limiting
from core.rate_limiter import RateLimitConfig, RateLimiter, get_rate_limiter
from core.site_manager import SiteConfig, SiteManager, get_site_manager
# Legacy (kept for backward compatibility, will be removed in v2.0)
from core.tool_generator import ToolGenerator
# Tool Management (Option B architecture)
@@ -56,9 +51,7 @@ __all__ = [
"get_auth_manager",
"APIKeyManager",
"get_api_key_manager",
# Project/Site Management
"ProjectManager",
"get_project_manager",
# Site Management
"SiteManager",
"SiteConfig",
"get_site_manager",

View File

@@ -850,12 +850,7 @@ async def get_all_projects(
is_admin = False
current_user_id = None
if user_session:
if (
hasattr(user_session, "user_type")
and user_session.user_type == "master"
or isinstance(user_session, dict)
and user_session.get("role") == "admin"
):
if 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
elif isinstance(user_session, dict) and "user_id" in user_session:
current_user_id = user_session["user_id"]
@@ -1512,6 +1507,7 @@ async def get_oauth_clients_data() -> dict:
"grant_types": client.grant_types,
"allowed_scopes": client.allowed_scopes,
"created_at": client.created_at.isoformat() if client.created_at else "",
"owner_user_id": getattr(client, "owner_user_id", None),
}
)
@@ -1528,10 +1524,26 @@ async def get_oauth_clients_data() -> dict:
async def dashboard_oauth_clients_list(request: Request) -> Response:
"""Render OAuth clients list page (admin only)."""
session, redirect = _require_admin_session(request)
if redirect:
return redirect
"""Render OAuth clients list page (admin and user)."""
# Try admin session first, then user session
auth = get_dashboard_auth()
session = None
is_admin = False
user_id = None
admin_session = auth.get_session_from_request(request)
if admin_session and is_admin_session(admin_session):
session = admin_session
is_admin = True
else:
user_session = auth.get_user_session_from_request(request)
if user_session:
session = user_session
is_admin = is_admin_session(user_session)
user_id = user_session.get("user_id")
if not session:
return RedirectResponse(url="/auth/login", status_code=303)
# Get language
accept_language = request.headers.get("accept-language")
@@ -1539,8 +1551,13 @@ async def dashboard_oauth_clients_list(request: Request) -> Response:
lang = detect_language(accept_language, query_lang)
t = get_translations(lang)
# Get clients data
# Get clients data — admin sees all, user sees own
clients_data = await get_oauth_clients_data()
if not is_admin and user_id:
clients_data["clients"] = [
c for c in clients_data["clients"] if c.get("owner_user_id") == user_id
]
clients_data["total_count"] = len(clients_data["clients"])
return templates.TemplateResponse(
request,
@@ -1552,15 +1569,26 @@ async def dashboard_oauth_clients_list(request: Request) -> Response:
"clients": clients_data["clients"],
"total_count": clients_data["total_count"],
"current_page": "oauth_clients",
"is_admin": is_admin,
},
)
async def dashboard_oauth_clients_create(request: Request) -> Response:
"""API endpoint to create OAuth client (admin only)."""
session, redirect = _require_admin_session(request)
if redirect:
return JSONResponse({"error": "Admin access required"}, status_code=403)
"""API endpoint to create OAuth client (admin and user)."""
# Accept both admin and user sessions
auth = get_dashboard_auth()
owner_user_id = None
admin_session = auth.get_session_from_request(request)
user_session = auth.get_user_session_from_request(request)
if admin_session and is_admin_session(admin_session):
pass # Admin — no owner_user_id
elif user_session:
owner_user_id = user_session.get("user_id")
else:
return JSONResponse({"error": "Authentication required"}, status_code=403)
try:
data = await request.json()
@@ -1569,7 +1597,7 @@ async def dashboard_oauth_clients_create(request: Request) -> Response:
redirect_uris = data.get("redirect_uris") or []
if not redirect_uris and data.get("redirect_uri"):
redirect_uris = [data.get("redirect_uri")]
scopes = data.get("scopes", ["read"])
scopes = data.get("scopes", ["read", "write", "admin"])
if not client_name or not redirect_uris:
return JSONResponse({"error": "Missing required fields"}, status_code=400)
@@ -1578,16 +1606,23 @@ async def dashboard_oauth_clients_create(request: Request) -> Response:
client_registry = get_client_registry()
client_id, client_secret = client_registry.create_client(
client_name=client_name, redirect_uris=redirect_uris, allowed_scopes=scopes
)
create_kwargs = {
"client_name": client_name,
"redirect_uris": redirect_uris,
"allowed_scopes": scopes,
}
if owner_user_id:
create_kwargs["owner_user_id"] = owner_user_id
client_id, client_secret = client_registry.create_client(**create_kwargs)
# Log the action
from core.audit_log import get_audit_logger
audit_logger = get_audit_logger()
audit_logger.log_system_event(
event=f"OAuth client created: {client_name}", details={"client_id": client_id}
event=f"OAuth client created: {client_name}",
details={"client_id": client_id, "owner_user_id": owner_user_id},
)
return JSONResponse(
@@ -1604,10 +1639,22 @@ async def dashboard_oauth_clients_create(request: Request) -> Response:
async def dashboard_oauth_clients_delete(request: Request) -> Response:
"""API endpoint to delete OAuth client (admin only)."""
session, redirect = _require_admin_session(request)
if redirect:
return JSONResponse({"error": "Admin access required"}, status_code=403)
"""API endpoint to delete OAuth client (admin and user)."""
# Accept both admin and user sessions
auth = get_dashboard_auth()
is_admin_user = False
user_id = None
admin_session = auth.get_session_from_request(request)
user_session = auth.get_user_session_from_request(request)
if admin_session and is_admin_session(admin_session):
is_admin_user = True
elif user_session:
user_id = user_session.get("user_id")
is_admin_user = is_admin_session(user_session)
else:
return JSONResponse({"error": "Authentication required"}, status_code=403)
try:
client_id = request.path_params.get("client_id", "")
@@ -1616,6 +1663,14 @@ async def dashboard_oauth_clients_delete(request: Request) -> Response:
client_registry = get_client_registry()
# Non-admin users can only delete their own clients
if not is_admin_user and user_id:
client = client_registry.get_client(client_id)
if not client:
return JSONResponse({"error": "Client not found"}, status_code=404)
if getattr(client, "owner_user_id", None) != user_id:
return JSONResponse({"error": "Access denied"}, status_code=403)
success = client_registry.delete_client(client_id)
if success:
@@ -3131,107 +3186,18 @@ async def api_get_config(request: Request) -> Response:
async def dashboard_user_oauth_clients_list(request: Request) -> Response:
"""GET /dashboard/connect/oauth-clients — OAuth user's own OAuth clients."""
user_session, redirect = _require_user_session(request)
if redirect:
return redirect
accept_language = request.headers.get("accept-language")
query_lang = request.query_params.get("lang")
lang = detect_language(accept_language, query_lang)
t = get_translations(lang)
from core.oauth import get_client_registry as _get_client_registry
registry = _get_client_registry()
user_id = user_session["user_id"]
user_clients = [c for c in registry.list_clients() if c.owner_user_id == user_id]
return templates.TemplateResponse(
request,
"dashboard/user-oauth-clients.html",
{
"lang": lang,
"t": t,
"session": user_session,
"clients": user_clients,
"current_page": "connect",
},
)
"""GET /dashboard/connect/oauth-clients — Redirect to unified OAuth clients page."""
return RedirectResponse(url="/dashboard/oauth-clients", status_code=303)
async def dashboard_user_oauth_clients_create(request: Request) -> Response:
"""POST /api/dashboard/user-oauth-clients/create — Create OAuth client for OAuth user."""
user_session, redirect = _require_user_session(request)
if redirect:
return JSONResponse({"error": "Unauthorized"}, status_code=401)
try:
body = await request.json()
except Exception:
return JSONResponse({"error": "Invalid JSON"}, status_code=400)
client_name = body.get("client_name", "").strip()
redirect_uris_raw = body.get("redirect_uris", "")
scopes = body.get("scopes", ["read", "write", "admin"])
if not client_name:
return JSONResponse({"error": "Client name required"}, status_code=400)
if isinstance(redirect_uris_raw, str):
redirect_uris = [u.strip() for u in redirect_uris_raw.splitlines() if u.strip()]
else:
redirect_uris = [u.strip() for u in redirect_uris_raw if u.strip()]
if not redirect_uris:
return JSONResponse({"error": "At least one redirect URI required"}, status_code=400)
scope_str = " ".join(scopes) if isinstance(scopes, list) else scopes
from core.oauth import get_client_registry as _get_client_registry
registry = _get_client_registry()
client_id, client_secret = registry.create_client(
client_name=client_name,
redirect_uris=redirect_uris,
allowed_scopes=scope_str.split() if isinstance(scope_str, str) else scopes,
owner_user_id=user_session["user_id"],
)
client = registry.get_client(client_id)
return JSONResponse(
{
"client_id": client_id,
"client_secret": client_secret,
"client_name": client.client_name,
"redirect_uris": client.redirect_uris,
"scope": client.scope,
}
)
"""POST /api/dashboard/user-oauth-clients/create — Forwards to unified create endpoint."""
return await dashboard_oauth_clients_create(request)
async def dashboard_user_oauth_clients_delete(request: Request) -> Response:
"""DELETE /api/dashboard/user-oauth-clients/{client_id}Delete user's own OAuth client."""
user_session, redirect = _require_user_session(request)
if redirect:
return JSONResponse({"error": "Unauthorized"}, status_code=401)
client_id = request.path_params.get("client_id", "")
from core.oauth import get_client_registry as _get_client_registry
registry = _get_client_registry()
client = registry.get_client(client_id)
if not client:
return JSONResponse({"error": "Client not found"}, status_code=404)
# Only allow deleting own clients
if client.owner_user_id != user_session["user_id"]:
return JSONResponse({"error": "Access denied"}, status_code=403)
registry.delete_client(client_id)
return JSONResponse({"success": True})
"""DELETE /api/dashboard/user-oauth-clients/{client_id}Forwards to unified delete endpoint."""
return await dashboard_oauth_clients_delete(request)
async def get_service_page_data(plugin_type: str) -> dict | None:

View File

@@ -48,6 +48,7 @@ class MCPEndpointFactory:
self.tool_registry = tool_registry
self.middleware_classes = middleware_classes or []
self.endpoints: dict[str, FastMCP] = {}
self._tool_counts: dict[str, int] = {}
self._tool_handlers: dict[str, Callable] = {}
def register_tool_handler(self, tool_name: str, handler: Callable):
@@ -92,8 +93,9 @@ class MCPEndpointFactory:
for middleware in custom_middleware:
mcp.add_middleware(middleware)
# Store endpoint
# Store endpoint and tool count
self.endpoints[config.path] = mcp
self._tool_counts[config.path] = len(tools)
logger.info(f" - Endpoint {config.path} created successfully")
@@ -282,9 +284,7 @@ class MCPEndpointFactory:
"""
info = []
for path, mcp in self.endpoints.items():
# Get tool count
# Note: This requires accessing FastMCP internals
tool_count = len(mcp._tool_manager._tools) if hasattr(mcp, "_tool_manager") else 0
tool_count = self._tool_counts.get(path, 0)
info.append(
{

View File

@@ -149,11 +149,7 @@ class EndpointRegistry:
tool_count = 0
if mcp:
# Try to get tool count from FastMCP
try:
tool_count = len(mcp._tool_manager._tools)
except AttributeError:
pass
tool_count = self.factory._tool_counts.get(path, 0)
endpoints.append(
EndpointInfo(

View File

@@ -23,7 +23,6 @@ from pathlib import Path
from typing import Any
from core.audit_log import AuditLogger
from core.project_manager import ProjectManager
from core.site_manager import SiteManager
logger = logging.getLogger(__name__)
@@ -130,7 +129,6 @@ class HealthMonitor:
def __init__(
self,
project_manager: ProjectManager,
audit_logger: AuditLogger | None = None,
metrics_retention_hours: int = 24,
max_metrics_per_project: int = 1000,
@@ -140,13 +138,11 @@ class HealthMonitor:
Initialize health monitor.
Args:
project_manager: Project manager instance
audit_logger: Optional audit logger for logging health events
metrics_retention_hours: Hours to retain historical metrics
max_metrics_per_project: Maximum metrics to store per project
site_manager: Optional SiteManager for comprehensive site discovery
site_manager: SiteManager for site discovery
"""
self.project_manager = project_manager
self.site_manager = site_manager
self.audit_logger = audit_logger
self.metrics_retention_hours = metrics_retention_hours
@@ -410,15 +406,7 @@ class HealthMonitor:
start_time = time.time()
try:
# Get plugin instance from ProjectManager
plugin = self.project_manager.projects.get(project_id)
if plugin:
# Perform health check via plugin instance
health_result = await plugin.health_check()
elif self.site_manager:
# Site exists in SiteManager but not legacy ProjectManager
# Create a temporary plugin instance for a proper health check
if self.site_manager:
health_result = await self._site_manager_health_check(project_id)
else:
return ProjectHealthStatus(
@@ -524,7 +512,7 @@ class HealthMonitor:
async def _site_manager_health_check(self, project_id: str) -> dict[str, Any]:
"""
Health check for sites managed by SiteManager (not in legacy ProjectManager).
Health check for a site via SiteManager.
Creates a temporary plugin instance and calls its health_check() method,
falling back to a basic HTTP check if plugin instantiation fails.
@@ -650,8 +638,8 @@ class HealthMonitor:
"""
health_statuses = {}
# Collect all known project/site IDs from both sources
all_project_ids = set(self.project_manager.projects.keys())
# Collect all known site IDs from SiteManager
all_project_ids = set()
if self.site_manager:
for site_info in self.site_manager.list_all_sites():
all_project_ids.add(site_info["full_id"])
@@ -860,7 +848,6 @@ def get_health_monitor() -> HealthMonitor | None:
def initialize_health_monitor(
project_manager: ProjectManager,
audit_logger: AuditLogger | None = None,
site_manager: SiteManager | None = None,
**kwargs,
@@ -869,16 +856,13 @@ def initialize_health_monitor(
Initialize the global health monitor.
Args:
project_manager: Project manager instance
audit_logger: Optional audit logger
site_manager: Optional SiteManager for comprehensive site discovery
site_manager: SiteManager for site discovery
**kwargs: Additional configuration options
Returns:
HealthMonitor instance
"""
global _health_monitor
_health_monitor = HealthMonitor(
project_manager, audit_logger, site_manager=site_manager, **kwargs
)
_health_monitor = HealthMonitor(audit_logger=audit_logger, site_manager=site_manager, **kwargs)
return _health_monitor

View File

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

View File

@@ -4,20 +4,12 @@ Site Manager - Type-safe site configuration management
Manages site configurations with Pydantic validation.
Part of Option B clean architecture refactoring.
Discovers sites from environment variables:
- {PLUGIN_TYPE}_{SITE_ID}_{CONFIG_KEY}
- {PLUGIN_TYPE}_{SITE_ID}_ALIAS (optional)
Example:
WORDPRESS_SITE1_URL=https://example.com
WORDPRESS_SITE1_USERNAME=admin
WORDPRESS_SITE1_APP_PASSWORD=xxxx
WORDPRESS_SITE2_ALIAS=myblog
Sites are managed via the web dashboard and stored in SQLite (DB-based).
The SiteManager provides registration and lookup infrastructure for
plugin tool generation and endpoint routing.
"""
import logging
import os
import re
from typing import Any
from pydantic import BaseModel, ConfigDict, Field, ValidationInfo, field_validator
@@ -111,7 +103,8 @@ class SiteManager:
"""
Manage site configurations with type safety.
Discovers, validates, and provides access to site configurations.
Provides registration and lookup of site configurations.
Sites are registered programmatically (e.g., from database) via register_site().
Attributes:
sites: Dictionary mapping plugin_type to site configurations
@@ -120,7 +113,7 @@ class SiteManager:
Examples:
>>> manager = SiteManager()
>>> manager.discover_sites(['wordpress', 'gitea'])
>>> manager.register_site(config)
>>> config = manager.get_site_config('wordpress', 'myblog')
>>> sites = manager.list_sites('wordpress')
"""
@@ -136,167 +129,6 @@ class SiteManager:
self.logger = logging.getLogger("SiteManager")
self.logger.info("SiteManager initialized")
def discover_sites(self, plugin_types: list[str]) -> int:
"""
Discover sites from environment variables.
Scans environment for site configurations and registers them.
Args:
plugin_types: List of plugin types to discover (e.g., ['wordpress'])
Returns:
Number of sites discovered
Examples:
>>> count = manager.discover_sites(['wordpress', 'gitea'])
>>> print(f"Discovered {count} sites")
"""
self.logger.info(f"Starting site discovery for: {', '.join(plugin_types)}")
total_discovered = 0
for plugin_type in plugin_types:
count = self._discover_plugin_sites(plugin_type)
total_discovered += count
self.logger.info(
f"Discovery complete. Found {total_discovered} sites "
f"across {len(plugin_types)} plugin types."
)
return total_discovered
# Reserved words that should NOT be interpreted as site IDs
RESERVED_SITE_WORDS = {
"limit",
"rate",
"config",
"debug",
"log",
"level",
"mode",
"timeout",
"retry",
"max",
"min",
"default",
"global",
"enabled",
"disabled",
"host",
"port",
"path",
"key",
"secret",
"token",
"advanced",
"basic",
"simple",
"pro",
"premium",
"standard",
}
def _discover_plugin_sites(self, plugin_type: str) -> int:
"""
Discover all sites for a specific plugin type.
Args:
plugin_type: Type of plugin (e.g., 'wordpress')
Returns:
Number of sites discovered for this plugin type
Examples:
>>> count = manager._discover_plugin_sites('wordpress')
"""
prefix = plugin_type.upper() + "_"
# Build list of longer prefixes from other plugin types to avoid collisions.
# e.g. WORDPRESS_ must not match WORDPRESS_ADVANCED_ env vars.
from plugins import registry as plugin_registry
all_plugin_types = plugin_registry.get_registered_types()
longer_prefixes = [
pt.upper() + "_"
for pt in all_plugin_types
if pt != plugin_type and pt.upper().startswith(plugin_type.upper() + "_")
]
# Pattern to match: WORDPRESS_SITE1_URL, WORDPRESS_SITE2_USERNAME, etc.
env_pattern = re.compile(f"^{prefix}([A-Z0-9_]+?)_(.+)$")
# Find all unique site IDs
site_ids = set()
for env_key in os.environ.keys():
# Skip env vars that belong to a more specific plugin type
if any(env_key.startswith(lp) for lp in longer_prefixes):
continue
match = env_pattern.match(env_key)
if match:
site_id = match.group(1).lower()
# Skip reserved words that are not real site IDs
if site_id not in self.RESERVED_SITE_WORDS:
site_ids.add(site_id)
# Load configuration for each site
discovered_count = 0
for site_id in site_ids:
try:
config = self._load_site_config(plugin_type, site_id)
if config:
self.register_site(config)
discovered_count += 1
except Exception as e:
self.logger.error(
f"Failed to load {plugin_type} site '{site_id}': {e}", exc_info=True
)
return discovered_count
def _load_site_config(self, plugin_type: str, site_id: str) -> SiteConfig | None:
"""
Load configuration for a site from environment.
Args:
plugin_type: Plugin type
site_id: Site ID
Returns:
SiteConfig if successful, None if incomplete
Examples:
>>> config = manager._load_site_config('wordpress', 'site1')
"""
prefix = f"{plugin_type.upper()}_{site_id.upper()}_"
config_data = {"site_id": site_id, "plugin_type": plugin_type}
# Collect all config keys for this site
for env_key, env_value in os.environ.items():
if env_key.startswith(prefix):
# Extract config key (everything after prefix)
config_key = env_key[len(prefix) :].lower()
config_data[config_key] = env_value
# Must have at least some configuration beyond site_id and plugin_type
if len(config_data) <= 2:
return None
try:
# Create and validate SiteConfig
config = SiteConfig(**config_data)
self.logger.debug(
f"Loaded config for {plugin_type}/{site_id}: " f"{list(config_data.keys())}"
)
return config
except Exception as e:
self.logger.error(f"Validation failed for {plugin_type}/{site_id}: {e}", exc_info=True)
return None
def register_site(self, config: SiteConfig) -> None:
"""
Register a site configuration.
@@ -356,7 +188,7 @@ class SiteManager:
# SECURITY: Don't reveal available plugin types in multi-tenant environment
raise ValueError(
f"No sites configured for plugin type: {plugin_type}. "
f"Please check your environment variables."
f"Please add a site via the dashboard."
)
# Try direct lookup
@@ -373,7 +205,7 @@ class SiteManager:
)
raise ValueError(
f"Site '{site}' not configured for {plugin_type}. "
f"Please verify the site alias/ID and check environment variables."
f"Please verify the site alias/ID in the dashboard."
)
def list_sites(self, plugin_type: str) -> list[str]:
@@ -545,7 +377,7 @@ def get_site_manager() -> SiteManager:
Examples:
>>> manager = get_site_manager()
>>> manager.discover_sites(['wordpress'])
>>> manager.register_site(config)
"""
global _site_manager
if _site_manager is None:

View File

@@ -138,6 +138,9 @@
('services', t.get('services', 'Services'), 'M19 11H5m14 0a2 2 0 012 2v6a2 2 0 01-2 2H5a2 2 0 01-2-2v-6a2 2 0 012-2m14 0V9a2 2 0 00-2-2M5 11V9a2 2 0 012-2m0 0V5a2 2 0 012-2h6a2 2 0 012 2v2M7 7h10', '/dashboard/services'),
('connect', t.get('connect', 'Connect'), 'M13.828 10.172a4 4 0 00-5.656 0l-4 4a4 4 0 105.656
5.656l1.102-1.101m-.758-4.899a4 4 0 005.656 0l4-4a4 4 0 00-5.656-5.656l-1.1 1.1', '/dashboard/connect'),
('oauth_clients', t.oauth_clients, 'M9 12l2 2 4-4m5.618-4.016A11.955 11.955 0 0112 2.944a11.955 11.955 0
01-8.618 3.04A12.02 12.02 0 003 9c0 5.591 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622
0-1.042-.133-2.052-.382-3.016z', '/dashboard/oauth-clients'),
] %}
{# ── Admin-only nav items ── #}
@@ -146,9 +149,6 @@
'/dashboard/projects'),
('api_keys', t.api_keys, 'M15 7a2 2 0 012 2m4 0a6 6 0 01-7.743 5.743L11 17H9v2H7v2H4a1 1 0
01-1-1v-2.586a1 1 0 01.293-.707l5.964-5.964A6 6 0 1121 9z', '/dashboard/api-keys'),
('oauth_clients', t.oauth_clients, 'M9 12l2 2 4-4m5.618-4.016A11.955 11.955 0 0112 2.944a11.955 11.955 0
01-8.618 3.04A12.02 12.02 0 003 9c0 5.591 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622
0-1.042-.133-2.052-.382-3.016z', '/dashboard/oauth-clients'),
('audit_logs', t.audit_logs, 'M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2
2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2m-3 7h3m-3 4h3m-6-4h.01M9 16h.01',
'/dashboard/audit-logs'),

View File

@@ -159,10 +159,10 @@
<p class="text-sm text-green-700 dark:text-green-400">
{% if lang == 'fa' %}
<strong>نکته:</strong> فقط آدرس URL کافی است. هنگام اتصال می‌توانید با <strong>API Key</strong> یا <strong>GitHub/Google</strong> احراز هویت کنید.
ساخت <a href="/dashboard/connect/oauth-clients{% if lang and lang != 'en' %}?lang={{ lang }}{% endif %}" class="underline">OAuth Client</a> اختیاری است.
ساخت <a href="/dashboard/oauth-clients{% if lang and lang != 'en' %}?lang={{ lang }}{% endif %}" class="underline">OAuth Client</a> اختیاری است.
{% else %}
<strong>Tip:</strong> You only need the URL above. When connecting, you can authenticate with an <strong>API Key</strong> or <strong>GitHub/Google</strong>.
Creating an <a href="/dashboard/connect/oauth-clients{% if lang and lang != 'en' %}?lang={{ lang }}{% endif %}" class="underline">OAuth Client</a> is optional.
Creating an <a href="/dashboard/oauth-clients{% if lang and lang != 'en' %}?lang={{ lang }}{% endif %}" class="underline">OAuth Client</a> is optional.
{% endif %}
</p>
</div>

View File

@@ -228,6 +228,14 @@
<p class="text-gray-500 text-sm mt-2">
{% if lang == 'fa' %}فیلترها را پاک کنید{% else %}Try clearing the filters{% endif %}
</p>
{% else %}
<p class="text-gray-500 text-sm mt-2">
{% if lang == 'fa' %}
سایت‌ها از طریق <a href="/dashboard/sites{% if lang and lang != 'en' %}?lang={{ lang }}{% endif %}" class="text-primary-400 hover:underline">صفحه سایت‌ها</a> مدیریت می‌شوند.
{% else %}
Sites are managed via the <a href="/dashboard/sites" class="text-primary-400 hover:underline">My Sites</a> page.
{% endif %}
</p>
{% endif %}
</td>
</tr>

View File

@@ -50,15 +50,9 @@ services:
- USER_RATE_LIMIT_PER_MIN=${USER_RATE_LIMIT_PER_MIN:-30}
- USER_RATE_LIMIT_PER_HR=${USER_RATE_LIMIT_PER_HR:-500}
# === ADMIN-MANAGED SITES (optional) ===
# Add WordPress/Gitea/n8n/etc. sites here if you want admin-level access.
# Users can add their own sites via the dashboard without these.
# Format: {PLUGIN_TYPE}_{SITE_ID}_{CONFIG_KEY}
#
# - WORDPRESS_SITE1_URL=${WORDPRESS_SITE1_URL:-}
# - WORDPRESS_SITE1_USERNAME=${WORDPRESS_SITE1_USERNAME:-}
# - WORDPRESS_SITE1_APP_PASSWORD=${WORDPRESS_SITE1_APP_PASSWORD:-}
# - WORDPRESS_SITE1_ALIAS=${WORDPRESS_SITE1_ALIAS:-}
# === SITES ===
# Sites are managed via the web dashboard (DB-based).
# After deployment, open the dashboard to add sites.
healthcheck:
test: ["CMD", "wget", "-qO-", "http://localhost:8000/health"]

View File

@@ -248,12 +248,11 @@ Without Docker socket:
- WordPress Advanced database/system tools are unavailable
- All REST API tools (bulk operations, content management) work normally
### Environment Variable Reference
### Site Configuration
All site configuration follows the pattern: `{PLUGIN_TYPE}_{SITE_ID}_{CONFIG_KEY}=value`
Sites are managed via the web dashboard. After starting the server, open the dashboard to add sites.
- `SITE_ID` can be any alphanumeric identifier (e.g., `SITE1`, `PROD`, `MYBLOG`)
- Multiple sites: change `SITE1` to `SITE2`, `SITE3`, etc.
Each plugin requires specific credentials (see below for reference).
#### WordPress
```env
@@ -358,7 +357,7 @@ After starting (via Docker or locally), wait ~30 seconds for the server to initi
```bash
curl http://localhost:8000/health
# Expected: {"status": "ok", "tools_loaded": 596, ...}
# Expected: {"status": "ok", "tools_loaded": 565, ...}
```
**2. Open the web dashboard:**
@@ -392,7 +391,7 @@ docker compose logs -f mcphub
| Port 8000 already in use | Change port in docker-compose.yaml: `"8001:8000"` |
| Health check shows "unhealthy" | Wait 60 seconds, then check logs for startup errors |
| Dashboard login fails | Make sure you're using the `MASTER_API_KEY` value from your `.env` |
| Sites not showing up | Restart after adding new env vars: `docker compose restart` |
| Sites not showing up | Add sites via the web dashboard, then check the connect page |
---
@@ -482,7 +481,7 @@ MCP Hub supports **Open Dynamic Client Registration** (RFC 7591). ChatGPT can au
## Using MCP Tools
### 596 Tools Across 9 Plugins
### 565 Tools Across 9 Plugins
| Plugin | Tools | Env Prefix |
|--------|-------|------------|
@@ -492,7 +491,7 @@ MCP Hub supports **Open Dynamic Client Registration** (RFC 7591). ChatGPT can au
| Gitea | 56 | `GITEA_` |
| n8n | 56 | `N8N_` |
| Supabase | 70 | `SUPABASE_` |
| OpenPanel | 73 | `OPENPANEL_` |
| OpenPanel | 42 | `OPENPANEL_` |
| Appwrite | 100 | `APPWRITE_` |
| Directus | 100 | `DIRECTUS_` |
| System | 24 | (no config needed) |
@@ -519,9 +518,9 @@ Use the most specific endpoint for your use case to minimize token usage:
| `/project/{alias}/mcp` | Single-site workflow | 22-100 | No (pre-scoped) |
| `/{plugin}/mcp` | Multi-site management | 23-101 | Yes |
| `/system/mcp` | System administration | 24 | N/A |
| `/mcp` | Admin & discovery only | 596 | Yes |
| `/mcp` | Admin & discovery only | 565 | Yes |
> **Recommendation**: Always use the most specific endpoint. Using `/mcp` (596 tools) wastes context tokens and degrades AI response quality.
> **Recommendation**: Always use the most specific endpoint. Using `/mcp` (565 tools) wastes context tokens and degrades AI response quality.
**Available plugin endpoints:**
@@ -533,7 +532,7 @@ Use the most specific endpoint for your use case to minimize token usage:
| `/gitea/mcp` | Gitea | 56 |
| `/n8n/mcp` | n8n | 56 |
| `/supabase/mcp` | Supabase | 70 |
| `/openpanel/mcp` | OpenPanel | 73 |
| `/openpanel/mcp` | OpenPanel | 42 |
| `/appwrite/mcp` | Appwrite | 100 |
| `/directus/mcp` | Directus | 100 |
| `/system/mcp` | System Management | 24 |
@@ -610,18 +609,16 @@ docker compose up --build -d
### Step 3: Configure Environment Variables
Add all required environment variables in Coolify's environment variable UI:
Add the required environment variables in Coolify's environment variable UI:
```
MASTER_API_KEY=your-secure-key-here
OAUTH_JWT_SECRET_KEY=your-jwt-secret
OAUTH_BASE_URL=https://your-domain.com
WORDPRESS_SITE1_URL=https://example.com
WORDPRESS_SITE1_USERNAME=admin
WORDPRESS_SITE1_APP_PASSWORD=xxxx xxxx xxxx xxxx
ENCRYPTION_KEY=your-base64-encryption-key
```
The server auto-discovers all `WORDPRESS_*`, `WOOCOMMERCE_*`, `GITEA_*`, and other plugin environment variables at startup.
After deployment, add sites via the web dashboard at `https://your-domain.com/dashboard`.
### Step 4: Configure Health Check
@@ -641,7 +638,7 @@ The server auto-discovers all `WORDPRESS_*`, `WOOCOMMERCE_*`, `GITEA_*`, and oth
## Next Steps
1. **Explore the full tool list**: See the [README](../README.md) for all 596 tools
1. **Explore the full tool list**: See the [README](../README.md) for all 565 tools
2. **Set up API keys**: [API Keys Guide](API_KEYS_GUIDE.md) for per-project access control
3. **Configure OAuth**: [OAuth Guide](OAUTH_GUIDE.md) for Claude/ChatGPT auto-registration
4. **Monitor health**: Use `check_all_projects_health` tool or visit the web dashboard

View File

@@ -2,7 +2,7 @@
# MCP Hub — Environment Configuration
# ===================================
#
# MINIMUM TO START: Just set MASTER_API_KEY and one site below.
# MINIMUM TO START: Set MASTER_API_KEY, then add sites via the web dashboard.
# Full docs: https://github.com/airano-ir/mcphub/blob/main/docs/getting-started.md
#
# After editing, run:
@@ -49,117 +49,14 @@ DASHBOARD_SESSION_SECRET=
# DISABLE_MASTER_KEY_LOGIN=false
# ============================================
# WORDPRESS SITES
# SITES
# ============================================
# Pattern: WORDPRESS_{SITE_ID}_{CONFIG_KEY}
# Add as many sites as you want (SITE1, SITE2, SITE3, ...)
# Sites are managed via the web dashboard (DB-based).
# After starting the server, open the dashboard to add sites:
# http://localhost:8000/dashboard
#
# How to create a WordPress Application Password:
# 1. Log into WordPress admin → Users → Your Profile
# 2. Scroll to "Application Passwords" section
# 3. Enter name "MCP Hub" → click "Add New"
# 4. Copy the generated password (format: xxxx xxxx xxxx xxxx)
WORDPRESS_SITE1_URL=https://your-site.com
WORDPRESS_SITE1_USERNAME=admin
WORDPRESS_SITE1_APP_PASSWORD=xxxx xxxx xxxx xxxx
WORDPRESS_SITE1_ALIAS=mysite
# Optional: Docker container name for WP-CLI tools (cache flush, transients, etc.)
# Find your container name with: docker ps --filter name=wordpress
# This lets MCP Hub run wp-cli commands inside your WordPress container.
# WORDPRESS_SITE1_CONTAINER=wordpress-container-name
# Second site example (uncomment to use):
# WORDPRESS_SITE2_URL=https://another-site.com
# WORDPRESS_SITE2_USERNAME=admin
# WORDPRESS_SITE2_APP_PASSWORD=yyyy yyyy yyyy yyyy
# WORDPRESS_SITE2_ALIAS=blog
# ============================================
# WORDPRESS ADVANCED (database, bulk, system)
# ============================================
# Pattern: WORDPRESS_ADVANCED_{SITE_ID}_{CONFIG_KEY}
# Provides database operations, bulk operations, and system management.
# REQUIRES Docker socket AND container name — all tools use WP-CLI.
#
# Uses the same credentials as WordPress, but the CONTAINER key is REQUIRED.
# WORDPRESS_ADVANCED_SITE1_URL=https://your-site.com
# WORDPRESS_ADVANCED_SITE1_USERNAME=admin
# WORDPRESS_ADVANCED_SITE1_APP_PASSWORD=xxxx xxxx xxxx xxxx
# WORDPRESS_ADVANCED_SITE1_CONTAINER=wordpress-container-name
# WORDPRESS_ADVANCED_SITE1_ALIAS=mysite-advanced
# ============================================
# WOOCOMMERCE STORES
# ============================================
# Pattern: WOOCOMMERCE_{STORE_ID}_{CONFIG_KEY}
# WooCommerce is a separate plugin — uses Consumer Key/Secret, not App Passwords.
#
# Create keys: WooCommerce → Settings → Advanced → REST API → Add Key
# WOOCOMMERCE_STORE1_URL=https://your-store.com
# WOOCOMMERCE_STORE1_CONSUMER_KEY=ck_xxxxx
# WOOCOMMERCE_STORE1_CONSUMER_SECRET=cs_xxxxx
# WOOCOMMERCE_STORE1_ALIAS=mystore
# ============================================
# OTHER PLUGINS (uncomment to use)
# ============================================
# All plugins auto-discover their environment variables at startup.
# Pattern: {PLUGIN_TYPE}_{INSTANCE_ID}_{CONFIG_KEY}
# --- Gitea ---
# GITEA_REPO1_URL=https://git.example.com
# GITEA_REPO1_TOKEN=your_gitea_token
# GITEA_REPO1_ALIAS=mygitea
# --- n8n ---
# N8N_INSTANCE1_URL=https://n8n.example.com
# N8N_INSTANCE1_API_KEY=your_n8n_api_key
# N8N_INSTANCE1_ALIAS=myn8n
# --- Supabase (Self-Hosted or Cloud) ---
# Works with both self-hosted Supabase and supabase.com cloud projects.
# For cloud: URL is https://xxxx.supabase.co
# For self-hosted: URL is your Kong gateway (e.g., http://your-server:8000)
#
# SERVICE_ROLE_KEY is required (bypasses RLS, full admin access).
# ANON_KEY is optional — if omitted, SERVICE_ROLE_KEY is used for all calls.
#
# Note: postgres-meta tools (list_tables, execute_sql, get_table_schema, etc.)
# require the /pg/ Kong route or a direct META_URL. These tools do NOT work
# on supabase.com cloud (postgres-meta is internal only on cloud).
# PostgREST/Auth/Storage/Functions tools work on both cloud and self-hosted.
#
# SUPABASE_PROJECT1_URL=https://xxxx.supabase.co
# SUPABASE_PROJECT1_SERVICE_ROLE_KEY=your_service_role_key
# SUPABASE_PROJECT1_ANON_KEY=your_anon_key
# SUPABASE_PROJECT1_ALIAS=mysupabase
#
# Optional: direct postgres-meta URL for self-hosted when /pg/ is not via Kong:
# SUPABASE_PROJECT1_META_URL=http://localhost:5555
#
# Optional: Basic Auth for postgres-meta when exposed via a public URL.
# Format: username:password — not needed for internal Docker networks or Kong.
# SUPABASE_PROJECT1_META_AUTH=admin:secretpassword
# --- OpenPanel ---
# OPENPANEL_INSTANCE1_URL=https://openpanel.example.com
# OPENPANEL_INSTANCE1_CLIENT_ID=your_client_id
# OPENPANEL_INSTANCE1_CLIENT_SECRET=your_client_secret
# OPENPANEL_INSTANCE1_ALIAS=myopenpanel
# --- Appwrite ---
# APPWRITE_PROJECT1_URL=https://appwrite.example.com
# APPWRITE_PROJECT1_API_KEY=your_appwrite_api_key
# APPWRITE_PROJECT1_PROJECT_ID=your_project_id
# APPWRITE_PROJECT1_ALIAS=myappwrite
# --- Directus ---
# DIRECTUS_INSTANCE1_URL=https://directus.example.com
# DIRECTUS_INSTANCE1_TOKEN=your_directus_token
# DIRECTUS_INSTANCE1_ALIAS=mydirectus
# Supported plugins: WordPress, WooCommerce, Supabase, OpenPanel,
# Gitea, n8n, Appwrite, Directus, WordPress Advanced
# ============================================
# OAUTH (optional — for ChatGPT/Claude auto-registration)

View File

@@ -4,8 +4,8 @@ Uses REST APIs:
- Export API (GET /export/events, /export/charts) for raw data export
- Insights API (GET /insights/:projectId/*) for analytics queries
Note: project_id is optional if configured in environment variables.
When not provided, the default project_id from OPENPANEL_SITE1_PROJECT_ID is used.
Note: project_id is optional if configured in the site settings.
When not provided, the default project_id from the site configuration is used.
Requires 'read' or 'root' mode client for Export API.
"""

View File

@@ -23,6 +23,6 @@ def get_project_id(client: OpenPanelClient, project_id: str | None) -> str:
return client.default_project_id
raise ValueError(
"project_id is required. Either provide it as a parameter or configure "
"OPENPANEL_SITE1_PROJECT_ID in environment variables. "
"the project_id field when adding the site in the dashboard. "
"You can find your Project ID in OpenPanel Dashboard → Project Settings."
)

View File

@@ -67,18 +67,17 @@ class WordPressClient:
# Validate required parameters
if not site_url:
raise ConfigurationError(
"Site URL is not configured. "
"Please set the URL environment variable (e.g., WORDPRESS_SITE1_URL)."
"Site URL is not configured. " "Please add or update the site in the dashboard."
)
if not username:
raise ConfigurationError(
"Username is not configured. "
"Please set the USERNAME environment variable (e.g., WORDPRESS_SITE1_USERNAME)."
"Please update the site credentials in the dashboard."
)
if not app_password:
raise ConfigurationError(
"App password is not configured. "
"Please set the APP_PASSWORD environment variable (e.g., WORDPRESS_SITE1_APP_PASSWORD)."
"Please update the site credentials in the dashboard."
)
self.site_url = site_url.rstrip("/")

View File

@@ -67,7 +67,7 @@ class WordPressAdvancedPlugin(BasePlugin):
if not container_name:
raise ValueError(
"WordPress Advanced plugin requires 'container' configuration. "
"Please set WORDPRESS_ADVANCED_SITE1_CONTAINER in environment variables."
"Please set the 'container' field when adding the site in the dashboard."
)
# Import WP-CLI manager

View File

@@ -1,6 +1,6 @@
[project]
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)"
authors = [
{name = "MCP Hub", email = "contact@mcphub.dev"}
@@ -28,7 +28,7 @@ classifiers = [
]
dependencies = [
"fastmcp>=2.14.0,<3.0.0",
"fastmcp>=3.0.0,<4.0.0",
"httpx>=0.25.0",
"aiohttp>=3.9.0",
"pydantic>=2.5.0",
@@ -69,7 +69,7 @@ requires = ["setuptools>=68.0", "wheel"]
build-backend = "setuptools.build_meta"
[tool.setuptools]
py-modules = ["server", "server_multi"]
py-modules = ["server"]
[tool.setuptools.packages.find]
include = ["core*", "plugins*"]
@@ -208,7 +208,6 @@ unfixable = []
"tests/*" = ["S101", "B007"]
"tests/legacy/*" = ["E402", "B007", "F401"]
"server.py" = ["E402", "F405", "F403", "UP045", "B023"] # Late imports + star imports + dynamic types
"server_multi.py" = ["E402", "F405", "F403", "UP045"]
"core/__init__.py" = ["F401", "F403"] # Re-exports
"plugins/__init__.py" = ["F401"]
"plugins/*/__init__.py" = ["F401", "F403", "F405"]

View File

@@ -14,8 +14,9 @@ Usage:
Environment Variables:
MASTER_API_KEY: Master API key for authentication
{PLUGIN_TYPE}_{PROJECT_ID}_{CONFIG_KEY}: Project configurations
LOG_LEVEL: Logging level (DEBUG, INFO, WARNING, ERROR)
Sites are managed via the web dashboard and stored in SQLite (DB-based).
"""
import base64
@@ -50,7 +51,6 @@ from core import (
get_api_key_manager,
get_audit_logger,
get_auth_manager,
get_project_manager,
get_rate_limiter,
# Core architecture modules
get_site_manager,
@@ -298,7 +298,6 @@ logger.info("Jinja2 template engine initialized")
# Initialize managers
auth_manager = get_auth_manager()
api_key_manager = get_api_key_manager()
project_manager = get_project_manager()
audit_logger = get_audit_logger()
csrf_manager = get_csrf_manager()
@@ -308,7 +307,6 @@ from plugins import registry as plugin_registry
site_manager = get_site_manager()
plugin_types = plugin_registry.get_registered_types()
site_manager.discover_sites(plugin_types)
# Initialize tool registry (central tool management)
tool_registry = get_tool_registry()
@@ -323,24 +321,8 @@ if auth_manager._is_temporary_key:
logger.info(f"Master API Key (temporary): {_mk}")
else:
logger.info(f"Master API Key: {_mk[:8]}***{_mk[-4:]}")
logger.info(f"Discovered {len(project_manager.projects)} per-site project instances (legacy)")
logger.info(
f"Discovered {site_manager.get_count()} unique sites across {len(plugin_types)} plugin types"
)
logger.info(f"Site breakdown: {site_manager.get_count_by_type()}")
# Log discovered projects
for full_id, plugin in project_manager.projects.items():
logger.info(f" - {full_id} ({plugin.get_plugin_name()})")
# Log discovered sites
logger.info("\nDiscovered sites:")
for site_info in site_manager.list_all_sites():
alias_display = (
f" (alias: {site_info['alias']})" if site_info["alias"] != site_info["site_id"] else ""
)
logger.info(f" - {site_info['full_id']}{alias_display}")
logger.info(f"Plugin types registered: {', '.join(plugin_types)}")
logger.info("Sites are managed via the web dashboard (DB-based)")
logger.info("=" * 60)
@@ -1122,7 +1104,6 @@ from core import initialize_health_monitor
# Initialize health monitor
health_monitor = initialize_health_monitor(
project_manager=project_manager,
audit_logger=audit_logger,
site_manager=site_manager,
metrics_retention_hours=24,
@@ -1401,15 +1382,11 @@ async def get_project_info(project_id: str) -> str:
import json
try:
# Try legacy ProjectManager first
info = project_manager.get_project_info(project_id)
# Fallback to SiteManager if legacy finds nothing
if info is None:
for site_data in site_manager.list_all_sites():
if site_data["full_id"] == project_id:
info = site_data
break
info = None
for site_data in site_manager.list_all_sites():
if site_data["full_id"] == project_id:
info = site_data
break
if info is None:
return f"Project '{project_id}' not found. Use list_projects to see available projects."
@@ -3799,12 +3776,11 @@ Use get_endpoints() to see all available MCP endpoints."""
import json
try:
info = project_manager.get_project_info(project_id)
if info is None:
for site_data in site_manager.list_all_sites():
if site_data["full_id"] == project_id:
info = site_data
break
info = None
for site_data in site_manager.list_all_sites():
if site_data["full_id"] == project_id:
info = site_data
break
if info is None:
return f"Project '{project_id}' not found. Use list_projects to see available projects."
return json.dumps(info, indent=2)
@@ -5032,12 +5008,6 @@ def main():
logger.info(f"Host: {args.host}")
logger.info(f"Port: {args.port}")
# Check if any sites were discovered (SiteManager is the primary discovery system)
if site_manager.get_count() == 0:
logger.warning("No sites discovered! Check your environment variables.")
logger.warning("Expected format: {PLUGIN_TYPE}_{SITE_ID}_{CONFIG_KEY}=value")
logger.warning("Example: WORDPRESS_SITE1_URL=https://example.com")
try:
if args.transport == "stdio":
# stdio doesn't support multi-endpoint

File diff suppressed because it is too large Load Diff

View File

@@ -24,7 +24,7 @@ if sys.platform == "win32":
# Add project root to path
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
from core import AuditLogger, ProjectManager, initialize_health_monitor
from core import AuditLogger, initialize_health_monitor
async def test_health_monitor():
@@ -35,11 +35,9 @@ async def test_health_monitor():
# Initialize components
print("\n1. Initializing components...")
project_manager = ProjectManager()
audit_logger = AuditLogger()
health_monitor = initialize_health_monitor(
project_manager=project_manager,
audit_logger=audit_logger,
metrics_retention_hours=24,
max_metrics_per_project=1000,

View File

@@ -1,4 +1,4 @@
"""Tests for Site Manager (core/site_manager.py)."""
"""Tests for Site Manager (core/site_manager.py) — registration and lookup."""
import pytest
@@ -127,69 +127,6 @@ class TestSiteManagerRegistration:
assert manager.list_sites("nonexistent") == []
class TestSiteManagerDiscovery:
"""Test site discovery from environment variables."""
def test_discover_wordpress_sites(self, monkeypatch):
"""Should discover sites from WORDPRESS_SITE1_* env vars."""
monkeypatch.setenv("WORDPRESS_SITE1_URL", "https://wp1.example.com")
monkeypatch.setenv("WORDPRESS_SITE1_USERNAME", "admin")
monkeypatch.setenv("WORDPRESS_SITE1_APP_PASSWORD", "xxxx")
monkeypatch.setenv("WORDPRESS_SITE1_ALIAS", "myblog")
manager = SiteManager()
count = manager.discover_sites(["wordpress"])
assert count == 1
config = manager.get_site_config("wordpress", "site1")
assert config.url == "https://wp1.example.com"
assert config.username == "admin"
assert config.alias == "myblog"
def test_discover_multiple_sites(self, monkeypatch):
"""Should discover multiple sites for same plugin type."""
monkeypatch.setenv("WORDPRESS_SITE1_URL", "https://wp1.example.com")
monkeypatch.setenv("WORDPRESS_SITE1_USERNAME", "admin")
monkeypatch.setenv("WORDPRESS_SITE2_URL", "https://wp2.example.com")
monkeypatch.setenv("WORDPRESS_SITE2_USERNAME", "editor")
manager = SiteManager()
count = manager.discover_sites(["wordpress"])
assert count == 2
def test_discover_across_plugin_types(self, monkeypatch):
"""Should discover sites across different plugin types."""
monkeypatch.setenv("WORDPRESS_SITE1_URL", "https://wp.example.com")
monkeypatch.setenv("WORDPRESS_SITE1_USERNAME", "admin")
monkeypatch.setenv("GITEA_SITE1_URL", "https://git.example.com")
monkeypatch.setenv("GITEA_SITE1_TOKEN", "tok_123")
manager = SiteManager()
count = manager.discover_sites(["wordpress", "gitea"])
assert count == 2
def test_reserved_words_skipped(self, monkeypatch):
"""Reserved words like LIMIT, RATE should not become site IDs."""
monkeypatch.setenv("WORDPRESS_LIMIT_PER_MINUTE", "100")
monkeypatch.setenv("WORDPRESS_RATE_PER_HOUR", "500")
manager = SiteManager()
count = manager.discover_sites(["wordpress"])
assert count == 0
def test_incomplete_config_skipped(self, monkeypatch):
"""Sites with no config data (only prefix match) should be skipped."""
# WORDPRESS_SITE1_ exists as prefix but no actual config keys
# This is hard to test directly since env vars always have a value
# Instead, test that a site with only site_id and plugin_type (no config) is skipped
manager = SiteManager()
count = manager.discover_sites(["wordpress"])
assert count == 0
class TestSiteManagerCounts:
"""Test counting and listing methods."""