From 80377a1a224902faba3c0d1e17d3efd58f4aace2 Mon Sep 17 00:00:00 2001 From: airano Date: Tue, 24 Feb 2026 03:30:16 +0330 Subject: [PATCH] fix: 15 bug fixes for v3.1.0 dashboard and Track E features MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- core/dashboard/routes.py | 64 +++++++++-- core/templates/dashboard/404.html | 47 ++++++++ core/templates/dashboard/audit-logs/list.html | 2 +- core/templates/dashboard/connect.html | 8 +- core/templates/dashboard/index.html | 2 +- core/templates/dashboard/settings/index.html | 6 +- core/user_auth.py | 12 +++ core/user_endpoints.py | 2 +- plugins/wordpress/client.py | 14 ++- plugins/wordpress_advanced/plugin.py | 15 ++- server.py | 102 ++++++++++++++++-- 11 files changed, 244 insertions(+), 30 deletions(-) create mode 100644 core/templates/dashboard/404.html diff --git a/core/dashboard/routes.py b/core/dashboard/routes.py index 8f1dcd3..4de5401 100644 --- a/core/dashboard/routes.py +++ b/core/dashboard/routes.py @@ -399,13 +399,18 @@ async def get_recent_activity(limit: int = 10) -> list: entries = audit_logger.get_recent_entries(limit=limit) for entry in entries: + # Skip internal/noise events + evt = entry.get("event_type", "") + if evt in ("health_metric_recorded", "health_check"): + continue + activity.append( { - "timestamp": entry.get("timestamp", ""), - "type": entry.get("event_type", "unknown"), - "message": entry.get("message", ""), - "project": entry.get("metadata", {}).get("project_id", "-"), - "level": entry.get("level", "INFO"), + "timestamp": entry.get("timestamp") or "", + "type": evt or "unknown", + "message": entry.get("message") or "", + "project": (entry.get("metadata") or {}).get("project_id", "-"), + "level": entry.get("level") or "INFO", } ) @@ -2117,7 +2122,7 @@ def get_system_config() -> dict: """Get system configuration for display.""" return { - "server_mode": os.environ.get("MCP_SERVER_MODE", "sse"), + "server_mode": os.environ.get("MCP_SERVER_MODE", "streamable-http"), "port": os.environ.get("PORT", "8000"), "log_level": os.environ.get("LOG_LEVEL", "INFO"), "oauth_auth_mode": os.environ.get("OAUTH_AUTH_MODE", "trusted_domains"), @@ -2131,6 +2136,19 @@ def get_system_config() -> dict: } +_PLUGIN_DESCRIPTIONS = { + "wordpress": "WordPress REST API management (posts, pages, media, users)", + "woocommerce": "WooCommerce store management (products, orders, customers)", + "wordpress_advanced": "WordPress Advanced operations (WP-CLI, database, bulk ops)", + "gitea": "Gitea self-hosted Git management (repos, issues, PRs)", + "n8n": "n8n workflow automation management", + "supabase": "Supabase self-hosted backend (database, auth, storage)", + "openpanel": "OpenPanel analytics and event tracking", + "appwrite": "Appwrite backend services (databases, users, functions)", + "directus": "Directus headless CMS management", +} + + def get_registered_plugins() -> list: """Get list of registered plugins.""" plugins = [] @@ -2145,7 +2163,7 @@ def get_registered_plugins() -> list: plugins.append( { "name": name, - "description": getattr(plugin_cls, "description", "No description"), + "description": _PLUGIN_DESCRIPTIONS.get(name, "Plugin"), } ) except Exception as e: @@ -2210,15 +2228,15 @@ async def dashboard_settings_page(request: Request) -> Response: plugins = get_registered_plugins() about = get_about_info() - # Format session info + # Format session display info (for Session Information section) if isinstance(session, dict): - session_info = { + session_display = { "user_type": session.get("type", "oauth_user"), "created_at": "", "expires_at": "", } else: - session_info = { + session_display = { "user_type": session.user_type if session else "unknown", "created_at": session.created_at.isoformat() if session and session.created_at else "", "expires_at": session.expires_at.isoformat() if session and session.expires_at else "", @@ -2230,7 +2248,8 @@ async def dashboard_settings_page(request: Request) -> Response: "request": request, "lang": lang, "t": t, - "session": session_info, + "session": session, # Original session for RBAC sidebar + "session_display": session_display, # Formatted for display "config": config, "plugins": plugins, "about": about, @@ -2659,6 +2678,21 @@ async def api_create_site(request: Request) -> Response: if redirect: return JSONResponse({"error": "Unauthorized"}, status_code=401) + # Verify user exists in DB (guards against stale JWT from old container) + try: + from core.database import get_database + + db = get_database() + user = await db.get_user_by_id(user_session["user_id"]) + if user is None: + response = JSONResponse( + {"error": "Session expired. Please log in again."}, status_code=401 + ) + response.delete_cookie("mcp_user_session") + return response + except RuntimeError: + pass # Database not initialized — will fail later with clear error + try: body = await request.json() except Exception: @@ -2685,6 +2719,14 @@ async def api_create_site(request: Request) -> Response: status_code=503, ) except Exception as e: + error_msg = str(e) + if "FOREIGN KEY constraint failed" in error_msg: + logger.warning("Stale session — user %s not in DB", user_session["user_id"]) + response = JSONResponse( + {"error": "Session expired. Please log in again."}, status_code=401 + ) + response.delete_cookie("mcp_user_session") + return response logger.error("Failed to create site: %s", e, exc_info=True) return JSONResponse({"error": "Internal error"}, status_code=500) diff --git a/core/templates/dashboard/404.html b/core/templates/dashboard/404.html new file mode 100644 index 0000000..b5673a5 --- /dev/null +++ b/core/templates/dashboard/404.html @@ -0,0 +1,47 @@ + + + + + + 404 — Page Not Found | MCP Hub + + + + +
+
+ + + +
+

404

+

Page not found

+

The page you're looking for doesn't exist or has been moved.

+ +

MCP Hub

+
+ + diff --git a/core/templates/dashboard/audit-logs/list.html b/core/templates/dashboard/audit-logs/list.html index 4093740..6655dc0 100644 --- a/core/templates/dashboard/audit-logs/list.html +++ b/core/templates/dashboard/audit-logs/list.html @@ -178,7 +178,7 @@ - {{ log.timestamp[:19] }} + {{ log.timestamp[:19] if log.timestamp else '-' }} diff --git a/core/templates/dashboard/connect.html b/core/templates/dashboard/connect.html index b6018dc..96d8306 100644 --- a/core/templates/dashboard/connect.html +++ b/core/templates/dashboard/connect.html @@ -22,7 +22,7 @@

{{ t.your_api_key }}

{{ new_key }} - +

{{ t.key_shown_once }}

@@ -33,7 +33,7 @@

{{ t.your_api_key }}

- +

{{ t.key_shown_once }}

@@ -104,7 +104,7 @@
Select a site and client to generate configuration...
- +
{% else %}

{{ t.no_sites }}. {{ t.add_site }}

@@ -185,7 +185,7 @@ function copyConfig() { navigator.clipboard.writeText(text); const btn = document.getElementById('copy-config-btn'); btn.textContent = '{{ t.copied }}'; - setTimeout(() => btn.textContent = '{{ t.copy }}', 2000); + setTimeout(() => btn.textContent = '{{ t['copy'] }}', 2000); } // Load config on page load diff --git a/core/templates/dashboard/index.html b/core/templates/dashboard/index.html index dc90df1..b465d87 100644 --- a/core/templates/dashboard/index.html +++ b/core/templates/dashboard/index.html @@ -140,7 +140,7 @@

{{ activity.project }}

- {{ activity.timestamp[:19] }} + {{ activity.timestamp[:19] if activity.timestamp else '-' }} {% endfor %} diff --git a/core/templates/dashboard/settings/index.html b/core/templates/dashboard/settings/index.html index f0bacbb..a66024b 100644 --- a/core/templates/dashboard/settings/index.html +++ b/core/templates/dashboard/settings/index.html @@ -188,15 +188,15 @@

{% if lang == 'fa' %}نوع کاربر{% else %}User Type{% endif %}

-

{{ session.user_type }}

+

{{ session_display.user_type }}

{% if lang == 'fa' %}ایجاد شده در{% else %}Created At{% endif %}

-

{{ session.created_at[:19] if session.created_at else '-' }}

+

{{ session_display.created_at[:19] if session_display.created_at else '-' }}

{% if lang == 'fa' %}انقضا در{% else %}Expires At{% endif %}

-

{{ session.expires_at[:19] if session.expires_at else '-' }}

+

{{ session_display.expires_at[:19] if session_display.expires_at else '-' }}

diff --git a/core/user_auth.py b/core/user_auth.py index ce4afd8..41d459d 100644 --- a/core/user_auth.py +++ b/core/user_auth.py @@ -104,6 +104,12 @@ class UserAuth: self._registration_records: dict[str, list[float]] = {} providers = self.available_providers() + if providers and not self._public_url: + logger.warning( + "OAuth providers configured (%s) but PUBLIC_URL is not set. " + "OAuth login will fail until PUBLIC_URL is configured.", + providers, + ) logger.info( "UserAuth initialized: providers=%s, session_expiry=%dh", providers, @@ -139,6 +145,12 @@ class UserAuth: Raises: ValueError: If provider is unsupported or not configured. """ + if not self._public_url: + raise ValueError( + "PUBLIC_URL environment variable must be set for OAuth login to work. " + "Example: PUBLIC_URL=https://mcp.example.com" + ) + state = secrets.token_hex(32) self._pending_states[state] = time.time() self._cleanup_expired_states() diff --git a/core/user_endpoints.py b/core/user_endpoints.py index 3d7b283..c2be0f5 100644 --- a/core/user_endpoints.py +++ b/core/user_endpoints.py @@ -116,7 +116,7 @@ async def _execute_tool( Uses the same pattern as unified_handler in tool_generator.py. """ - from plugins import plugin_registry + from plugins import registry as plugin_registry if not plugin_registry.is_registered(plugin_type): return {"type": "text", "text": f"Error: Unknown plugin type '{plugin_type}'"} diff --git a/plugins/wordpress/client.py b/plugins/wordpress/client.py index e8d89c9..a243146 100644 --- a/plugins/wordpress/client.py +++ b/plugins/wordpress/client.py @@ -540,7 +540,7 @@ class WordPressClient: async with session.get(f"{self.site_url}/wp-json") as response: if response.status == 200: data = await response.json() - return { + result = { "healthy": True, "accessible": True, "name": data.get("name", "Unknown"), @@ -549,6 +549,18 @@ class WordPressClient: "routes": len(data.get("routes", {})), } + # Test authentication with an authenticated request + try: + await self.get("users/me") + result["auth_valid"] = True + except Exception: + result["auth_valid"] = False + result["auth_warning"] = ( + "Site accessible but credentials may be invalid" + ) + + return result + # Detect REST API disabled (common with security plugins) if response.status in (403, 404): return { diff --git a/plugins/wordpress_advanced/plugin.py b/plugins/wordpress_advanced/plugin.py index 6e6f09f..7e3b268 100644 --- a/plugins/wordpress_advanced/plugin.py +++ b/plugins/wordpress_advanced/plugin.py @@ -132,6 +132,15 @@ class WordPressAdvancedPlugin(BasePlugin): except Exception as e: self.logger.warning(f"REST API check failed: {e}") + # Test authentication with an authenticated request + auth_valid = False + if rest_api_available: + try: + await self.client.get("users/me") + auth_valid = True + except Exception: + pass + # Test WP-CLI access (optional — only needed for database/system tools) wp_cli_available = False try: @@ -140,16 +149,20 @@ class WordPressAdvancedPlugin(BasePlugin): except Exception: pass - return { + result = { "healthy": rest_api_available, "wp_cli_available": wp_cli_available, "rest_api_available": rest_api_available, + "auth_valid": auth_valid, "features": { "database_operations": wp_cli_available, "bulk_operations": rest_api_available, "system_operations": wp_cli_available, }, } + if not auth_valid and rest_api_available: + result["auth_warning"] = "Site accessible but credentials may be invalid" + return result except Exception as e: return { "healthy": False, diff --git a/server.py b/server.py index 5824795..2b5ccd8 100644 --- a/server.py +++ b/server.py @@ -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")