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>
This commit is contained in:
2026-04-02 00:24:33 +02:00
parent 81417b552f
commit 3815f5a6d5
23 changed files with 202 additions and 782 deletions

View File

@@ -5,9 +5,12 @@
# Version: 3.0.0
# Last Updated: 2026-02-17
#
# 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):

View File

@@ -125,26 +125,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 +143,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 |
@@ -182,7 +173,7 @@ Web UI at the server root, built with Starlette + Jinja2 + HTMX + Tailwind CSS.
### 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.
`core/project_manager.py` (retained for HealthMonitor compatibility), `core/site_registry.py`, `core/unified_tools.py` — kept for backward compatibility. New code should use `SiteManager`, `ToolRegistry`, and `ToolGenerator` instead.
## Commit Style
@@ -211,7 +202,7 @@ v4 development cycle with 15 phases. See `docs/ROADMAP.md` (Track F) and `.claud
- `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

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

View File

@@ -10,12 +10,12 @@ import json
import logging
import os
import secrets
import bcrypt
from dataclasses import asdict, dataclass
from datetime import datetime, timedelta
from pathlib import Path
import bcrypt
logger = logging.getLogger(__name__)

View File

@@ -850,12 +850,9 @@ 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":
is_admin = True
elif 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 +1509,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 +1526,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 +1553,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 +1571,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 +1599,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 +1608,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 +1641,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 +1665,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 +3188,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

@@ -1,33 +1,23 @@
"""
Project Manager
Project Manager (Legacy)
Discovers and manages project instances from environment variables.
Handles plugin lifecycle and tool registration.
Retained for backward compatibility with HealthMonitor.
Sites are now managed via the web dashboard (DB-based).
"""
import logging
import os
import re
from typing import Any
from plugins import BasePlugin, registry
from plugins import BasePlugin
logger = logging.getLogger(__name__)
class ProjectManager:
"""
Manage multiple project instances.
Legacy project manager — retained for HealthMonitor compatibility.
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
...
Sites are now managed via the web dashboard and stored in SQLite.
"""
def __init__(self):
@@ -35,115 +25,6 @@ class ProjectManager:
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.
@@ -255,5 +136,4 @@ def get_project_manager() -> ProjectManager:
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
@@ -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 |
---
@@ -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

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

@@ -483,6 +483,8 @@ async def delete_user_factor(client: SupabaseClient, user_id: str, factor_id: st
return json.dumps({"success": False, "error": str(e)}, indent=2, ensure_ascii=False)
async def search_users(
client: SupabaseClient, query: str, page: int = 1, per_page: int = 50
) -> str:

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

@@ -77,12 +77,8 @@ class WPCLIManager:
try:
# First, test if we have Docker socket access
test_process = await asyncio.create_subprocess_exec(
"docker",
"version",
"--format",
"{{.Server.Version}}",
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
"docker", "version", "--format", "{{.Server.Version}}",
stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE
)
test_stdout, test_stderr = await asyncio.wait_for(
@@ -103,15 +99,10 @@ class WPCLIManager:
# Now check for our specific container using exact name match
process = await asyncio.create_subprocess_exec(
"docker",
"ps",
"--all",
"--filter",
f"name=^{self.container_name}$",
"--format",
"{{.Names}}|{{.Status}}",
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
"docker", "ps", "--all",
"--filter", f"name=^{self.container_name}$",
"--format", "{{.Names}}|{{.Status}}",
stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE
)
stdout, stderr = await asyncio.wait_for(process.communicate(), timeout=5.0)
@@ -126,13 +117,8 @@ class WPCLIManager:
if not output:
# Container not found - get list of available containers for helpful error
list_process = await asyncio.create_subprocess_exec(
"docker",
"ps",
"--all",
"--format",
"{{.Names}}",
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
"docker", "ps", "--all", "--format", "{{.Names}}",
stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE
)
list_stdout, _ = await list_process.communicate()
available = list_stdout.decode().strip().split("\n") if list_stdout else []
@@ -184,14 +170,9 @@ class WPCLIManager:
try:
# Try to run wp --version
process = await asyncio.create_subprocess_exec(
"docker",
"exec",
self.container_name,
"wp",
"--version",
"--allow-root",
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
"docker", "exec", self.container_name,
"wp", "--version", "--allow-root",
stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE
)
stdout, stderr = await asyncio.wait_for(process.communicate(), timeout=5.0)
@@ -310,9 +291,7 @@ class WPCLIManager:
)
# 4. Build docker exec command (split command into args to prevent shell injection)
cmd_parts = (
["docker", "exec", self.container_name, "wp"] + command.split() + ["--allow-root"]
)
cmd_parts = ["docker", "exec", self.container_name, "wp"] + command.split() + ["--allow-root"]
self.logger.info(f"Executing: {' '.join(cmd_parts)}")
@@ -603,29 +582,17 @@ class WPCLIManager:
try:
# Try GNU stat first, fall back to BSD stat
process = await asyncio.create_subprocess_exec(
"docker",
"exec",
self.container_name,
"stat",
"-c",
"%s",
export_path,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
"docker", "exec", self.container_name,
"stat", "-c", "%s", export_path,
stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE
)
stdout, _ = await asyncio.wait_for(process.communicate(), timeout=5.0)
if process.returncode != 0:
# BSD stat fallback
process = await asyncio.create_subprocess_exec(
"docker",
"exec",
self.container_name,
"stat",
"-f",
"%z",
export_path,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
"docker", "exec", self.container_name,
"stat", "-f", "%z", export_path,
stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE
)
stdout, _ = await asyncio.wait_for(process.communicate(), timeout=5.0)

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

@@ -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
@@ -66,8 +67,6 @@ from core.dashboard.routes import (
api_get_config,
api_list_keys,
api_list_sites,
# K.5: Settings routes
api_save_setting,
api_test_site,
api_update_site,
# E.2: OAuth Social Login routes
@@ -109,6 +108,8 @@ from core.dashboard.routes import (
# F.3: Service pages
dashboard_service_page,
dashboard_services_list,
# K.5: Settings routes
api_save_setting,
dashboard_settings_page,
# E.3: Site Management pages
dashboard_sites_add,
@@ -308,7 +309,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 +323,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)
@@ -5032,12 +5016,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

View File

@@ -165,7 +165,6 @@ rate_limiter = get_rate_limiter()
# Initialize site manager
site_manager = get_site_manager()
plugin_types = plugin_registry.get_registered_types()
site_manager.discover_sites(plugin_types)
# WooCommerce can fallback to WordPress site configurations if no WOOCOMMERCE_* env vars
# This mapping is only used when woocommerce has no sites configured

View File

@@ -1,5 +1,6 @@
"""Tests for Admin System Unification (F.4)."""
from core.dashboard.auth import (
DashboardSession,
get_session_display_info,

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."""