diff --git a/CHANGELOG.md b/CHANGELOG.md index 6311780..5ae239e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,25 @@ All notable changes to MCP Hub will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), 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) diff --git a/CLAUDE.md b/CLAUDE.md index 1051ef5..3bb0ed9 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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 ``` @@ -171,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` (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 ``` @@ -197,7 +190,6 @@ 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. diff --git a/core/__init__.py b/core/__init__.py index 5b84546..589ff77 100644 --- a/core/__init__.py +++ b/core/__init__.py @@ -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", diff --git a/core/api_keys.py b/core/api_keys.py index 24c04f9..62abd6a 100644 --- a/core/api_keys.py +++ b/core/api_keys.py @@ -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__) diff --git a/core/dashboard/routes.py b/core/dashboard/routes.py index d3450c1..003e4d4 100644 --- a/core/dashboard/routes.py +++ b/core/dashboard/routes.py @@ -850,9 +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": - is_admin = True - elif 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"] diff --git a/core/endpoints/factory.py b/core/endpoints/factory.py index d9eb6e5..4a76193 100644 --- a/core/endpoints/factory.py +++ b/core/endpoints/factory.py @@ -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( { diff --git a/core/endpoints/registry.py b/core/endpoints/registry.py index 34773fb..bc70ffe 100644 --- a/core/endpoints/registry.py +++ b/core/endpoints/registry.py @@ -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( diff --git a/core/health.py b/core/health.py index 78d8c22..d164d87 100644 --- a/core/health.py +++ b/core/health.py @@ -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 diff --git a/core/project_manager.py b/core/project_manager.py deleted file mode 100644 index 8d08695..0000000 --- a/core/project_manager.py +++ /dev/null @@ -1,139 +0,0 @@ -""" -Project Manager (Legacy) - -Retained for backward compatibility with HealthMonitor. -Sites are now managed via the web dashboard (DB-based). -""" - -import logging -from typing import Any - -from plugins import BasePlugin - -logger = logging.getLogger(__name__) - - -class ProjectManager: - """ - Legacy project manager — retained for HealthMonitor compatibility. - - Sites are now managed via the web dashboard and stored in SQLite. - """ - - def __init__(self): - """Initialize project manager.""" - self.projects: dict[str, BasePlugin] = {} - self.logger = logging.getLogger("ProjectManager") - - 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() - return _project_manager diff --git a/plugins/supabase/handlers/auth.py b/plugins/supabase/handlers/auth.py index d657e5e..a4bb174 100644 --- a/plugins/supabase/handlers/auth.py +++ b/plugins/supabase/handlers/auth.py @@ -483,8 +483,6 @@ 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: diff --git a/plugins/wordpress/wp_cli.py b/plugins/wordpress/wp_cli.py index 30493b3..5b263f4 100644 --- a/plugins/wordpress/wp_cli.py +++ b/plugins/wordpress/wp_cli.py @@ -77,8 +77,12 @@ 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( @@ -99,10 +103,15 @@ 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) @@ -117,8 +126,13 @@ 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 [] @@ -170,9 +184,14 @@ 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) @@ -291,7 +310,9 @@ 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)}") @@ -582,17 +603,29 @@ 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) diff --git a/pyproject.toml b/pyproject.toml index 683aef8..ac39a25 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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"] diff --git a/server.py b/server.py index ec8a852..706ac8e 100644 --- a/server.py +++ b/server.py @@ -51,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, @@ -67,6 +66,8 @@ 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 @@ -108,8 +109,6 @@ 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, @@ -299,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() @@ -1106,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, @@ -1385,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." @@ -3783,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) diff --git a/server_multi.py b/server_multi.py deleted file mode 100644 index 5b1b778..0000000 --- a/server_multi.py +++ /dev/null @@ -1,1152 +0,0 @@ -#!/usr/bin/env python3 -""" -MCP Hub - Multi-Endpoint Server v2.1.0 - -Multi-Endpoint Architecture (Phase X + D.1): -- /mcp → Admin endpoint (all tools, Master API Key required) -- /wordpress/mcp → WordPress Core tools (64 tools) -- /woocommerce/mcp → WooCommerce tools (28 tools) ← NEW Phase D.1 -- /wordpress-advanced/mcp → WordPress Advanced tools (22 tools) -- /gitea/mcp → Gitea tools only (55 tools) -- /project/{id}/mcp → Project-specific tools - -Note: FastMCP automatically adds /mcp to the mount path. - -Benefits: -- Users only see tools they can access -- Better security and access control -- Optimized context for AI assistants -- Scalable architecture - -Usage: - python server_multi.py --transport streamable-http --port 8000 -""" - -import inspect -import logging -import os -import sys -import time -from collections.abc import Callable -from typing import Optional - -import uvicorn -from fastmcp import FastMCP -from fastmcp.exceptions import ToolError -from fastmcp.server.dependencies import get_http_headers -from fastmcp.server.middleware import Middleware, MiddlewareContext -from starlette.applications import Starlette -from starlette.middleware import Middleware as StarletteMiddleware -from starlette.requests import Request -from starlette.responses import HTMLResponse, JSONResponse, RedirectResponse -from starlette.routing import Mount, Route -from starlette.templating import Jinja2Templates - -# Import core modules -from core import ( - ToolDefinition, - ToolGenerator, - clear_api_key_context, - get_api_key_manager, - get_audit_logger, - get_auth_manager, - get_project_manager, - get_rate_limiter, - get_site_manager, - get_tool_registry, - set_api_key_context, -) - -# Import endpoint configuration -from core.endpoints import ( - ENDPOINT_CONFIGS, - EndpointConfig, - EndpointType, - create_project_endpoint_config, -) -from core.i18n import detect_language, get_all_translations - -# OAuth -from core.oauth import OAuthError, get_csrf_manager, get_oauth_server -from plugins import registry as plugin_registry - -# Configure logging -LOG_LEVEL = os.getenv("LOG_LEVEL", "INFO").upper() -logging.basicConfig( - level=getattr(logging, LOG_LEVEL), - format="%(asctime)s - %(name)s - %(levelname)s - %(message)s", - handlers=[logging.StreamHandler(sys.stderr)], -) -logger = logging.getLogger(__name__) - -# OAuth Configuration -OAUTH_AUTH_MODE = os.getenv("OAUTH_AUTH_MODE", "required").lower() - -# ======================================== -# DCR (Dynamic Client Registration) Configuration -# ======================================== -# Allowlist of redirect_uri patterns for open DCR (without Master API Key) -import re - -DCR_ALLOWED_REDIRECT_PATTERNS = [ - # Claude AI - r"^https://claude\.ai/.*", - r"^https://claude\.com/.*", - # ChatGPT / OpenAI - r"^https://chatgpt\.com/.*", - r"^https://chat\.openai\.com/.*", - r"^https://platform\.openai\.com/.*", - # Localhost for development - r"^http://localhost:\d+/.*", - r"^http://127\.0\.0\.1:\d+/.*", -] - -# Load additional patterns from environment -DCR_EXTRA_PATTERNS = os.getenv("DCR_ALLOWED_REDIRECT_PATTERNS", "") -if DCR_EXTRA_PATTERNS: - for pattern in DCR_EXTRA_PATTERNS.split(","): - pattern = pattern.strip() - if pattern: - DCR_ALLOWED_REDIRECT_PATTERNS.append(pattern) - -# DCR Rate Limiting -DCR_RATE_LIMIT_PER_MINUTE = int(os.getenv("DCR_RATE_LIMIT_PER_MINUTE", "10")) -DCR_RATE_LIMIT_PER_HOUR = int(os.getenv("DCR_RATE_LIMIT_PER_HOUR", "30")) -_dcr_rate_limits: dict = {} - - -def is_redirect_uri_allowed_for_open_dcr(redirect_uris: list) -> bool: - """Check if all redirect_uris match the allowlist patterns.""" - for uri in redirect_uris: - uri_allowed = False - for pattern in DCR_ALLOWED_REDIRECT_PATTERNS: - if re.match(pattern, uri): - uri_allowed = True - break - if not uri_allowed: - return False - return True - - -def check_dcr_rate_limit(client_ip: str) -> tuple: - """Check if DCR request is within rate limits.""" - now = time.time() - if client_ip not in _dcr_rate_limits: - _dcr_rate_limits[client_ip] = { - "minute": 0, - "hour": 0, - "minute_reset": now + 60, - "hour_reset": now + 3600, - } - limits = _dcr_rate_limits[client_ip] - if now > limits["minute_reset"]: - limits["minute"] = 0 - limits["minute_reset"] = now + 60 - if now > limits["hour_reset"]: - limits["hour"] = 0 - limits["hour_reset"] = now + 3600 - if limits["minute"] >= DCR_RATE_LIMIT_PER_MINUTE: - return False, f"Rate limit: {DCR_RATE_LIMIT_PER_MINUTE}/min" - if limits["hour"] >= DCR_RATE_LIMIT_PER_HOUR: - return False, f"Rate limit: {DCR_RATE_LIMIT_PER_HOUR}/hour" - limits["minute"] += 1 - limits["hour"] += 1 - return True, "" - - -# 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() -rate_limiter = get_rate_limiter() - -# Initialize site manager -site_manager = get_site_manager() -plugin_types = plugin_registry.get_registered_types() - -# WooCommerce can fallback to WordPress site configurations if no WOOCOMMERCE_* env vars -# This mapping is only used when woocommerce has no sites configured -from core.tool_generator import PLUGIN_SITE_FALLBACK - -for plugin_type, fallback_type in PLUGIN_SITE_FALLBACK.items(): - if fallback_type in site_manager.sites and plugin_type not in site_manager.sites: - # Copy site configs from fallback type to plugin type - site_manager.sites[plugin_type] = site_manager.sites[fallback_type].copy() - logger.info( - f"Fallback: using {fallback_type} sites for {plugin_type} ({len(site_manager.sites[plugin_type])} sites)" - ) - elif plugin_type in site_manager.sites: - logger.info( - f"{plugin_type} has its own sites configured ({len(site_manager.sites[plugin_type])} sites)" - ) - -# Initialize tool registry and generator -tool_registry = get_tool_registry() -tool_generator = ToolGenerator(site_manager) - -# Jinja2 templates -templates = Jinja2Templates(directory="templates") - -# OAuth server -oauth_server = get_oauth_server() - -# Server start time -server_start_time = time.time() - - -# === TOOL GENERATION === - - -def generate_all_tools(): - """Generate tools from all plugins into the tool registry""" - logger.info("=" * 60) - logger.info("Generating tools from plugins...") - logger.info("=" * 60) - - # WordPress Core (64 tools) - try: - from plugins.wordpress.plugin import WordPressPlugin - - wordpress_tools = tool_generator.generate_tools(WordPressPlugin, "wordpress") - for tool_def in wordpress_tools: - tool_registry.register(tool_def) - logger.info(f" WordPress Core: {len(wordpress_tools)} tools") - except Exception as e: - logger.error(f"Failed to generate WordPress tools: {e}") - - # WooCommerce (28 tools) - Phase D.1 - try: - from plugins.woocommerce.plugin import WooCommercePlugin - - woocommerce_tools = tool_generator.generate_tools(WooCommercePlugin, "woocommerce") - for tool_def in woocommerce_tools: - tool_registry.register(tool_def) - logger.info(f" WooCommerce: {len(woocommerce_tools)} tools") - except Exception as e: - logger.error(f"Failed to generate WooCommerce tools: {e}") - - # WordPress Advanced (22 tools) - try: - from plugins.wordpress_advanced.plugin import WordPressAdvancedPlugin - - wp_adv_tools = tool_generator.generate_tools(WordPressAdvancedPlugin, "wordpress_advanced") - for tool_def in wp_adv_tools: - tool_registry.register(tool_def) - logger.info(f" WordPress Advanced: {len(wp_adv_tools)} tools") - except Exception as e: - logger.error(f"Failed to generate WordPress Advanced tools: {e}") - - # Gitea (55 tools) - try: - from plugins.gitea.plugin import GiteaPlugin - - gitea_tools = tool_generator.generate_tools(GiteaPlugin, "gitea") - for tool_def in gitea_tools: - tool_registry.register(tool_def) - logger.info(f" Gitea: {len(gitea_tools)} tools") - except Exception as e: - logger.error(f"Failed to generate Gitea tools: {e}") - - logger.info(f"Total tools in registry: {tool_registry.get_count()}") - logger.info("=" * 60) - - -# === ENDPOINT CREATION === - - -class EndpointAuthMiddleware(Middleware): - """Authentication middleware for specific endpoint""" - - def __init__(self, endpoint_config: EndpointConfig): - self.config = endpoint_config - - async def on_call_tool(self, context: MiddlewareContext, call_next): - tool_name = getattr(context.message, "name", "unknown") - - try: - # Get headers - try: - headers = get_http_headers() - except Exception: - headers = {} - - auth_header = headers.get("authorization", "") - headers.get("x-forwarded-for", "unknown") - - # Parse token - token = None - if auth_header.startswith("Bearer "): - token = auth_header[7:] - elif auth_header: - token = auth_header - - # Check authentication - is_master = False - key_project_id = None - key_scope = "read" - key_id = None - - if token: - if token.startswith("sk-"): - # Master key - if auth_manager.validate_master_key(token): - is_master = True - key_project_id = "*" - key_scope = "admin" - key_id = "master" - else: - raise ToolError("Invalid master API key") - - elif token.startswith("cmp_"): - # Project API key - key = api_key_manager.get_key_by_token(token) - if not key: - raise ToolError("Invalid API key") - if key.revoked: - raise ToolError("API key has been revoked") - if key.is_expired(): - raise ToolError("API key has expired") - - key_id = key.key_id - key_project_id = key.project_id - key_scope = key.scope - - else: - # Try OAuth token - try: - from core.oauth import get_token_manager - - token_manager = get_token_manager() - payload = token_manager.validate_access_token(token) - if payload: - key_project_id = payload.get("project_id", "*") - key_scope = payload.get("scope", "read") - key_id = f"oauth_{payload.get('sub', 'unknown')}" - else: - # OAuth token validation returned None - invalid token - raise ToolError("Invalid OAuth access token") - except ToolError: - raise - except Exception: - raise ToolError("Invalid authentication token") - - # Check if endpoint requires master key - if self.config.require_master_key and not is_master: - raise ToolError(f"Endpoint {self.config.path} requires master API key") - - # Check if no auth provided - require authentication for all endpoints - if not token: - if self.config.require_master_key: - raise ToolError( - f"Endpoint {self.config.path} requires master API key. " - "Please provide Authorization header with Bearer sk-..." - ) - else: - raise ToolError( - "Authentication required. Please provide Authorization header with Bearer token. " - "Supported tokens: Master key (sk-...), Project API key (cmp_...), or OAuth token." - ) - - # Check plugin type access for non-master keys - if key_project_id and key_project_id != "*": - if "_" in key_project_id: - key_plugin_type = key_project_id.split("_")[0] - if self.config.plugin_types and key_plugin_type not in self.config.plugin_types: - raise ToolError( - f"API key for {key_plugin_type} cannot access {self.config.name}" - ) - - # Check tool blacklist - if not self.config.allows_tool(tool_name): - raise ToolError(f"Access denied to tool: {tool_name}") - - # Set context - if key_id: - set_api_key_context( - key_id=key_id, - project_id=key_project_id or "*", - scope=key_scope, - is_global=key_project_id == "*", - ) - - # Execute tool - result = await call_next(context) - - # Clear context - clear_api_key_context() - - return result - - except ToolError: - raise - except Exception as e: - logger.error(f"Auth error in {tool_name}: {e}") - raise ToolError(f"Authentication error: {str(e)}") - - -def create_dynamic_tool( - name: str, description: str, handler: Callable, input_schema: dict | None = None -) -> Callable: - """Create a dynamic tool function with proper signature""" - - # Build parameters - params = [] - annotations = {} - - if input_schema and "properties" in input_schema: - required = input_schema.get("required", []) - - for param_name, param_info in input_schema["properties"].items(): - param_type = param_info.get("type", "string") - type_map = { - "string": str, - "integer": int, - "number": float, - "boolean": bool, - "array": list, - "object": dict, - } - py_type = type_map.get(param_type, str) - - if param_name not in required: - default_value = param_info.get("default", None) - annotations[param_name] = Optional[py_type] - params.append( - inspect.Parameter( - param_name, - inspect.Parameter.POSITIONAL_OR_KEYWORD, - default=default_value, - annotation=Optional[py_type], - ) - ) - else: - annotations[param_name] = py_type - params.append( - inspect.Parameter( - param_name, inspect.Parameter.POSITIONAL_OR_KEYWORD, annotation=py_type - ) - ) - - # Create wrapper using closure (no exec) - async def dynamic_wrapper(**kwargs): - return await handler(**kwargs) - - dynamic_wrapper.__name__ = name - dynamic_wrapper.__qualname__ = name - dynamic_wrapper.__doc__ = description - dynamic_wrapper.__signature__ = inspect.Signature(params) - annotations["return"] = str - dynamic_wrapper.__annotations__ = annotations - - return dynamic_wrapper - - -def get_tools_for_endpoint(config: EndpointConfig) -> list[ToolDefinition]: - """Get tools that should be registered for a specific endpoint""" - tools = [] - - for tool_def in tool_registry.get_all(): - tool_name = tool_def.name - - # Check plugin type - # Order matters: check more specific prefixes first - plugin_type = None - if tool_name.startswith("wordpress_advanced_"): - plugin_type = "wordpress_advanced" - elif tool_name.startswith("woocommerce_"): - plugin_type = "woocommerce" - elif tool_name.startswith("wordpress_"): - plugin_type = "wordpress" - elif tool_name.startswith("gitea_"): - plugin_type = "gitea" - - # Filter by plugin type - if config.plugin_types and plugin_type: - if plugin_type not in config.plugin_types: - continue - - # Check blacklist - if not config.allows_tool(tool_name): - continue - - tools.append(tool_def) - - return tools - - -def create_mcp_endpoint(config: EndpointConfig) -> FastMCP: - """Create a FastMCP instance for a specific endpoint""" - mcp = FastMCP(config.name) - - # Add authentication middleware - mcp.add_middleware(EndpointAuthMiddleware(config)) - - # Get tools for this endpoint - tools = get_tools_for_endpoint(config) - - logger.info(f"Creating endpoint {config.path}: {len(tools)} tools") - - # Register tools - for tool_def in tools: - try: - wrapped = create_dynamic_tool( - tool_def.name, tool_def.description, tool_def.handler, tool_def.input_schema - ) - mcp.tool()(wrapped) - except Exception as e: - logger.error(f"Failed to register tool {tool_def.name}: {e}") - - return mcp - - -# === SYSTEM TOOLS (added to admin endpoint) === - - -def add_system_tools(mcp: FastMCP): - """Add system tools to an MCP instance""" - - @mcp.tool() - async def list_projects() -> str: - """List all discovered projects across all plugins.""" - projects = [] - for full_id, plugin in project_manager.projects.items(): - projects.append( - { - "id": full_id, - "name": plugin.get_plugin_name(), - "type": plugin.get_plugin_name().lower(), - } - ) - return str({"projects": projects, "total": len(projects)}) - - @mcp.tool() - async def get_system_metrics() -> str: - """Get system-wide metrics including uptime and request statistics.""" - from core import get_health_monitor - - monitor = get_health_monitor() - metrics = monitor.get_system_metrics() - return str(metrics) - - @mcp.tool() - async def get_rate_limit_stats(client_id: str = None) -> str: - """Get rate limit statistics.""" - stats = rate_limiter.get_stats(client_id) - return str(stats) - - @mcp.tool() - async def list_endpoints() -> str: - """List all available MCP endpoints including per-project endpoints.""" - endpoints = [] - - # Add predefined endpoints - for _endpoint_type, config in ENDPOINT_CONFIGS.items(): - endpoints.append( - { - "path": config.path, - "name": config.name, - "description": config.description, - "plugin_types": config.plugin_types, - "require_master_key": config.require_master_key, - "type": "predefined", - } - ) - - # Add per-project endpoints - all_sites = site_manager.list_all_sites() - for site_info in all_sites: - plugin_type = site_info["plugin_type"] - site_id = site_info["site_id"] - alias = site_info.get("alias") - full_id = site_info["full_id"] - - # Use alias for path if different from site_id - path_suffix = alias if alias and alias != site_id else full_id - path = f"/mcp/project/{path_suffix}" - - endpoints.append( - { - "path": path, - "name": f"Project: {full_id}", - "description": f"Tools scoped to {plugin_type} project {full_id}", - "plugin_types": [plugin_type], - "require_master_key": False, - "type": "project", - "project_id": full_id, - "alias": alias, - } - ) - - return str({"endpoints": endpoints, "total": len(endpoints)}) - - # API Key management tools - @mcp.tool() - async def manage_api_keys_create( - project_id: str, scope: str = "read", name: str = None, expires_in_days: int = None - ) -> dict: - """Create a new API key for a project.""" - try: - key = api_key_manager.create_key( - project_id=project_id, scope=scope, name=name, expires_in_days=expires_in_days - ) - return { - "success": True, - "key_id": key.key_id, - "api_key": key.api_key, - "project_id": key.project_id, - "scope": key.scope, - "warning": "SAVE THIS KEY - It will not be shown again!", - } - except Exception as e: - return {"success": False, "error": str(e)} - - @mcp.tool() - async def manage_api_keys_list(project_id: str = None) -> dict: - """List API keys.""" - try: - if project_id == "*": - keys = api_key_manager.list_all_keys() - elif project_id: - keys = api_key_manager.list_keys(project_id) - else: - keys = api_key_manager.list_all_keys() - - return { - "success": True, - "keys": [ - { - "key_id": k.key_id, - "project_id": k.project_id, - "scope": k.scope, - "name": k.name, - "revoked": k.revoked, - } - for k in keys - ], - "total": len(keys), - } - except Exception as e: - return {"success": False, "error": str(e)} - - @mcp.tool() - async def manage_api_keys_revoke(key_id: str) -> dict: - """Revoke an API key.""" - try: - result = api_key_manager.revoke_key(key_id) - return {"success": result, "key_id": key_id} - except Exception as e: - return {"success": False, "error": str(e)} - - # OAuth tools - @mcp.tool() - async def oauth_list_clients() -> dict: - """List registered OAuth clients.""" - try: - clients = oauth_server.client_registry.list_clients() - return { - "success": True, - "clients": [ - { - "client_id": c.client_id, - "client_name": c.client_name, - "redirect_uris": c.redirect_uris, - } - for c in clients - ], - "total": len(clients), - } - except Exception as e: - return {"success": False, "error": str(e)} - - @mcp.tool() - async def oauth_revoke_client(client_id: str) -> dict: - """Revoke an OAuth client.""" - try: - result = oauth_server.client_registry.revoke_client(client_id) - return {"success": result, "client_id": client_id} - except Exception as e: - return {"success": False, "error": str(e)} - - -# === HTTP ROUTES === - - -async def health_check(request: Request) -> JSONResponse: - """Health check endpoint""" - return JSONResponse( - { - "status": "healthy", - "uptime": int(time.time() - server_start_time), - "version": "2.1.0", - "architecture": "multi-endpoint", - "phase": "D.1 - WooCommerce Split", - "endpoints": list(ENDPOINT_CONFIGS.keys()), - } - ) - - -async def endpoint_info(request: Request) -> JSONResponse: - """List all available endpoints including per-project endpoints""" - endpoints = [] - - # Add predefined endpoints - # Note: FastMCP adds /mcp to the mount path automatically - for _endpoint_type, config in ENDPOINT_CONFIGS.items(): - # Convert mount path to actual MCP path - if config.path == "/": - mcp_path = "/mcp" - else: - mcp_path = f"{config.path}/mcp" - - endpoints.append( - { - "path": mcp_path, - "mount_path": config.path, - "name": config.name, - "description": config.description, - "plugin_types": config.plugin_types, - "require_master_key": config.require_master_key, - "type": "predefined", - } - ) - - # Add per-project endpoints - all_sites = site_manager.list_all_sites() - for site_info in all_sites: - plugin_type = site_info["plugin_type"] - site_info["site_id"] - alias = site_info.get("alias") - full_id = site_info["full_id"] - - # Use effective path suffix (handles duplicate alias conflicts) - path_suffix = site_manager.get_effective_path_suffix(full_id) - mount_path = f"/project/{path_suffix}" - mcp_path = f"/project/{path_suffix}/mcp" - - endpoints.append( - { - "path": mcp_path, - "mount_path": mount_path, - "name": f"Project: {full_id}", - "description": f"Tools scoped to {plugin_type} project {full_id}", - "plugin_types": [plugin_type], - "require_master_key": False, - "type": "project", - "project_id": full_id, - "alias": alias, - "effective_path": path_suffix, - } - ) - - # Add alias conflicts info - alias_conflicts = site_manager.get_alias_conflicts() - - return JSONResponse( - { - "endpoints": endpoints, - "total": len(endpoints), - "alias_conflicts": alias_conflicts if alias_conflicts else None, - } - ) - - -# OAuth endpoints (imported from server.py patterns) -async def oauth_metadata(request: Request) -> JSONResponse: - """OAuth 2.0 Authorization Server Metadata (RFC 8414)""" - base_url = str(request.base_url).rstrip("/") - return JSONResponse( - { - "issuer": base_url, - "authorization_endpoint": f"{base_url}/oauth/authorize", - "token_endpoint": f"{base_url}/oauth/token", - "registration_endpoint": f"{base_url}/oauth/register", - "response_types_supported": ["code"], - "grant_types_supported": ["authorization_code", "refresh_token"], - "code_challenge_methods_supported": ["S256"], - "token_endpoint_auth_methods_supported": ["client_secret_post", "client_secret_basic"], - "scopes_supported": ["read", "write", "admin"], - } - ) - - -async def oauth_protected_resource(request: Request) -> JSONResponse: - """OAuth 2.0 Protected Resource Metadata (RFC 9728)""" - base_url = str(request.base_url).rstrip("/") - return JSONResponse( - { - "resource": base_url, - "authorization_servers": [base_url], - "scopes_supported": ["read", "write", "admin"], - "bearer_methods_supported": ["header"], - } - ) - - -async def oauth_register(request: Request) -> JSONResponse: - """ - Dynamic Client Registration (RFC 7591) - MCP Spec Compliant - - Supports two modes: - 1. Open DCR: For trusted MCP clients (Claude, ChatGPT) - no auth required - 2. Protected DCR: For custom redirect_uris - Master API Key required - """ - client_ip = request.client.host if request.client else "unknown" - - try: - data = await request.json() - except Exception: - return JSONResponse( - {"error": "invalid_request", "error_description": "Invalid JSON"}, status_code=400 - ) - - redirect_uris = data.get("redirect_uris", []) - if not redirect_uris or not isinstance(redirect_uris, list): - return JSONResponse( - {"error": "invalid_redirect_uri", "error_description": "redirect_uris required"}, - status_code=400, - ) - - # Check if open DCR is allowed for these redirect_uris - is_open_dcr_allowed = is_redirect_uri_allowed_for_open_dcr(redirect_uris) - - # Check for Master API Key - auth_header = request.headers.get("authorization", "") - token = auth_header[7:] if auth_header.startswith("Bearer ") else None - has_valid_master_key = token and auth_manager.validate_master_key(token) - - if is_open_dcr_allowed: - # Open DCR - check rate limiting - rate_ok, rate_error = check_dcr_rate_limit(client_ip) - if not rate_ok: - logger.warning(f"DCR rate limit exceeded for {client_ip}") - return JSONResponse( - {"error": "too_many_requests", "error_description": rate_error}, status_code=429 - ) - logger.info(f"Open DCR registration from {client_ip} for {redirect_uris}") - audit_logger.log_event( - event_type="oauth_dcr_open", - details={ - "client_ip": client_ip, - "redirect_uris": redirect_uris, - "client_name": data.get("client_name", "Unknown"), - "auth_mode": "open_dcr", - }, - ) - elif has_valid_master_key: - logger.info(f"Protected DCR registration from {client_ip} with Master API Key") - audit_logger.log_event( - event_type="oauth_dcr_protected", - details={ - "client_ip": client_ip, - "redirect_uris": redirect_uris, - "client_name": data.get("client_name", "Unknown"), - "auth_mode": "master_key", - }, - ) - else: - logger.warning( - f"Unauthorized DCR attempt from {client_ip}: {redirect_uris} not in allowlist" - ) - return JSONResponse( - { - "error": "unauthorized", - "error_description": f"DCR requires trusted redirect_uri (Claude, ChatGPT) or Master API Key. " - f"Your redirect_uris {redirect_uris} are not allowed.", - }, - status_code=401, - ) - - try: - client = oauth_server.register_client( - client_name=data.get("client_name", "Unknown"), - redirect_uris=redirect_uris, - scope=data.get("scope", "read"), - ) - return JSONResponse( - { - "client_id": client.client_id, - "client_secret": client.client_secret, - "client_name": client.client_name, - "redirect_uris": client.redirect_uris, - }, - status_code=201, - ) - except Exception as e: - logger.error(f"DCR error: {e}") - return JSONResponse({"error": "server_error", "error_description": str(e)}, status_code=400) - - -async def oauth_authorize(request: Request): - """OAuth Authorization Endpoint""" - client_id = request.query_params.get("client_id") - redirect_uri = request.query_params.get("redirect_uri") - scope = request.query_params.get("scope", "read") - state = request.query_params.get("state", "") - code_challenge = request.query_params.get("code_challenge") - - # Validate client - client = oauth_server.client_registry.get_client(client_id) - if not client: - return HTMLResponse("