chore(F.15): FastMCP 3.x upgrade + legacy cleanup — v3.5.0
Some checks failed
Release / Test before release (push) Has been cancelled
Release / Publish to PyPI (push) Has been cancelled
Release / Publish to Docker Hub (push) Has been cancelled
Release / Create GitHub Release (push) Has been cancelled

- Upgrade fastmcp from >=2.14.0,<3.0.0 to >=3.0.0,<4.0.0
- Remove server_multi.py (legacy multi-endpoint server)
- Remove core/project_manager.py (legacy project manager)
- Refactor HealthMonitor to use SiteManager exclusively
- Replace _tool_manager._tools with tracked _tool_counts dict
- Clean core/__init__.py exports
- Update CHANGELOG, CLAUDE.md, tests
- Auto-format fixes (black + ruff)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-04-02 03:29:38 +02:00
parent c47ec7fd28
commit 1615daf1a7
16 changed files with 102 additions and 1392 deletions

View File

@@ -5,6 +5,25 @@ All notable changes to MCP Hub will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [3.5.0] — 2026-04-02
### FastMCP Upgrade & Legacy Cleanup (Track F.15)
Major cleanup release: upgraded to FastMCP 3.x, removed all legacy modules, and simplified the architecture.
#### Changed
- **FastMCP 3.x**: Upgraded from `fastmcp>=2.14.0,<3.0.0` to `fastmcp>=3.0.0,<4.0.0`
- **HealthMonitor refactored**: Removed `ProjectManager` dependency — now uses `SiteManager` exclusively for site discovery and health checks
- **Endpoint introspection**: Replaced internal `_tool_manager._tools` access with tracked `_tool_counts` dict in `MCPEndpointFactory`
#### Removed
- **`server_multi.py`**: Legacy multi-endpoint server entry point (replaced by unified `server.py` since v3.0)
- **`core/project_manager.py`**: Legacy project manager shell — HealthMonitor no longer depends on it
- **Legacy exports**: Removed `ProjectManager` and `get_project_manager` from `core/__init__.py`
- References from `pyproject.toml` (py-modules, ruff per-file-ignores), community build script, and documentation
---
## [3.4.0] — 2026-03-31
### OpenPanel Plugin — Public Release (Track F.10)

View File

@@ -69,7 +69,6 @@ All configured in `pyproject.toml`:
```
├── server.py # Primary entry point
├── server_multi.py # Alternative multi-endpoint server
├── core/ # Layer 1: Core system modules
├── plugins/ # Layer 2: Plugin system (9 plugins)
├── core/templates/ # Jinja2 templates (dashboard + OAuth)
@@ -93,8 +92,6 @@ Layer 3: API & Web UI (server.py + core/dashboard/) — FastMCP server, Starle
### Entry Points
- **`server.py`** (~3500 lines) — Primary entry point. Handles FastMCP server, Starlette routes, middleware, plugins.
- **`server_multi.py`** — Alternative multi-endpoint server (legacy, predates unified `server.py` endpoints).
### Multi-Endpoint Architecture
```
@@ -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.

View File

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

View File

@@ -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,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"]

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,6 +1,6 @@
[project]
name = "mcphub-server"
version = "3.4.0"
version = "3.5.0"
description = "AI-native management hub for WordPress, WooCommerce, and self-hosted services via Model Context Protocol (MCP)"
authors = [
{name = "MCP Hub", email = "contact@mcphub.dev"}
@@ -28,7 +28,7 @@ classifiers = [
]
dependencies = [
"fastmcp>=2.14.0,<3.0.0",
"fastmcp>=3.0.0,<4.0.0",
"httpx>=0.25.0",
"aiohttp>=3.9.0",
"pydantic>=2.5.0",
@@ -69,7 +69,7 @@ requires = ["setuptools>=68.0", "wheel"]
build-backend = "setuptools.build_meta"
[tool.setuptools]
py-modules = ["server", "server_multi"]
py-modules = ["server"]
[tool.setuptools.packages.find]
include = ["core*", "plugins*"]
@@ -208,7 +208,6 @@ unfixable = []
"tests/*" = ["S101", "B007"]
"tests/legacy/*" = ["E402", "B007", "F401"]
"server.py" = ["E402", "F405", "F403", "UP045", "B023"] # Late imports + star imports + dynamic types
"server_multi.py" = ["E402", "F405", "F403", "UP045"]
"core/__init__.py" = ["F401", "F403"] # Re-exports
"plugins/__init__.py" = ["F401"]
"plugins/*/__init__.py" = ["F401", "F403", "F405"]

View File

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

File diff suppressed because it is too large Load Diff

View File

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

View File

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