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:
@@ -399,13 +399,18 @@ async def get_recent_activity(limit: int = 10) -> list:
|
|||||||
entries = audit_logger.get_recent_entries(limit=limit)
|
entries = audit_logger.get_recent_entries(limit=limit)
|
||||||
|
|
||||||
for entry in entries:
|
for entry in entries:
|
||||||
|
# Skip internal/noise events
|
||||||
|
evt = entry.get("event_type", "")
|
||||||
|
if evt in ("health_metric_recorded", "health_check"):
|
||||||
|
continue
|
||||||
|
|
||||||
activity.append(
|
activity.append(
|
||||||
{
|
{
|
||||||
"timestamp": entry.get("timestamp", ""),
|
"timestamp": entry.get("timestamp") or "",
|
||||||
"type": entry.get("event_type", "unknown"),
|
"type": evt or "unknown",
|
||||||
"message": entry.get("message", ""),
|
"message": entry.get("message") or "",
|
||||||
"project": entry.get("metadata", {}).get("project_id", "-"),
|
"project": (entry.get("metadata") or {}).get("project_id", "-"),
|
||||||
"level": entry.get("level", "INFO"),
|
"level": entry.get("level") or "INFO",
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -2117,7 +2122,7 @@ def get_system_config() -> dict:
|
|||||||
"""Get system configuration for display."""
|
"""Get system configuration for display."""
|
||||||
|
|
||||||
return {
|
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"),
|
"port": os.environ.get("PORT", "8000"),
|
||||||
"log_level": os.environ.get("LOG_LEVEL", "INFO"),
|
"log_level": os.environ.get("LOG_LEVEL", "INFO"),
|
||||||
"oauth_auth_mode": os.environ.get("OAUTH_AUTH_MODE", "trusted_domains"),
|
"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:
|
def get_registered_plugins() -> list:
|
||||||
"""Get list of registered plugins."""
|
"""Get list of registered plugins."""
|
||||||
plugins = []
|
plugins = []
|
||||||
@@ -2145,7 +2163,7 @@ def get_registered_plugins() -> list:
|
|||||||
plugins.append(
|
plugins.append(
|
||||||
{
|
{
|
||||||
"name": name,
|
"name": name,
|
||||||
"description": getattr(plugin_cls, "description", "No description"),
|
"description": _PLUGIN_DESCRIPTIONS.get(name, "Plugin"),
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
@@ -2210,15 +2228,15 @@ async def dashboard_settings_page(request: Request) -> Response:
|
|||||||
plugins = get_registered_plugins()
|
plugins = get_registered_plugins()
|
||||||
about = get_about_info()
|
about = get_about_info()
|
||||||
|
|
||||||
# Format session info
|
# Format session display info (for Session Information section)
|
||||||
if isinstance(session, dict):
|
if isinstance(session, dict):
|
||||||
session_info = {
|
session_display = {
|
||||||
"user_type": session.get("type", "oauth_user"),
|
"user_type": session.get("type", "oauth_user"),
|
||||||
"created_at": "",
|
"created_at": "",
|
||||||
"expires_at": "",
|
"expires_at": "",
|
||||||
}
|
}
|
||||||
else:
|
else:
|
||||||
session_info = {
|
session_display = {
|
||||||
"user_type": session.user_type if session else "unknown",
|
"user_type": session.user_type if session else "unknown",
|
||||||
"created_at": session.created_at.isoformat() if session and session.created_at else "",
|
"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 "",
|
"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,
|
"request": request,
|
||||||
"lang": lang,
|
"lang": lang,
|
||||||
"t": t,
|
"t": t,
|
||||||
"session": session_info,
|
"session": session, # Original session for RBAC sidebar
|
||||||
|
"session_display": session_display, # Formatted for display
|
||||||
"config": config,
|
"config": config,
|
||||||
"plugins": plugins,
|
"plugins": plugins,
|
||||||
"about": about,
|
"about": about,
|
||||||
@@ -2659,6 +2678,21 @@ async def api_create_site(request: Request) -> Response:
|
|||||||
if redirect:
|
if redirect:
|
||||||
return JSONResponse({"error": "Unauthorized"}, status_code=401)
|
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:
|
try:
|
||||||
body = await request.json()
|
body = await request.json()
|
||||||
except Exception:
|
except Exception:
|
||||||
@@ -2685,6 +2719,14 @@ async def api_create_site(request: Request) -> Response:
|
|||||||
status_code=503,
|
status_code=503,
|
||||||
)
|
)
|
||||||
except Exception as e:
|
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)
|
logger.error("Failed to create site: %s", e, exc_info=True)
|
||||||
return JSONResponse({"error": "Internal error"}, status_code=500)
|
return JSONResponse({"error": "Internal error"}, status_code=500)
|
||||||
|
|
||||||
|
|||||||
47
core/templates/dashboard/404.html
Normal file
47
core/templates/dashboard/404.html
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en" class="dark">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>404 — Page Not Found | MCP Hub</title>
|
||||||
|
<script src="https://cdn.tailwindcss.com"></script>
|
||||||
|
<script>
|
||||||
|
tailwind.config = {
|
||||||
|
darkMode: 'class',
|
||||||
|
theme: {
|
||||||
|
extend: {
|
||||||
|
colors: {
|
||||||
|
primary: { 400: '#60a5fa', 500: '#3b82f6', 600: '#2563eb', 700: '#1d4ed8' }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
</head>
|
||||||
|
<body class="bg-gray-900 text-gray-100 min-h-screen flex items-center justify-center">
|
||||||
|
<div class="text-center px-6 py-16 max-w-lg">
|
||||||
|
<div class="w-20 h-20 bg-primary-500/20 rounded-2xl flex items-center justify-center mx-auto mb-8">
|
||||||
|
<svg class="w-10 h-10 text-primary-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9.172 16.172a4 4 0 015.656 0M9 10h.01M15 10h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<h1 class="text-6xl font-bold text-white mb-4">404</h1>
|
||||||
|
<p class="text-xl text-gray-400 mb-8">Page not found</p>
|
||||||
|
<p class="text-sm text-gray-500 mb-8">The page you're looking for doesn't exist or has been moved.</p>
|
||||||
|
<div class="flex items-center justify-center gap-4">
|
||||||
|
<a href="/dashboard"
|
||||||
|
class="inline-flex items-center px-5 py-2.5 text-sm font-medium text-white bg-primary-600 hover:bg-primary-500 rounded-lg transition-colors">
|
||||||
|
<svg class="w-4 h-4 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 12l2-2m0 0l7-7 7 7M5 10v10a1 1 0 001 1h3m10-11l2 2m-2-2v10a1 1 0 01-1 1h-3m-6 0a1 1 0 001-1v-4a1 1 0 011-1h2a1 1 0 011 1v4a1 1 0 001 1m-6 0h6"/>
|
||||||
|
</svg>
|
||||||
|
Dashboard
|
||||||
|
</a>
|
||||||
|
<a href="/health"
|
||||||
|
class="inline-flex items-center px-5 py-2.5 text-sm font-medium text-gray-300 bg-gray-800 hover:bg-gray-700 border border-gray-700 rounded-lg transition-colors">
|
||||||
|
Health Check
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
<p class="mt-12 text-xs text-gray-600">MCP Hub</p>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -178,7 +178,7 @@
|
|||||||
<tr class="hover:bg-gray-50 dark:hover:bg-gray-700/30 transition-colors">
|
<tr class="hover:bg-gray-50 dark:hover:bg-gray-700/30 transition-colors">
|
||||||
<!-- Timestamp -->
|
<!-- Timestamp -->
|
||||||
<td class="px-6 py-4">
|
<td class="px-6 py-4">
|
||||||
<span class="text-sm text-gray-700 dark:text-gray-300 font-mono">{{ log.timestamp[:19] }}</span>
|
<span class="text-sm text-gray-700 dark:text-gray-300 font-mono">{{ log.timestamp[:19] if log.timestamp else '-' }}</span>
|
||||||
</td>
|
</td>
|
||||||
|
|
||||||
<!-- Event Type -->
|
<!-- Event Type -->
|
||||||
|
|||||||
@@ -22,7 +22,7 @@
|
|||||||
<p class="font-medium mb-1">{{ t.your_api_key }}</p>
|
<p class="font-medium mb-1">{{ t.your_api_key }}</p>
|
||||||
<div class="flex items-center gap-2">
|
<div class="flex items-center gap-2">
|
||||||
<code class="bg-gray-100 dark:bg-gray-900 px-3 py-1 rounded text-sm flex-1 overflow-x-auto text-gray-800 dark:text-gray-200">{{ new_key }}</code>
|
<code class="bg-gray-100 dark:bg-gray-900 px-3 py-1 rounded text-sm flex-1 overflow-x-auto text-gray-800 dark:text-gray-200">{{ new_key }}</code>
|
||||||
<button onclick="copyText('{{ new_key }}')" class="text-sm text-yellow-700 dark:text-yellow-300 hover:text-yellow-600 dark:hover:text-yellow-200">{{ t.copy }}</button>
|
<button onclick="copyText('{{ new_key }}')" class="text-sm text-yellow-700 dark:text-yellow-300 hover:text-yellow-600 dark:hover:text-yellow-200">{{ t['copy'] }}</button>
|
||||||
</div>
|
</div>
|
||||||
<p class="text-xs text-yellow-600 dark:text-yellow-400 mt-1">{{ t.key_shown_once }}</p>
|
<p class="text-xs text-yellow-600 dark:text-yellow-400 mt-1">{{ t.key_shown_once }}</p>
|
||||||
</div>
|
</div>
|
||||||
@@ -33,7 +33,7 @@
|
|||||||
<p class="font-medium mb-1">{{ t.your_api_key }}</p>
|
<p class="font-medium mb-1">{{ t.your_api_key }}</p>
|
||||||
<div class="flex items-center gap-2">
|
<div class="flex items-center gap-2">
|
||||||
<code id="new-key-value" class="bg-gray-100 dark:bg-gray-900 px-3 py-1 rounded text-sm flex-1 overflow-x-auto text-gray-800 dark:text-gray-200"></code>
|
<code id="new-key-value" class="bg-gray-100 dark:bg-gray-900 px-3 py-1 rounded text-sm flex-1 overflow-x-auto text-gray-800 dark:text-gray-200"></code>
|
||||||
<button onclick="copyNewKey()" class="text-sm text-yellow-700 dark:text-yellow-300 hover:text-yellow-600 dark:hover:text-yellow-200">{{ t.copy }}</button>
|
<button onclick="copyNewKey()" class="text-sm text-yellow-700 dark:text-yellow-300 hover:text-yellow-600 dark:hover:text-yellow-200">{{ t['copy'] }}</button>
|
||||||
</div>
|
</div>
|
||||||
<p class="text-xs text-yellow-600 dark:text-yellow-400 mt-1">{{ t.key_shown_once }}</p>
|
<p class="text-xs text-yellow-600 dark:text-yellow-400 mt-1">{{ t.key_shown_once }}</p>
|
||||||
</div>
|
</div>
|
||||||
@@ -104,7 +104,7 @@
|
|||||||
|
|
||||||
<div class="relative">
|
<div class="relative">
|
||||||
<pre id="config-output" class="bg-gray-100 dark:bg-gray-900 rounded-lg p-4 text-sm text-gray-700 dark:text-gray-300 overflow-x-auto border border-gray-200 dark:border-gray-700 min-h-[120px]">Select a site and client to generate configuration...</pre>
|
<pre id="config-output" class="bg-gray-100 dark:bg-gray-900 rounded-lg p-4 text-sm text-gray-700 dark:text-gray-300 overflow-x-auto border border-gray-200 dark:border-gray-700 min-h-[120px]">Select a site and client to generate configuration...</pre>
|
||||||
<button onclick="copyConfig()" class="absolute top-2 {{ 'left-2' if lang == 'fa' else 'right-2' }} text-xs bg-gray-200 dark:bg-gray-700 hover:bg-gray-300 dark:hover:bg-gray-600 text-gray-600 dark:text-gray-300 px-3 py-1 rounded transition-colors" id="copy-config-btn">{{ t.copy }}</button>
|
<button onclick="copyConfig()" class="absolute top-2 {{ 'left-2' if lang == 'fa' else 'right-2' }} text-xs bg-gray-200 dark:bg-gray-700 hover:bg-gray-300 dark:hover:bg-gray-600 text-gray-600 dark:text-gray-300 px-3 py-1 rounded transition-colors" id="copy-config-btn">{{ t['copy'] }}</button>
|
||||||
</div>
|
</div>
|
||||||
{% else %}
|
{% else %}
|
||||||
<p class="text-gray-500 dark:text-gray-400 text-sm">{{ t.no_sites }}. <a href="/dashboard/sites/add{% if lang and lang != 'en' %}?lang={{ lang }}{% endif %}" class="text-blue-600 dark:text-blue-400 hover:text-blue-500 dark:hover:text-blue-300">{{ t.add_site }}</a></p>
|
<p class="text-gray-500 dark:text-gray-400 text-sm">{{ t.no_sites }}. <a href="/dashboard/sites/add{% if lang and lang != 'en' %}?lang={{ lang }}{% endif %}" class="text-blue-600 dark:text-blue-400 hover:text-blue-500 dark:hover:text-blue-300">{{ t.add_site }}</a></p>
|
||||||
@@ -185,7 +185,7 @@ function copyConfig() {
|
|||||||
navigator.clipboard.writeText(text);
|
navigator.clipboard.writeText(text);
|
||||||
const btn = document.getElementById('copy-config-btn');
|
const btn = document.getElementById('copy-config-btn');
|
||||||
btn.textContent = '{{ t.copied }}';
|
btn.textContent = '{{ t.copied }}';
|
||||||
setTimeout(() => btn.textContent = '{{ t.copy }}', 2000);
|
setTimeout(() => btn.textContent = '{{ t['copy'] }}', 2000);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Load config on page load
|
// Load config on page load
|
||||||
|
|||||||
@@ -140,7 +140,7 @@
|
|||||||
<p class="text-xs text-gray-500 dark:text-gray-400">{{ activity.project }}</p>
|
<p class="text-xs text-gray-500 dark:text-gray-400">{{ activity.project }}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<span class="text-xs text-gray-400 dark:text-gray-500">{{ activity.timestamp[:19] }}</span>
|
<span class="text-xs text-gray-400 dark:text-gray-500">{{ activity.timestamp[:19] if activity.timestamp else '-' }}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
|
|||||||
@@ -188,15 +188,15 @@
|
|||||||
<div class="grid grid-cols-1 md:grid-cols-3 gap-4">
|
<div class="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||||
<div>
|
<div>
|
||||||
<p class="text-sm text-gray-500 dark:text-gray-400">{% if lang == 'fa' %}نوع کاربر{% else %}User Type{% endif %}</p>
|
<p class="text-sm text-gray-500 dark:text-gray-400">{% if lang == 'fa' %}نوع کاربر{% else %}User Type{% endif %}</p>
|
||||||
<p class="text-gray-900 dark:text-white font-mono">{{ session.user_type }}</p>
|
<p class="text-gray-900 dark:text-white font-mono">{{ session_display.user_type }}</p>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<p class="text-sm text-gray-500 dark:text-gray-400">{% if lang == 'fa' %}ایجاد شده در{% else %}Created At{% endif %}</p>
|
<p class="text-sm text-gray-500 dark:text-gray-400">{% if lang == 'fa' %}ایجاد شده در{% else %}Created At{% endif %}</p>
|
||||||
<p class="text-gray-900 dark:text-white font-mono text-sm">{{ session.created_at[:19] if session.created_at else '-' }}</p>
|
<p class="text-gray-900 dark:text-white font-mono text-sm">{{ session_display.created_at[:19] if session_display.created_at else '-' }}</p>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<p class="text-sm text-gray-500 dark:text-gray-400">{% if lang == 'fa' %}انقضا در{% else %}Expires At{% endif %}</p>
|
<p class="text-sm text-gray-500 dark:text-gray-400">{% if lang == 'fa' %}انقضا در{% else %}Expires At{% endif %}</p>
|
||||||
<p class="text-gray-900 dark:text-white font-mono text-sm">{{ session.expires_at[:19] if session.expires_at else '-' }}</p>
|
<p class="text-gray-900 dark:text-white font-mono text-sm">{{ session_display.expires_at[:19] if session_display.expires_at else '-' }}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="mt-4 pt-4 border-t border-gray-200 dark:border-gray-700">
|
<div class="mt-4 pt-4 border-t border-gray-200 dark:border-gray-700">
|
||||||
|
|||||||
@@ -104,6 +104,12 @@ class UserAuth:
|
|||||||
self._registration_records: dict[str, list[float]] = {}
|
self._registration_records: dict[str, list[float]] = {}
|
||||||
|
|
||||||
providers = self.available_providers()
|
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(
|
logger.info(
|
||||||
"UserAuth initialized: providers=%s, session_expiry=%dh",
|
"UserAuth initialized: providers=%s, session_expiry=%dh",
|
||||||
providers,
|
providers,
|
||||||
@@ -139,6 +145,12 @@ class UserAuth:
|
|||||||
Raises:
|
Raises:
|
||||||
ValueError: If provider is unsupported or not configured.
|
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)
|
state = secrets.token_hex(32)
|
||||||
self._pending_states[state] = time.time()
|
self._pending_states[state] = time.time()
|
||||||
self._cleanup_expired_states()
|
self._cleanup_expired_states()
|
||||||
|
|||||||
@@ -116,7 +116,7 @@ async def _execute_tool(
|
|||||||
|
|
||||||
Uses the same pattern as unified_handler in tool_generator.py.
|
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):
|
if not plugin_registry.is_registered(plugin_type):
|
||||||
return {"type": "text", "text": f"Error: Unknown plugin type '{plugin_type}'"}
|
return {"type": "text", "text": f"Error: Unknown plugin type '{plugin_type}'"}
|
||||||
|
|||||||
@@ -540,7 +540,7 @@ class WordPressClient:
|
|||||||
async with session.get(f"{self.site_url}/wp-json") as response:
|
async with session.get(f"{self.site_url}/wp-json") as response:
|
||||||
if response.status == 200:
|
if response.status == 200:
|
||||||
data = await response.json()
|
data = await response.json()
|
||||||
return {
|
result = {
|
||||||
"healthy": True,
|
"healthy": True,
|
||||||
"accessible": True,
|
"accessible": True,
|
||||||
"name": data.get("name", "Unknown"),
|
"name": data.get("name", "Unknown"),
|
||||||
@@ -549,6 +549,18 @@ class WordPressClient:
|
|||||||
"routes": len(data.get("routes", {})),
|
"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)
|
# Detect REST API disabled (common with security plugins)
|
||||||
if response.status in (403, 404):
|
if response.status in (403, 404):
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -132,6 +132,15 @@ class WordPressAdvancedPlugin(BasePlugin):
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
self.logger.warning(f"REST API check failed: {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)
|
# Test WP-CLI access (optional — only needed for database/system tools)
|
||||||
wp_cli_available = False
|
wp_cli_available = False
|
||||||
try:
|
try:
|
||||||
@@ -140,16 +149,20 @@ class WordPressAdvancedPlugin(BasePlugin):
|
|||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
return {
|
result = {
|
||||||
"healthy": rest_api_available,
|
"healthy": rest_api_available,
|
||||||
"wp_cli_available": wp_cli_available,
|
"wp_cli_available": wp_cli_available,
|
||||||
"rest_api_available": rest_api_available,
|
"rest_api_available": rest_api_available,
|
||||||
|
"auth_valid": auth_valid,
|
||||||
"features": {
|
"features": {
|
||||||
"database_operations": wp_cli_available,
|
"database_operations": wp_cli_available,
|
||||||
"bulk_operations": rest_api_available,
|
"bulk_operations": rest_api_available,
|
||||||
"system_operations": wp_cli_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:
|
except Exception as e:
|
||||||
return {
|
return {
|
||||||
"healthy": False,
|
"healthy": False,
|
||||||
|
|||||||
102
server.py
102
server.py
@@ -124,6 +124,23 @@ logging.basicConfig(
|
|||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
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 Authorization Configuration
|
||||||
OAUTH_AUTH_MODE = os.getenv("OAUTH_AUTH_MODE", "trusted_domains").lower()
|
OAUTH_AUTH_MODE = os.getenv("OAUTH_AUTH_MODE", "trusted_domains").lower()
|
||||||
OAUTH_TRUSTED_DOMAINS = os.getenv(
|
OAUTH_TRUSTED_DOMAINS = os.getenv(
|
||||||
@@ -319,6 +336,22 @@ logger.info("=" * 60)
|
|||||||
# === MCP INSTRUCTIONS HELPER ===
|
# === MCP INSTRUCTIONS HELPER ===
|
||||||
# Phase K.2: Auto-discovery of available sites for AI assistants
|
# 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:
|
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.
|
The user can find it in OpenPanel Dashboard → Project Settings.
|
||||||
Track API operations (identify_user, track_event, etc.) work without project_id."""
|
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}
|
return f"""🔗 SINGLE SITE MODE - Connected to: {site_name}
|
||||||
URL: {site_url}
|
URL: {site_url}
|
||||||
|
|
||||||
You are connected to exactly ONE site. The 'site' parameter is OPTIONAL - you can omit it or use any value.
|
You are connected to exactly ONE site. The 'site' parameter is OPTIONAL - you can omit it or use any value.
|
||||||
|
|
||||||
Examples (all equivalent):
|
Examples (all equivalent):
|
||||||
- wordpress_list_posts(per_page=10)
|
- {ex1}
|
||||||
- wordpress_list_posts(site="{site_name}", per_page=10)
|
- {plugin_type}_list_posts(site="{site_name}", per_page=10)
|
||||||
- wordpress_list_posts(site="default", 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}"""
|
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)
|
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:
|
return f"""📋 MULTI-SITE MODE - {len(sites)} sites available:
|
||||||
|
|
||||||
{sites_text}
|
{sites_text}
|
||||||
|
|
||||||
When using tools, pass the 'site' parameter with either the site_id or alias.
|
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."""
|
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)
|
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) ===
|
# === USER API KEY MANAGER (E.3: Site Management) ===
|
||||||
|
|
||||||
try:
|
try:
|
||||||
@@ -3014,8 +3081,8 @@ async def _get_system_info_impl() -> dict:
|
|||||||
"success": True,
|
"success": True,
|
||||||
"server": {
|
"server": {
|
||||||
"name": "MCP Hub",
|
"name": "MCP Hub",
|
||||||
"version": "v2.6.0",
|
"version": SERVER_VERSION,
|
||||||
"phase": "X.3 (System Endpoint)",
|
"endpoint_type": "system",
|
||||||
},
|
},
|
||||||
"uptime": {
|
"uptime": {
|
||||||
"seconds": uptime_seconds,
|
"seconds": uptime_seconds,
|
||||||
@@ -4639,7 +4706,28 @@ def create_multi_endpoint_app(transport: str = "streamable-http"):
|
|||||||
StarletteMiddleware(DashboardCSRFMiddleware),
|
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("=" * 60)
|
||||||
logger.info("Multi-Endpoint Architecture Active")
|
logger.info("Multi-Endpoint Architecture Active")
|
||||||
|
|||||||
Reference in New Issue
Block a user