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