chore(F.15): FastMCP 3.x upgrade + legacy cleanup — v3.5.0
- Upgrade fastmcp from >=2.14.0,<3.0.0 to >=3.0.0,<4.0.0 - Remove server_multi.py (legacy multi-endpoint server) - Remove core/project_manager.py (legacy project manager) - Refactor HealthMonitor to use SiteManager exclusively - Replace _tool_manager._tools with tracked _tool_counts dict - Clean core/__init__.py exports - Update CHANGELOG, CLAUDE.md, tests - Auto-format fixes (black + ruff) Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
19
CHANGELOG.md
19
CHANGELOG.md
@@ -5,6 +5,25 @@ All notable changes to MCP Hub will be documented in this file.
|
|||||||
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
|
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
|
||||||
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||||
|
|
||||||
|
## [3.5.0] — 2026-04-02
|
||||||
|
|
||||||
|
### FastMCP Upgrade & Legacy Cleanup (Track F.15)
|
||||||
|
|
||||||
|
Major cleanup release: upgraded to FastMCP 3.x, removed all legacy modules, and simplified the architecture.
|
||||||
|
|
||||||
|
#### Changed
|
||||||
|
- **FastMCP 3.x**: Upgraded from `fastmcp>=2.14.0,<3.0.0` to `fastmcp>=3.0.0,<4.0.0`
|
||||||
|
- **HealthMonitor refactored**: Removed `ProjectManager` dependency — now uses `SiteManager` exclusively for site discovery and health checks
|
||||||
|
- **Endpoint introspection**: Replaced internal `_tool_manager._tools` access with tracked `_tool_counts` dict in `MCPEndpointFactory`
|
||||||
|
|
||||||
|
#### Removed
|
||||||
|
- **`server_multi.py`**: Legacy multi-endpoint server entry point (replaced by unified `server.py` since v3.0)
|
||||||
|
- **`core/project_manager.py`**: Legacy project manager shell — HealthMonitor no longer depends on it
|
||||||
|
- **Legacy exports**: Removed `ProjectManager` and `get_project_manager` from `core/__init__.py`
|
||||||
|
- References from `pyproject.toml` (py-modules, ruff per-file-ignores), community build script, and documentation
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## [3.4.0] — 2026-03-31
|
## [3.4.0] — 2026-03-31
|
||||||
|
|
||||||
### OpenPanel Plugin — Public Release (Track F.10)
|
### OpenPanel Plugin — Public Release (Track F.10)
|
||||||
|
|||||||
@@ -69,7 +69,6 @@ All configured in `pyproject.toml`:
|
|||||||
|
|
||||||
```
|
```
|
||||||
├── server.py # Primary entry point
|
├── server.py # Primary entry point
|
||||||
├── server_multi.py # Alternative multi-endpoint server
|
|
||||||
├── core/ # Layer 1: Core system modules
|
├── core/ # Layer 1: Core system modules
|
||||||
├── plugins/ # Layer 2: Plugin system (9 plugins)
|
├── plugins/ # Layer 2: Plugin system (9 plugins)
|
||||||
├── core/templates/ # Jinja2 templates (dashboard + OAuth)
|
├── core/templates/ # Jinja2 templates (dashboard + OAuth)
|
||||||
@@ -93,8 +92,6 @@ Layer 3: API & Web UI (server.py + core/dashboard/) — FastMCP server, Starle
|
|||||||
### Entry Points
|
### Entry Points
|
||||||
|
|
||||||
- **`server.py`** (~3500 lines) — Primary entry point. Handles FastMCP server, Starlette routes, middleware, plugins.
|
- **`server.py`** (~3500 lines) — Primary entry point. Handles FastMCP server, Starlette routes, middleware, plugins.
|
||||||
- **`server_multi.py`** — Alternative multi-endpoint server (legacy, predates unified `server.py` endpoints).
|
|
||||||
|
|
||||||
### Multi-Endpoint Architecture
|
### Multi-Endpoint Architecture
|
||||||
|
|
||||||
```
|
```
|
||||||
@@ -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.
|
**Admin pages**: Login, Home, Projects, API Keys, OAuth Clients, Audit Logs, Health, Settings.
|
||||||
**User pages**: Login (OAuth), Home, My Sites (list/add/edit/test/delete), Connect (API keys + config snippets + OAuth clients), Profile.
|
**User pages**: Login (OAuth), Home, My Sites (list/add/edit/test/delete), Connect (API keys + config snippets + OAuth clients), Profile.
|
||||||
|
|
||||||
### Legacy Modules (Deprecated)
|
|
||||||
|
|
||||||
`core/project_manager.py` (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
|
## Commit Style
|
||||||
|
|
||||||
```
|
```
|
||||||
@@ -197,7 +190,6 @@ v4 development cycle with 15 phases. See `docs/ROADMAP.md` (Track F) and `.claud
|
|||||||
## Gotchas
|
## Gotchas
|
||||||
|
|
||||||
- Test files exist in both `tests/` (proper) and root directory (legacy `test_*.py`). Run `pytest tests/` for organized tests only.
|
- Test files exist in both `tests/` (proper) and root directory (legacy `test_*.py`). Run `pytest tests/` for organized tests only.
|
||||||
- `server_multi.py` is the alternative multi-endpoint entry point; `server.py` is the primary
|
|
||||||
- `wordpress-plugin/` contains companion WP plugins (openpanel, airano-mcp-seo-bridge) — these are PHP, not Python
|
- `wordpress-plugin/` contains companion WP plugins (openpanel, airano-mcp-seo-bridge) — these are PHP, not Python
|
||||||
- `env.example` has "FUTURE" labels for Supabase/Gitea but both are fully implemented
|
- `env.example` has "FUTURE" labels for Supabase/Gitea but both are fully implemented
|
||||||
- 4 plugins are tested for public use: WordPress, WooCommerce, Supabase, OpenPanel. Others are admin-only or disabled.
|
- 4 plugins are tested for public use: WordPress, WooCommerce, Supabase, OpenPanel. Others are admin-only or disabled.
|
||||||
|
|||||||
@@ -37,14 +37,9 @@ from core.health import (
|
|||||||
initialize_health_monitor,
|
initialize_health_monitor,
|
||||||
)
|
)
|
||||||
|
|
||||||
# Project and Site Management
|
|
||||||
from core.project_manager import ProjectManager, get_project_manager
|
|
||||||
|
|
||||||
# Rate Limiting
|
# Rate Limiting
|
||||||
from core.rate_limiter import RateLimitConfig, RateLimiter, get_rate_limiter
|
from core.rate_limiter import RateLimitConfig, RateLimiter, get_rate_limiter
|
||||||
from core.site_manager import SiteConfig, SiteManager, get_site_manager
|
from core.site_manager import SiteConfig, SiteManager, get_site_manager
|
||||||
|
|
||||||
# Legacy (kept for backward compatibility, will be removed in v2.0)
|
|
||||||
from core.tool_generator import ToolGenerator
|
from core.tool_generator import ToolGenerator
|
||||||
|
|
||||||
# Tool Management (Option B architecture)
|
# Tool Management (Option B architecture)
|
||||||
@@ -56,9 +51,7 @@ __all__ = [
|
|||||||
"get_auth_manager",
|
"get_auth_manager",
|
||||||
"APIKeyManager",
|
"APIKeyManager",
|
||||||
"get_api_key_manager",
|
"get_api_key_manager",
|
||||||
# Project/Site Management
|
# Site Management
|
||||||
"ProjectManager",
|
|
||||||
"get_project_manager",
|
|
||||||
"SiteManager",
|
"SiteManager",
|
||||||
"SiteConfig",
|
"SiteConfig",
|
||||||
"get_site_manager",
|
"get_site_manager",
|
||||||
|
|||||||
@@ -10,12 +10,12 @@ import json
|
|||||||
import logging
|
import logging
|
||||||
import os
|
import os
|
||||||
import secrets
|
import secrets
|
||||||
|
|
||||||
import bcrypt
|
|
||||||
from dataclasses import asdict, dataclass
|
from dataclasses import asdict, dataclass
|
||||||
from datetime import datetime, timedelta
|
from datetime import datetime, timedelta
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
|
import bcrypt
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -850,9 +850,7 @@ async def get_all_projects(
|
|||||||
is_admin = False
|
is_admin = False
|
||||||
current_user_id = None
|
current_user_id = None
|
||||||
if user_session:
|
if user_session:
|
||||||
if hasattr(user_session, "user_type") and user_session.user_type == "master":
|
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_session.get("role") == "admin":
|
|
||||||
is_admin = True
|
is_admin = True
|
||||||
elif isinstance(user_session, dict) and "user_id" in user_session:
|
elif isinstance(user_session, dict) and "user_id" in user_session:
|
||||||
current_user_id = user_session["user_id"]
|
current_user_id = user_session["user_id"]
|
||||||
|
|||||||
@@ -48,6 +48,7 @@ class MCPEndpointFactory:
|
|||||||
self.tool_registry = tool_registry
|
self.tool_registry = tool_registry
|
||||||
self.middleware_classes = middleware_classes or []
|
self.middleware_classes = middleware_classes or []
|
||||||
self.endpoints: dict[str, FastMCP] = {}
|
self.endpoints: dict[str, FastMCP] = {}
|
||||||
|
self._tool_counts: dict[str, int] = {}
|
||||||
self._tool_handlers: dict[str, Callable] = {}
|
self._tool_handlers: dict[str, Callable] = {}
|
||||||
|
|
||||||
def register_tool_handler(self, tool_name: str, handler: Callable):
|
def register_tool_handler(self, tool_name: str, handler: Callable):
|
||||||
@@ -92,8 +93,9 @@ class MCPEndpointFactory:
|
|||||||
for middleware in custom_middleware:
|
for middleware in custom_middleware:
|
||||||
mcp.add_middleware(middleware)
|
mcp.add_middleware(middleware)
|
||||||
|
|
||||||
# Store endpoint
|
# Store endpoint and tool count
|
||||||
self.endpoints[config.path] = mcp
|
self.endpoints[config.path] = mcp
|
||||||
|
self._tool_counts[config.path] = len(tools)
|
||||||
|
|
||||||
logger.info(f" - Endpoint {config.path} created successfully")
|
logger.info(f" - Endpoint {config.path} created successfully")
|
||||||
|
|
||||||
@@ -282,9 +284,7 @@ class MCPEndpointFactory:
|
|||||||
"""
|
"""
|
||||||
info = []
|
info = []
|
||||||
for path, mcp in self.endpoints.items():
|
for path, mcp in self.endpoints.items():
|
||||||
# Get tool count
|
tool_count = self._tool_counts.get(path, 0)
|
||||||
# Note: This requires accessing FastMCP internals
|
|
||||||
tool_count = len(mcp._tool_manager._tools) if hasattr(mcp, "_tool_manager") else 0
|
|
||||||
|
|
||||||
info.append(
|
info.append(
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -149,11 +149,7 @@ class EndpointRegistry:
|
|||||||
tool_count = 0
|
tool_count = 0
|
||||||
|
|
||||||
if mcp:
|
if mcp:
|
||||||
# Try to get tool count from FastMCP
|
tool_count = self.factory._tool_counts.get(path, 0)
|
||||||
try:
|
|
||||||
tool_count = len(mcp._tool_manager._tools)
|
|
||||||
except AttributeError:
|
|
||||||
pass
|
|
||||||
|
|
||||||
endpoints.append(
|
endpoints.append(
|
||||||
EndpointInfo(
|
EndpointInfo(
|
||||||
|
|||||||
@@ -23,7 +23,6 @@ from pathlib import Path
|
|||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
from core.audit_log import AuditLogger
|
from core.audit_log import AuditLogger
|
||||||
from core.project_manager import ProjectManager
|
|
||||||
from core.site_manager import SiteManager
|
from core.site_manager import SiteManager
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
@@ -130,7 +129,6 @@ class HealthMonitor:
|
|||||||
|
|
||||||
def __init__(
|
def __init__(
|
||||||
self,
|
self,
|
||||||
project_manager: ProjectManager,
|
|
||||||
audit_logger: AuditLogger | None = None,
|
audit_logger: AuditLogger | None = None,
|
||||||
metrics_retention_hours: int = 24,
|
metrics_retention_hours: int = 24,
|
||||||
max_metrics_per_project: int = 1000,
|
max_metrics_per_project: int = 1000,
|
||||||
@@ -140,13 +138,11 @@ class HealthMonitor:
|
|||||||
Initialize health monitor.
|
Initialize health monitor.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
project_manager: Project manager instance
|
|
||||||
audit_logger: Optional audit logger for logging health events
|
audit_logger: Optional audit logger for logging health events
|
||||||
metrics_retention_hours: Hours to retain historical metrics
|
metrics_retention_hours: Hours to retain historical metrics
|
||||||
max_metrics_per_project: Maximum metrics to store per project
|
max_metrics_per_project: Maximum metrics to store per project
|
||||||
site_manager: Optional SiteManager for comprehensive site discovery
|
site_manager: SiteManager for site discovery
|
||||||
"""
|
"""
|
||||||
self.project_manager = project_manager
|
|
||||||
self.site_manager = site_manager
|
self.site_manager = site_manager
|
||||||
self.audit_logger = audit_logger
|
self.audit_logger = audit_logger
|
||||||
self.metrics_retention_hours = metrics_retention_hours
|
self.metrics_retention_hours = metrics_retention_hours
|
||||||
@@ -410,15 +406,7 @@ class HealthMonitor:
|
|||||||
start_time = time.time()
|
start_time = time.time()
|
||||||
|
|
||||||
try:
|
try:
|
||||||
# Get plugin instance from ProjectManager
|
if self.site_manager:
|
||||||
plugin = self.project_manager.projects.get(project_id)
|
|
||||||
|
|
||||||
if plugin:
|
|
||||||
# Perform health check via plugin instance
|
|
||||||
health_result = await plugin.health_check()
|
|
||||||
elif self.site_manager:
|
|
||||||
# Site exists in SiteManager but not legacy ProjectManager
|
|
||||||
# Create a temporary plugin instance for a proper health check
|
|
||||||
health_result = await self._site_manager_health_check(project_id)
|
health_result = await self._site_manager_health_check(project_id)
|
||||||
else:
|
else:
|
||||||
return ProjectHealthStatus(
|
return ProjectHealthStatus(
|
||||||
@@ -524,7 +512,7 @@ class HealthMonitor:
|
|||||||
|
|
||||||
async def _site_manager_health_check(self, project_id: str) -> dict[str, Any]:
|
async def _site_manager_health_check(self, project_id: str) -> dict[str, Any]:
|
||||||
"""
|
"""
|
||||||
Health check for sites managed by SiteManager (not in legacy ProjectManager).
|
Health check for a site via SiteManager.
|
||||||
|
|
||||||
Creates a temporary plugin instance and calls its health_check() method,
|
Creates a temporary plugin instance and calls its health_check() method,
|
||||||
falling back to a basic HTTP check if plugin instantiation fails.
|
falling back to a basic HTTP check if plugin instantiation fails.
|
||||||
@@ -650,8 +638,8 @@ class HealthMonitor:
|
|||||||
"""
|
"""
|
||||||
health_statuses = {}
|
health_statuses = {}
|
||||||
|
|
||||||
# Collect all known project/site IDs from both sources
|
# Collect all known site IDs from SiteManager
|
||||||
all_project_ids = set(self.project_manager.projects.keys())
|
all_project_ids = set()
|
||||||
if self.site_manager:
|
if self.site_manager:
|
||||||
for site_info in self.site_manager.list_all_sites():
|
for site_info in self.site_manager.list_all_sites():
|
||||||
all_project_ids.add(site_info["full_id"])
|
all_project_ids.add(site_info["full_id"])
|
||||||
@@ -860,7 +848,6 @@ def get_health_monitor() -> HealthMonitor | None:
|
|||||||
|
|
||||||
|
|
||||||
def initialize_health_monitor(
|
def initialize_health_monitor(
|
||||||
project_manager: ProjectManager,
|
|
||||||
audit_logger: AuditLogger | None = None,
|
audit_logger: AuditLogger | None = None,
|
||||||
site_manager: SiteManager | None = None,
|
site_manager: SiteManager | None = None,
|
||||||
**kwargs,
|
**kwargs,
|
||||||
@@ -869,16 +856,13 @@ def initialize_health_monitor(
|
|||||||
Initialize the global health monitor.
|
Initialize the global health monitor.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
project_manager: Project manager instance
|
|
||||||
audit_logger: Optional audit logger
|
audit_logger: Optional audit logger
|
||||||
site_manager: Optional SiteManager for comprehensive site discovery
|
site_manager: SiteManager for site discovery
|
||||||
**kwargs: Additional configuration options
|
**kwargs: Additional configuration options
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
HealthMonitor instance
|
HealthMonitor instance
|
||||||
"""
|
"""
|
||||||
global _health_monitor
|
global _health_monitor
|
||||||
_health_monitor = HealthMonitor(
|
_health_monitor = HealthMonitor(audit_logger=audit_logger, site_manager=site_manager, **kwargs)
|
||||||
project_manager, audit_logger, site_manager=site_manager, **kwargs
|
|
||||||
)
|
|
||||||
return _health_monitor
|
return _health_monitor
|
||||||
|
|||||||
@@ -1,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
|
|
||||||
@@ -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)
|
return json.dumps({"success": False, "error": str(e)}, indent=2, ensure_ascii=False)
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
async def search_users(
|
async def search_users(
|
||||||
client: SupabaseClient, query: str, page: int = 1, per_page: int = 50
|
client: SupabaseClient, query: str, page: int = 1, per_page: int = 50
|
||||||
) -> str:
|
) -> str:
|
||||||
|
|||||||
@@ -77,8 +77,12 @@ class WPCLIManager:
|
|||||||
try:
|
try:
|
||||||
# First, test if we have Docker socket access
|
# First, test if we have Docker socket access
|
||||||
test_process = await asyncio.create_subprocess_exec(
|
test_process = await asyncio.create_subprocess_exec(
|
||||||
"docker", "version", "--format", "{{.Server.Version}}",
|
"docker",
|
||||||
stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE
|
"version",
|
||||||
|
"--format",
|
||||||
|
"{{.Server.Version}}",
|
||||||
|
stdout=asyncio.subprocess.PIPE,
|
||||||
|
stderr=asyncio.subprocess.PIPE,
|
||||||
)
|
)
|
||||||
|
|
||||||
test_stdout, test_stderr = await asyncio.wait_for(
|
test_stdout, test_stderr = await asyncio.wait_for(
|
||||||
@@ -99,10 +103,15 @@ class WPCLIManager:
|
|||||||
|
|
||||||
# Now check for our specific container using exact name match
|
# Now check for our specific container using exact name match
|
||||||
process = await asyncio.create_subprocess_exec(
|
process = await asyncio.create_subprocess_exec(
|
||||||
"docker", "ps", "--all",
|
"docker",
|
||||||
"--filter", f"name=^{self.container_name}$",
|
"ps",
|
||||||
"--format", "{{.Names}}|{{.Status}}",
|
"--all",
|
||||||
stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE
|
"--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)
|
stdout, stderr = await asyncio.wait_for(process.communicate(), timeout=5.0)
|
||||||
@@ -117,8 +126,13 @@ class WPCLIManager:
|
|||||||
if not output:
|
if not output:
|
||||||
# Container not found - get list of available containers for helpful error
|
# Container not found - get list of available containers for helpful error
|
||||||
list_process = await asyncio.create_subprocess_exec(
|
list_process = await asyncio.create_subprocess_exec(
|
||||||
"docker", "ps", "--all", "--format", "{{.Names}}",
|
"docker",
|
||||||
stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE
|
"ps",
|
||||||
|
"--all",
|
||||||
|
"--format",
|
||||||
|
"{{.Names}}",
|
||||||
|
stdout=asyncio.subprocess.PIPE,
|
||||||
|
stderr=asyncio.subprocess.PIPE,
|
||||||
)
|
)
|
||||||
list_stdout, _ = await list_process.communicate()
|
list_stdout, _ = await list_process.communicate()
|
||||||
available = list_stdout.decode().strip().split("\n") if list_stdout else []
|
available = list_stdout.decode().strip().split("\n") if list_stdout else []
|
||||||
@@ -170,9 +184,14 @@ class WPCLIManager:
|
|||||||
try:
|
try:
|
||||||
# Try to run wp --version
|
# Try to run wp --version
|
||||||
process = await asyncio.create_subprocess_exec(
|
process = await asyncio.create_subprocess_exec(
|
||||||
"docker", "exec", self.container_name,
|
"docker",
|
||||||
"wp", "--version", "--allow-root",
|
"exec",
|
||||||
stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE
|
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)
|
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)
|
# 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)}")
|
self.logger.info(f"Executing: {' '.join(cmd_parts)}")
|
||||||
|
|
||||||
@@ -582,17 +603,29 @@ class WPCLIManager:
|
|||||||
try:
|
try:
|
||||||
# Try GNU stat first, fall back to BSD stat
|
# Try GNU stat first, fall back to BSD stat
|
||||||
process = await asyncio.create_subprocess_exec(
|
process = await asyncio.create_subprocess_exec(
|
||||||
"docker", "exec", self.container_name,
|
"docker",
|
||||||
"stat", "-c", "%s", export_path,
|
"exec",
|
||||||
stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE
|
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)
|
stdout, _ = await asyncio.wait_for(process.communicate(), timeout=5.0)
|
||||||
if process.returncode != 0:
|
if process.returncode != 0:
|
||||||
# BSD stat fallback
|
# BSD stat fallback
|
||||||
process = await asyncio.create_subprocess_exec(
|
process = await asyncio.create_subprocess_exec(
|
||||||
"docker", "exec", self.container_name,
|
"docker",
|
||||||
"stat", "-f", "%z", export_path,
|
"exec",
|
||||||
stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE
|
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)
|
stdout, _ = await asyncio.wait_for(process.communicate(), timeout=5.0)
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
[project]
|
[project]
|
||||||
name = "mcphub-server"
|
name = "mcphub-server"
|
||||||
version = "3.4.0"
|
version = "3.5.0"
|
||||||
description = "AI-native management hub for WordPress, WooCommerce, and self-hosted services via Model Context Protocol (MCP)"
|
description = "AI-native management hub for WordPress, WooCommerce, and self-hosted services via Model Context Protocol (MCP)"
|
||||||
authors = [
|
authors = [
|
||||||
{name = "MCP Hub", email = "contact@mcphub.dev"}
|
{name = "MCP Hub", email = "contact@mcphub.dev"}
|
||||||
@@ -28,7 +28,7 @@ classifiers = [
|
|||||||
]
|
]
|
||||||
|
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"fastmcp>=2.14.0,<3.0.0",
|
"fastmcp>=3.0.0,<4.0.0",
|
||||||
"httpx>=0.25.0",
|
"httpx>=0.25.0",
|
||||||
"aiohttp>=3.9.0",
|
"aiohttp>=3.9.0",
|
||||||
"pydantic>=2.5.0",
|
"pydantic>=2.5.0",
|
||||||
@@ -69,7 +69,7 @@ requires = ["setuptools>=68.0", "wheel"]
|
|||||||
build-backend = "setuptools.build_meta"
|
build-backend = "setuptools.build_meta"
|
||||||
|
|
||||||
[tool.setuptools]
|
[tool.setuptools]
|
||||||
py-modules = ["server", "server_multi"]
|
py-modules = ["server"]
|
||||||
|
|
||||||
[tool.setuptools.packages.find]
|
[tool.setuptools.packages.find]
|
||||||
include = ["core*", "plugins*"]
|
include = ["core*", "plugins*"]
|
||||||
@@ -208,7 +208,6 @@ unfixable = []
|
|||||||
"tests/*" = ["S101", "B007"]
|
"tests/*" = ["S101", "B007"]
|
||||||
"tests/legacy/*" = ["E402", "B007", "F401"]
|
"tests/legacy/*" = ["E402", "B007", "F401"]
|
||||||
"server.py" = ["E402", "F405", "F403", "UP045", "B023"] # Late imports + star imports + dynamic types
|
"server.py" = ["E402", "F405", "F403", "UP045", "B023"] # Late imports + star imports + dynamic types
|
||||||
"server_multi.py" = ["E402", "F405", "F403", "UP045"]
|
|
||||||
"core/__init__.py" = ["F401", "F403"] # Re-exports
|
"core/__init__.py" = ["F401", "F403"] # Re-exports
|
||||||
"plugins/__init__.py" = ["F401"]
|
"plugins/__init__.py" = ["F401"]
|
||||||
"plugins/*/__init__.py" = ["F401", "F403", "F405"]
|
"plugins/*/__init__.py" = ["F401", "F403", "F405"]
|
||||||
|
|||||||
16
server.py
16
server.py
@@ -51,7 +51,6 @@ from core import (
|
|||||||
get_api_key_manager,
|
get_api_key_manager,
|
||||||
get_audit_logger,
|
get_audit_logger,
|
||||||
get_auth_manager,
|
get_auth_manager,
|
||||||
get_project_manager,
|
|
||||||
get_rate_limiter,
|
get_rate_limiter,
|
||||||
# Core architecture modules
|
# Core architecture modules
|
||||||
get_site_manager,
|
get_site_manager,
|
||||||
@@ -67,6 +66,8 @@ from core.dashboard.routes import (
|
|||||||
api_get_config,
|
api_get_config,
|
||||||
api_list_keys,
|
api_list_keys,
|
||||||
api_list_sites,
|
api_list_sites,
|
||||||
|
# K.5: Settings routes
|
||||||
|
api_save_setting,
|
||||||
api_test_site,
|
api_test_site,
|
||||||
api_update_site,
|
api_update_site,
|
||||||
# E.2: OAuth Social Login routes
|
# E.2: OAuth Social Login routes
|
||||||
@@ -108,8 +109,6 @@ from core.dashboard.routes import (
|
|||||||
# F.3: Service pages
|
# F.3: Service pages
|
||||||
dashboard_service_page,
|
dashboard_service_page,
|
||||||
dashboard_services_list,
|
dashboard_services_list,
|
||||||
# K.5: Settings routes
|
|
||||||
api_save_setting,
|
|
||||||
dashboard_settings_page,
|
dashboard_settings_page,
|
||||||
# E.3: Site Management pages
|
# E.3: Site Management pages
|
||||||
dashboard_sites_add,
|
dashboard_sites_add,
|
||||||
@@ -299,7 +298,6 @@ logger.info("Jinja2 template engine initialized")
|
|||||||
# Initialize managers
|
# Initialize managers
|
||||||
auth_manager = get_auth_manager()
|
auth_manager = get_auth_manager()
|
||||||
api_key_manager = get_api_key_manager()
|
api_key_manager = get_api_key_manager()
|
||||||
project_manager = get_project_manager()
|
|
||||||
audit_logger = get_audit_logger()
|
audit_logger = get_audit_logger()
|
||||||
csrf_manager = get_csrf_manager()
|
csrf_manager = get_csrf_manager()
|
||||||
|
|
||||||
@@ -1106,7 +1104,6 @@ from core import initialize_health_monitor
|
|||||||
|
|
||||||
# Initialize health monitor
|
# Initialize health monitor
|
||||||
health_monitor = initialize_health_monitor(
|
health_monitor = initialize_health_monitor(
|
||||||
project_manager=project_manager,
|
|
||||||
audit_logger=audit_logger,
|
audit_logger=audit_logger,
|
||||||
site_manager=site_manager,
|
site_manager=site_manager,
|
||||||
metrics_retention_hours=24,
|
metrics_retention_hours=24,
|
||||||
@@ -1385,11 +1382,7 @@ async def get_project_info(project_id: str) -> str:
|
|||||||
import json
|
import json
|
||||||
|
|
||||||
try:
|
try:
|
||||||
# Try legacy ProjectManager first
|
info = None
|
||||||
info = project_manager.get_project_info(project_id)
|
|
||||||
|
|
||||||
# Fallback to SiteManager if legacy finds nothing
|
|
||||||
if info is None:
|
|
||||||
for site_data in site_manager.list_all_sites():
|
for site_data in site_manager.list_all_sites():
|
||||||
if site_data["full_id"] == project_id:
|
if site_data["full_id"] == project_id:
|
||||||
info = site_data
|
info = site_data
|
||||||
@@ -3783,8 +3776,7 @@ Use get_endpoints() to see all available MCP endpoints."""
|
|||||||
import json
|
import json
|
||||||
|
|
||||||
try:
|
try:
|
||||||
info = project_manager.get_project_info(project_id)
|
info = None
|
||||||
if info is None:
|
|
||||||
for site_data in site_manager.list_all_sites():
|
for site_data in site_manager.list_all_sites():
|
||||||
if site_data["full_id"] == project_id:
|
if site_data["full_id"] == project_id:
|
||||||
info = site_data
|
info = site_data
|
||||||
|
|||||||
1152
server_multi.py
1152
server_multi.py
File diff suppressed because it is too large
Load Diff
@@ -24,7 +24,7 @@ if sys.platform == "win32":
|
|||||||
# Add project root to path
|
# Add project root to path
|
||||||
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
||||||
|
|
||||||
from core import AuditLogger, ProjectManager, initialize_health_monitor
|
from core import AuditLogger, initialize_health_monitor
|
||||||
|
|
||||||
|
|
||||||
async def test_health_monitor():
|
async def test_health_monitor():
|
||||||
@@ -35,11 +35,9 @@ async def test_health_monitor():
|
|||||||
|
|
||||||
# Initialize components
|
# Initialize components
|
||||||
print("\n1. Initializing components...")
|
print("\n1. Initializing components...")
|
||||||
project_manager = ProjectManager()
|
|
||||||
audit_logger = AuditLogger()
|
audit_logger = AuditLogger()
|
||||||
|
|
||||||
health_monitor = initialize_health_monitor(
|
health_monitor = initialize_health_monitor(
|
||||||
project_manager=project_manager,
|
|
||||||
audit_logger=audit_logger,
|
audit_logger=audit_logger,
|
||||||
metrics_retention_hours=24,
|
metrics_retention_hours=24,
|
||||||
max_metrics_per_project=1000,
|
max_metrics_per_project=1000,
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
"""Tests for Admin System Unification (F.4)."""
|
"""Tests for Admin System Unification (F.4)."""
|
||||||
|
|
||||||
|
|
||||||
from core.dashboard.auth import (
|
from core.dashboard.auth import (
|
||||||
DashboardSession,
|
DashboardSession,
|
||||||
get_session_display_info,
|
get_session_display_info,
|
||||||
|
|||||||
Reference in New Issue
Block a user