fix: 15 bug fixes for v3.1.0 dashboard and Track E features

Bug fixes (ordered by priority):
- BUG-14 (P1): Fix ImportError in per-user MCP endpoint
- BUG-8 (P2): Raise clear error when PUBLIC_URL missing for OAuth
- BUG-12 (P3): Validate ENCRYPTION_KEY at startup
- BUG-13 (P4): Handle stale JWT gracefully on site creation
- BUG-11 (P5): Add auth validation to WordPress health checks
- BUG-15 (P6): Fix copy button showing Python repr (dict method shadow)
- BUG-1 (P7): Dynamic version from pyproject.toml instead of hardcoded
- BUG-5 (P8): Fix server mode default (sse → streamable-http)
- BUG-9 (P9): Plugin-specific example tools in endpoint instructions
- BUG-4 (P10): Fix settings page sidebar RBAC (session type mismatch)
- BUG-6 (P11): Show real plugin descriptions instead of 'No description'
- BUG-2 (P12): Remove stale 'phase: X.3' from system info
- BUG-10 (P13): Guard timestamps against None in Recent Activity
- BUG-3 (P14): Guard timestamps against None in audit logs
- BUG-7 (P15): Styled 404 page instead of plain text

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
airano
2026-02-24 03:30:16 +03:30
parent 684c889c27
commit 80377a1a22
11 changed files with 244 additions and 30 deletions

102
server.py
View File

@@ -124,6 +124,23 @@ logging.basicConfig(
logger = logging.getLogger(__name__)
# Server version — read from pyproject.toml, fallback to importlib.metadata
SERVER_VERSION = "3.1.0"
try:
_pyproject = os.path.join(os.path.dirname(__file__), "pyproject.toml")
with open(_pyproject) as _f:
for _line in _f:
if _line.strip().startswith("version"):
SERVER_VERSION = _line.split("=")[1].strip().strip('"').strip("'")
break
except Exception:
try:
from importlib.metadata import version as _pkg_version
SERVER_VERSION = _pkg_version("mcphub-server")
except Exception:
pass
# OAuth Authorization Configuration
OAUTH_AUTH_MODE = os.getenv("OAUTH_AUTH_MODE", "trusted_domains").lower()
OAUTH_TRUSTED_DOMAINS = os.getenv(
@@ -319,6 +336,22 @@ logger.info("=" * 60)
# === MCP INSTRUCTIONS HELPER ===
# Phase K.2: Auto-discovery of available sites for AI assistants
# Example tool names per plugin type for instructions
_PLUGIN_EXAMPLE_TOOLS = {
"wordpress": ("wordpress_list_posts(per_page=10)", "wordpress_get_post(post_id=123)"),
"woocommerce": ("woocommerce_list_products(per_page=10)", "woocommerce_get_order(order_id=1)"),
"wordpress_advanced": (
"wordpress_advanced_system_info()",
"wordpress_advanced_wp_db_check()",
),
"gitea": ("gitea_list_repositories()", "gitea_get_repository(owner='user', repo='name')"),
"n8n": ("n8n_list_workflows()", "n8n_get_workflow(workflow_id=1)"),
"supabase": ("supabase_list_tables()", "supabase_query_table(table='users')"),
"openpanel": ("openpanel_get_event_count()", "openpanel_list_events()"),
"appwrite": ("appwrite_list_databases()", "appwrite_list_collections(database_id='db')"),
"directus": ("directus_list_collections()", "directus_get_items(collection='posts')"),
}
def generate_mcp_instructions(plugin_type: str = None, site_locked: str = None) -> str:
"""
@@ -387,15 +420,18 @@ For export/read operations (get_event_count, export_events, etc.), you need to a
The user can find it in OpenPanel Dashboard → Project Settings.
Track API operations (identify_user, track_event, etc.) work without project_id."""
ex1, _ = _PLUGIN_EXAMPLE_TOOLS.get(
plugin_type, ("wordpress_list_posts(per_page=10)", "")
)
return f"""🔗 SINGLE SITE MODE - Connected to: {site_name}
URL: {site_url}
You are connected to exactly ONE site. The 'site' parameter is OPTIONAL - you can omit it or use any value.
Examples (all equivalent):
- wordpress_list_posts(per_page=10)
- wordpress_list_posts(site="{site_name}", per_page=10)
- wordpress_list_posts(site="default", per_page=10)
- {ex1}
- {plugin_type}_list_posts(site="{site_name}", per_page=10)
- {plugin_type}_list_posts(site="default", per_page=10)
Just use the tools directly without asking which site to use.{openpanel_note}"""
@@ -411,12 +447,18 @@ Just use the tools directly without asking which site to use.{openpanel_note}"""
sites_text = "\n".join(site_list)
ex1, _ = _PLUGIN_EXAMPLE_TOOLS.get(
plugin_type, ("wordpress_list_posts(per_page=10)", "")
)
# Replace per_page with site param for multi-site example
multi_ex = ex1.replace("(", '(site="site1", ', 1) if "(" in ex1 else ex1
return f"""📋 MULTI-SITE MODE - {len(sites)} sites available:
{sites_text}
When using tools, pass the 'site' parameter with either the site_id or alias.
Example: wordpress_list_posts(site="site1", per_page=10)
Example: {multi_ex}
Use list_sites() to see all available sites."""
@@ -1183,6 +1225,31 @@ except Exception as e:
logger.info("User auth not initialized (OAuth not configured): %s", e)
# === ENCRYPTION KEY VALIDATION (E.1: Credential Encryption) ===
_encryption_key_raw = os.getenv("ENCRYPTION_KEY", "")
if _encryption_key_raw:
try:
import base64 as _b64
_key_bytes = _b64.b64decode(_encryption_key_raw)
if len(_key_bytes) != 32:
logger.error(
"Invalid ENCRYPTION_KEY: must decode to 32 bytes, got %d. "
"User site management will not work.",
len(_key_bytes),
)
else:
from core.encryption import initialize_credential_encryption
initialize_credential_encryption(_encryption_key_raw)
logger.info("Credential encryption initialized")
except Exception as e:
logger.error("Invalid ENCRYPTION_KEY: %s. User site management will not work.", e)
else:
logger.info("ENCRYPTION_KEY not set — user site management disabled")
# === USER API KEY MANAGER (E.3: Site Management) ===
try:
@@ -3014,8 +3081,8 @@ async def _get_system_info_impl() -> dict:
"success": True,
"server": {
"name": "MCP Hub",
"version": "v2.6.0",
"phase": "X.3 (System Endpoint)",
"version": SERVER_VERSION,
"endpoint_type": "system",
},
"uptime": {
"seconds": uptime_seconds,
@@ -4639,7 +4706,28 @@ def create_multi_endpoint_app(transport: str = "streamable-http"):
StarletteMiddleware(DashboardCSRFMiddleware),
]
app = Starlette(routes=routes, lifespan=combined_lifespan, middleware=middleware)
# Custom 404 handler — styled HTML instead of plain text
async def not_found_handler(request: Request, exc):
_404_path = os.path.join(
os.path.dirname(__file__), "core", "templates", "dashboard", "404.html"
)
try:
with open(_404_path, encoding="utf-8") as f:
content = f.read()
from starlette.responses import HTMLResponse
return HTMLResponse(content, status_code=404)
except Exception:
from starlette.responses import PlainTextResponse
return PlainTextResponse("404 Not Found", status_code=404)
app = Starlette(
routes=routes,
lifespan=combined_lifespan,
middleware=middleware,
exception_handlers={404: not_found_handler},
)
logger.info("=" * 60)
logger.info("Multi-Endpoint Architecture Active")