fix(ci): fix black/ruff failures, add CoC and PR template
- Fix Python formatting (sync no longer strips blank lines from .py files) - Remove test_community_build.py (tests private sync module) - Fix ruff warnings in test files - Add CODE_OF_CONDUCT.md - Add .github/PULL_REQUEST_TEMPLATE.md Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -16,12 +16,14 @@ from pathlib import Path
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
# Valid scope values
|
||||
VALID_SCOPES = ["read", "write", "admin"]
|
||||
|
||||
# Scope can be single ("read") or multiple space-separated ("read write admin")
|
||||
Scope = str
|
||||
|
||||
|
||||
def validate_scope(scope: str) -> bool:
|
||||
"""
|
||||
Validate that scope contains only valid scope values.
|
||||
@@ -38,6 +40,7 @@ def validate_scope(scope: str) -> bool:
|
||||
scope_list = scope.split()
|
||||
return all(s in VALID_SCOPES for s in scope_list)
|
||||
|
||||
|
||||
def normalize_scope(scope: str) -> str:
|
||||
"""
|
||||
Normalize scope string by removing duplicates and sorting.
|
||||
@@ -56,6 +59,7 @@ def normalize_scope(scope: str) -> str:
|
||||
unique_scopes.append(s)
|
||||
return " ".join(unique_scopes)
|
||||
|
||||
|
||||
@dataclass
|
||||
class APIKey:
|
||||
"""
|
||||
@@ -105,6 +109,7 @@ class APIKey:
|
||||
"""Check if key is valid (not revoked, not expired)."""
|
||||
return not self.revoked and not self.is_expired()
|
||||
|
||||
|
||||
class APIKeyManager:
|
||||
"""
|
||||
Manages per-project API keys with persistence.
|
||||
@@ -487,9 +492,11 @@ class APIKeyManager:
|
||||
"valid": key.is_valid(),
|
||||
}
|
||||
|
||||
|
||||
# Global instance
|
||||
_api_key_manager: APIKeyManager | None = None
|
||||
|
||||
|
||||
def get_api_key_manager() -> APIKeyManager:
|
||||
"""Get the global API Key Manager instance."""
|
||||
global _api_key_manager
|
||||
|
||||
@@ -19,6 +19,7 @@ from enum import Enum
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
|
||||
class LogLevel(Enum):
|
||||
"""Log severity levels."""
|
||||
|
||||
@@ -27,6 +28,7 @@ class LogLevel(Enum):
|
||||
ERROR = "ERROR"
|
||||
CRITICAL = "CRITICAL"
|
||||
|
||||
|
||||
class EventType(Enum):
|
||||
"""Types of events to log."""
|
||||
|
||||
@@ -36,6 +38,7 @@ class EventType(Enum):
|
||||
ERROR = "error"
|
||||
SYSTEM = "system"
|
||||
|
||||
|
||||
class AuditLogger:
|
||||
"""
|
||||
Audit logging system for MCP operations.
|
||||
@@ -554,9 +557,11 @@ class AuditLogger:
|
||||
"log_file_size_mb": log_file_size_mb,
|
||||
}
|
||||
|
||||
|
||||
# Global audit logger instance
|
||||
_audit_logger: AuditLogger | None = None
|
||||
|
||||
|
||||
def get_audit_logger() -> AuditLogger:
|
||||
"""Get the global audit logger instance."""
|
||||
global _audit_logger
|
||||
|
||||
@@ -10,6 +10,7 @@ import secrets
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class AuthManager:
|
||||
"""
|
||||
Manage authentication for MCP server.
|
||||
@@ -115,9 +116,11 @@ class AuthManager:
|
||||
"""Check if a project has its own API key."""
|
||||
return project_id in self.project_keys
|
||||
|
||||
|
||||
# Global authentication manager instance
|
||||
_auth_manager: AuthManager | None = None
|
||||
|
||||
|
||||
def get_auth_manager() -> AuthManager:
|
||||
"""Get the global authentication manager instance."""
|
||||
global _auth_manager
|
||||
|
||||
@@ -12,6 +12,7 @@ from typing import Any
|
||||
# This allows unified handlers to check project access permissions
|
||||
_api_key_context: ContextVar[dict[str, Any] | None] = ContextVar("api_key_context", default=None)
|
||||
|
||||
|
||||
def set_api_key_context(key_id: str, project_id: str, scope: str, is_global: bool) -> None:
|
||||
"""
|
||||
Store API key information in request context.
|
||||
@@ -26,6 +27,7 @@ def set_api_key_context(key_id: str, project_id: str, scope: str, is_global: boo
|
||||
{"key_id": key_id, "project_id": project_id, "scope": scope, "is_global": is_global}
|
||||
)
|
||||
|
||||
|
||||
def get_api_key_context() -> dict[str, Any] | None:
|
||||
"""
|
||||
Retrieve API key information from request context.
|
||||
@@ -35,6 +37,7 @@ def get_api_key_context() -> dict[str, Any] | None:
|
||||
"""
|
||||
return _api_key_context.get()
|
||||
|
||||
|
||||
def clear_api_key_context() -> None:
|
||||
"""Clear API key context (for cleanup)."""
|
||||
_api_key_context.set(None)
|
||||
|
||||
@@ -20,6 +20,7 @@ logger = logging.getLogger(__name__)
|
||||
# Singleton instance
|
||||
_dashboard_auth: Optional["DashboardAuth"] = None
|
||||
|
||||
|
||||
@dataclass
|
||||
class DashboardSession:
|
||||
"""Dashboard session information."""
|
||||
@@ -30,6 +31,7 @@ class DashboardSession:
|
||||
user_type: str # "master" or "api_key"
|
||||
key_id: str | None = None # For API key sessions
|
||||
|
||||
|
||||
class DashboardAuth:
|
||||
"""
|
||||
Dashboard authentication manager.
|
||||
@@ -270,6 +272,7 @@ class DashboardAuth:
|
||||
)
|
||||
return None
|
||||
|
||||
|
||||
def get_dashboard_auth() -> DashboardAuth:
|
||||
"""Get or create the singleton DashboardAuth instance."""
|
||||
global _dashboard_auth
|
||||
|
||||
@@ -35,6 +35,7 @@ PLUGIN_DISPLAY_NAMES = {
|
||||
"appwrite": "Appwrite",
|
||||
}
|
||||
|
||||
|
||||
def get_plugin_display_name(plugin_type: str) -> str:
|
||||
"""Get the proper display name for a plugin type."""
|
||||
if plugin_type in PLUGIN_DISPLAY_NAMES:
|
||||
@@ -42,6 +43,7 @@ def get_plugin_display_name(plugin_type: str) -> str:
|
||||
# Default: replace underscores with spaces and title case
|
||||
return plugin_type.replace("_", " ").title()
|
||||
|
||||
|
||||
# Register custom Jinja filter
|
||||
templates.env.filters["plugin_name"] = get_plugin_display_name
|
||||
|
||||
@@ -155,6 +157,7 @@ DASHBOARD_TRANSLATIONS = {
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def detect_language(accept_language: str | None, query_lang: str | None = None) -> str:
|
||||
"""Detect language from Accept-Language header or query parameter."""
|
||||
if query_lang and query_lang in DASHBOARD_TRANSLATIONS:
|
||||
@@ -167,10 +170,12 @@ def detect_language(accept_language: str | None, query_lang: str | None = None)
|
||||
|
||||
return "en"
|
||||
|
||||
|
||||
def get_translations(lang: str) -> dict:
|
||||
"""Get translations for a language."""
|
||||
return DASHBOARD_TRANSLATIONS.get(lang, DASHBOARD_TRANSLATIONS["en"])
|
||||
|
||||
|
||||
def get_client_ip(request: Request) -> str:
|
||||
"""Get client IP from request, handling proxies."""
|
||||
forwarded = request.headers.get("x-forwarded-for")
|
||||
@@ -178,6 +183,7 @@ def get_client_ip(request: Request) -> str:
|
||||
return forwarded.split(",")[0].strip()
|
||||
return request.client.host if request.client else "unknown"
|
||||
|
||||
|
||||
async def get_dashboard_stats() -> dict:
|
||||
"""Get dashboard statistics."""
|
||||
stats = {
|
||||
@@ -221,6 +227,7 @@ async def get_dashboard_stats() -> dict:
|
||||
|
||||
return stats
|
||||
|
||||
|
||||
async def get_projects_by_type() -> dict:
|
||||
"""Get projects grouped by plugin type."""
|
||||
projects_by_type = {}
|
||||
@@ -242,6 +249,7 @@ async def get_projects_by_type() -> dict:
|
||||
|
||||
return projects_by_type
|
||||
|
||||
|
||||
async def get_recent_activity(limit: int = 10) -> list:
|
||||
"""Get recent activity from audit logs."""
|
||||
activity = []
|
||||
@@ -270,6 +278,7 @@ async def get_recent_activity(limit: int = 10) -> list:
|
||||
|
||||
return activity
|
||||
|
||||
|
||||
async def get_health_summary() -> dict:
|
||||
"""Get health status summary."""
|
||||
summary = {
|
||||
@@ -302,8 +311,10 @@ async def get_health_summary() -> dict:
|
||||
|
||||
return summary
|
||||
|
||||
|
||||
# Route handlers
|
||||
|
||||
|
||||
async def dashboard_login_page(request: Request) -> Response:
|
||||
"""Render dashboard login page."""
|
||||
auth = get_dashboard_auth()
|
||||
@@ -333,6 +344,7 @@ async def dashboard_login_page(request: Request) -> Response:
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
async def dashboard_login_submit(request: Request) -> Response:
|
||||
"""Handle dashboard login form submission."""
|
||||
auth = get_dashboard_auth()
|
||||
@@ -406,6 +418,7 @@ async def dashboard_login_submit(request: Request) -> Response:
|
||||
logger.info(f"Dashboard login successful: type={user_type}, ip={client_ip}")
|
||||
return response
|
||||
|
||||
|
||||
async def dashboard_logout(request: Request) -> Response:
|
||||
"""Handle dashboard logout."""
|
||||
auth = get_dashboard_auth()
|
||||
@@ -429,6 +442,7 @@ async def dashboard_logout(request: Request) -> Response:
|
||||
logger.info(f"Dashboard logout from {client_ip}")
|
||||
return response
|
||||
|
||||
|
||||
async def dashboard_home(request: Request) -> Response:
|
||||
"""Render dashboard home page."""
|
||||
auth = get_dashboard_auth()
|
||||
@@ -467,6 +481,7 @@ async def dashboard_home(request: Request) -> Response:
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
async def dashboard_api_stats(request: Request) -> Response:
|
||||
"""API endpoint for dashboard stats."""
|
||||
auth = get_dashboard_auth()
|
||||
@@ -488,10 +503,12 @@ async def dashboard_api_stats(request: Request) -> Response:
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
# ============================================================
|
||||
# Projects Routes (Phase K.2)
|
||||
# ============================================================
|
||||
|
||||
|
||||
def get_cached_health_status(project_id: str) -> dict:
|
||||
"""
|
||||
Get cached health status for a project without performing new health check.
|
||||
@@ -537,6 +554,7 @@ def get_cached_health_status(project_id: str) -> dict:
|
||||
logger.warning(f"Error getting cached health for {project_id}: {e}")
|
||||
return {"status": "unknown", "last_check": None, "error_rate": 0}
|
||||
|
||||
|
||||
async def get_all_projects(
|
||||
plugin_type: str | None = None,
|
||||
search: str | None = None,
|
||||
@@ -615,6 +633,7 @@ async def get_all_projects(
|
||||
"available_plugin_types": sorted(available_plugin_types),
|
||||
}
|
||||
|
||||
|
||||
async def get_project_detail(project_id: str) -> dict | None:
|
||||
"""Get detailed information about a specific project."""
|
||||
try:
|
||||
@@ -728,6 +747,7 @@ async def get_project_detail(project_id: str) -> dict | None:
|
||||
logger.warning(f"Error getting project detail: {e}")
|
||||
return None
|
||||
|
||||
|
||||
async def dashboard_projects_list(request: Request) -> Response:
|
||||
"""Render projects list page."""
|
||||
auth = get_dashboard_auth()
|
||||
@@ -779,6 +799,7 @@ async def dashboard_projects_list(request: Request) -> Response:
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
async def dashboard_project_detail(request: Request) -> Response:
|
||||
"""Render project detail page."""
|
||||
auth = get_dashboard_auth()
|
||||
@@ -829,6 +850,7 @@ async def dashboard_project_detail(request: Request) -> Response:
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
async def dashboard_api_projects(request: Request) -> Response:
|
||||
"""API endpoint for projects list."""
|
||||
auth = get_dashboard_auth()
|
||||
@@ -851,6 +873,7 @@ async def dashboard_api_projects(request: Request) -> Response:
|
||||
|
||||
return JSONResponse(projects_data)
|
||||
|
||||
|
||||
async def dashboard_api_project_detail(request: Request) -> Response:
|
||||
"""API endpoint for project detail."""
|
||||
auth = get_dashboard_auth()
|
||||
@@ -868,6 +891,7 @@ async def dashboard_api_project_detail(request: Request) -> Response:
|
||||
|
||||
return JSONResponse(project)
|
||||
|
||||
|
||||
async def dashboard_project_health_check(request: Request) -> Response:
|
||||
"""API endpoint to check health of a specific project."""
|
||||
logger.debug(f"Health check request received: {request.url.path}")
|
||||
@@ -942,10 +966,12 @@ async def dashboard_project_health_check(request: Request) -> Response:
|
||||
{"status": "error", "project_id": project_id, "message": str(e)}, status_code=500
|
||||
)
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# K.3: API Keys Management
|
||||
# =============================================================================
|
||||
|
||||
|
||||
async def get_all_api_keys(
|
||||
project_id: str | None = None,
|
||||
status: str = "active",
|
||||
@@ -1000,6 +1026,7 @@ async def get_all_api_keys(
|
||||
"per_page": per_page,
|
||||
}
|
||||
|
||||
|
||||
async def dashboard_api_keys_list(request: Request) -> Response:
|
||||
"""Render API keys list page."""
|
||||
auth = get_dashboard_auth()
|
||||
@@ -1057,6 +1084,7 @@ async def dashboard_api_keys_list(request: Request) -> Response:
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
async def dashboard_api_keys_create(request: Request) -> Response:
|
||||
"""API endpoint to create a new API key."""
|
||||
auth = get_dashboard_auth()
|
||||
@@ -1111,6 +1139,7 @@ async def dashboard_api_keys_create(request: Request) -> Response:
|
||||
logger.error(f"Error creating API key: {e}")
|
||||
return JSONResponse({"error": "Failed to create key"}, status_code=500)
|
||||
|
||||
|
||||
async def dashboard_api_keys_revoke(request: Request) -> Response:
|
||||
"""API endpoint to revoke an API key."""
|
||||
auth = get_dashboard_auth()
|
||||
@@ -1144,6 +1173,7 @@ async def dashboard_api_keys_revoke(request: Request) -> Response:
|
||||
logger.error(f"Error revoking API key: {e}")
|
||||
return JSONResponse({"error": "Failed to revoke key"}, status_code=500)
|
||||
|
||||
|
||||
async def dashboard_api_keys_delete(request: Request) -> Response:
|
||||
"""API endpoint to delete an API key."""
|
||||
auth = get_dashboard_auth()
|
||||
@@ -1177,10 +1207,12 @@ async def dashboard_api_keys_delete(request: Request) -> Response:
|
||||
logger.error(f"Error deleting API key: {e}")
|
||||
return JSONResponse({"error": "Failed to delete key"}, status_code=500)
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# K.4: OAuth Clients Management
|
||||
# =============================================================================
|
||||
|
||||
|
||||
async def get_oauth_clients_data() -> dict:
|
||||
"""Get OAuth clients data."""
|
||||
from core.oauth.client_registry import get_client_registry
|
||||
@@ -1213,6 +1245,7 @@ async def get_oauth_clients_data() -> dict:
|
||||
"total_count": 0,
|
||||
}
|
||||
|
||||
|
||||
async def dashboard_oauth_clients_list(request: Request) -> Response:
|
||||
"""Render OAuth clients list page."""
|
||||
auth = get_dashboard_auth()
|
||||
@@ -1246,6 +1279,7 @@ async def dashboard_oauth_clients_list(request: Request) -> Response:
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
async def dashboard_oauth_clients_create(request: Request) -> Response:
|
||||
"""API endpoint to create OAuth client."""
|
||||
auth = get_dashboard_auth()
|
||||
@@ -1295,6 +1329,7 @@ async def dashboard_oauth_clients_create(request: Request) -> Response:
|
||||
logger.error(f"Error creating OAuth client: {e}")
|
||||
return JSONResponse({"error": "Failed to create client"}, status_code=500)
|
||||
|
||||
|
||||
async def dashboard_oauth_clients_delete(request: Request) -> Response:
|
||||
"""API endpoint to delete OAuth client."""
|
||||
auth = get_dashboard_auth()
|
||||
@@ -1329,10 +1364,12 @@ async def dashboard_oauth_clients_delete(request: Request) -> Response:
|
||||
logger.error(f"Error deleting OAuth client: {e}")
|
||||
return JSONResponse({"error": "Failed to delete client"}, status_code=500)
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# K.4: Audit Logs Management
|
||||
# =============================================================================
|
||||
|
||||
|
||||
async def get_audit_logs_data(
|
||||
event_type: str | None = None,
|
||||
level: str | None = None,
|
||||
@@ -1450,6 +1487,7 @@ async def get_audit_logs_data(
|
||||
"per_page": per_page,
|
||||
}
|
||||
|
||||
|
||||
async def dashboard_audit_logs_list(request: Request) -> Response:
|
||||
"""Render audit logs list page."""
|
||||
auth = get_dashboard_auth()
|
||||
@@ -1514,6 +1552,7 @@ async def dashboard_audit_logs_list(request: Request) -> Response:
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
async def dashboard_api_audit_logs(request: Request) -> Response:
|
||||
"""API endpoint for audit logs list."""
|
||||
auth = get_dashboard_auth()
|
||||
@@ -1545,10 +1584,12 @@ async def dashboard_api_audit_logs(request: Request) -> Response:
|
||||
logger.error(f"Error getting audit logs: {e}")
|
||||
return JSONResponse({"error": "Failed to get logs"}, status_code=500)
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# K.5: Health Monitoring
|
||||
# =============================================================================
|
||||
|
||||
|
||||
def get_basic_health_data() -> dict:
|
||||
"""Get basic health data (fast, no project checks)."""
|
||||
try:
|
||||
@@ -1618,6 +1659,7 @@ def get_basic_health_data() -> dict:
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def get_cached_projects_health() -> dict:
|
||||
"""
|
||||
Get cached health status for all projects without live checks.
|
||||
@@ -1679,6 +1721,7 @@ def get_cached_projects_health() -> dict:
|
||||
"alerts": [], # No alerts from cached data
|
||||
}
|
||||
|
||||
|
||||
async def get_health_data(live_check: bool = True) -> dict:
|
||||
"""
|
||||
Get comprehensive health monitoring data.
|
||||
@@ -1777,6 +1820,7 @@ async def get_health_data(live_check: bool = True) -> dict:
|
||||
logger.error(f"Error getting health data: {e}")
|
||||
return default_result
|
||||
|
||||
|
||||
async def dashboard_health_page(request: Request) -> Response:
|
||||
"""
|
||||
Render health monitoring page.
|
||||
@@ -1831,6 +1875,7 @@ async def dashboard_health_page(request: Request) -> Response:
|
||||
logger.error(traceback.format_exc())
|
||||
return HTMLResponse(f"<h1>Error loading health page</h1><pre>{e}</pre>", status_code=500)
|
||||
|
||||
|
||||
async def dashboard_health_projects_partial(request: Request) -> Response:
|
||||
"""HTMX endpoint for projects health data (HTML partial)."""
|
||||
logger.debug("Health projects partial endpoint called")
|
||||
@@ -1882,6 +1927,7 @@ async def dashboard_health_projects_partial(request: Request) -> Response:
|
||||
status_code=500,
|
||||
)
|
||||
|
||||
|
||||
async def dashboard_api_health(request: Request) -> Response:
|
||||
"""API endpoint for health data."""
|
||||
auth = get_dashboard_auth()
|
||||
@@ -1898,10 +1944,12 @@ async def dashboard_api_health(request: Request) -> Response:
|
||||
logger.error(f"Error getting health data: {e}")
|
||||
return JSONResponse({"error": "Failed to get health data"}, status_code=500)
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# K.5: Settings Page
|
||||
# =============================================================================
|
||||
|
||||
|
||||
def get_system_config() -> dict:
|
||||
"""Get system configuration for display."""
|
||||
|
||||
@@ -1919,6 +1967,7 @@ def get_system_config() -> dict:
|
||||
"dashboard_session_expiry": os.environ.get("DASHBOARD_SESSION_EXPIRY_HOURS", "24"),
|
||||
}
|
||||
|
||||
|
||||
def get_registered_plugins() -> list:
|
||||
"""Get list of registered plugins."""
|
||||
plugins = []
|
||||
@@ -1941,6 +1990,7 @@ def get_registered_plugins() -> list:
|
||||
|
||||
return plugins
|
||||
|
||||
|
||||
def get_about_info() -> dict:
|
||||
"""Get about information."""
|
||||
import sys
|
||||
@@ -1961,6 +2011,7 @@ def get_about_info() -> dict:
|
||||
"tools_count": tools_count,
|
||||
}
|
||||
|
||||
|
||||
async def dashboard_settings_page(request: Request) -> Response:
|
||||
"""Render settings page."""
|
||||
auth = get_dashboard_auth()
|
||||
@@ -2004,6 +2055,7 @@ async def dashboard_settings_page(request: Request) -> Response:
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
def register_dashboard_routes(mcp):
|
||||
"""
|
||||
Register dashboard routes with the MCP server.
|
||||
|
||||
@@ -8,6 +8,7 @@ Each endpoint has specific plugin types, scopes, and access requirements.
|
||||
from dataclasses import dataclass, field
|
||||
from enum import Enum
|
||||
|
||||
|
||||
class EndpointType(Enum):
|
||||
"""Types of MCP endpoints"""
|
||||
|
||||
@@ -25,6 +26,7 @@ class EndpointType(Enum):
|
||||
PROJECT = "project" # Dynamic per-project endpoint
|
||||
CUSTOM = "custom"
|
||||
|
||||
|
||||
@dataclass
|
||||
class EndpointConfig:
|
||||
"""
|
||||
@@ -90,6 +92,7 @@ class EndpointConfig:
|
||||
return True # Empty set = all scopes
|
||||
return scope in self.allowed_scopes
|
||||
|
||||
|
||||
# Predefined endpoint configurations
|
||||
ENDPOINT_CONFIGS = {
|
||||
# Admin endpoint - all tools, requires Master API Key
|
||||
@@ -305,12 +308,14 @@ ENDPOINT_CONFIGS = {
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
def get_endpoint_config(endpoint_type: EndpointType) -> EndpointConfig:
|
||||
"""Get configuration for a specific endpoint type"""
|
||||
if endpoint_type not in ENDPOINT_CONFIGS:
|
||||
raise ValueError(f"Unknown endpoint type: {endpoint_type}")
|
||||
return ENDPOINT_CONFIGS[endpoint_type]
|
||||
|
||||
|
||||
def create_project_endpoint_config(
|
||||
project_id: str, plugin_type: str, site_alias: str | None = None
|
||||
) -> EndpointConfig:
|
||||
|
||||
@@ -21,6 +21,7 @@ if TYPE_CHECKING:
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class MCPEndpointFactory:
|
||||
"""
|
||||
Factory for creating scoped MCP endpoints.
|
||||
|
||||
@@ -24,6 +24,7 @@ from .config import EndpointConfig
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@dataclass
|
||||
class AuthContext:
|
||||
"""Authentication context for a request"""
|
||||
@@ -35,6 +36,7 @@ class AuthContext:
|
||||
is_oauth_token: bool = False
|
||||
client_ip: str | None = None
|
||||
|
||||
|
||||
class EndpointAuthMiddleware(Middleware):
|
||||
"""
|
||||
Authentication middleware for multi-endpoint architecture.
|
||||
@@ -256,6 +258,7 @@ class EndpointAuthMiddleware(Middleware):
|
||||
duration_ms = int((time.time() - start_time) * 1000)
|
||||
logger.warning(f"Tool {tool_name} failed: {error} (duration={duration_ms}ms)")
|
||||
|
||||
|
||||
class EndpointRateLimitMiddleware(Middleware):
|
||||
"""
|
||||
Rate limiting middleware for multi-endpoint architecture.
|
||||
@@ -285,6 +288,7 @@ class EndpointRateLimitMiddleware(Middleware):
|
||||
# Proceed with request
|
||||
return await call_next(context)
|
||||
|
||||
|
||||
class EndpointAuditMiddleware(Middleware):
|
||||
"""
|
||||
Audit logging middleware for multi-endpoint architecture.
|
||||
@@ -333,6 +337,7 @@ class EndpointAuditMiddleware(Middleware):
|
||||
)
|
||||
raise
|
||||
|
||||
|
||||
def create_endpoint_middleware(endpoint_config: EndpointConfig) -> list:
|
||||
"""
|
||||
Create middleware stack for an endpoint.
|
||||
|
||||
@@ -21,6 +21,7 @@ from .factory import MCPEndpointFactory
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@dataclass
|
||||
class EndpointInfo:
|
||||
"""Information about a registered endpoint"""
|
||||
@@ -33,6 +34,7 @@ class EndpointInfo:
|
||||
plugin_types: list[str]
|
||||
require_master_key: bool
|
||||
|
||||
|
||||
class EndpointRegistry:
|
||||
"""
|
||||
Central registry for all MCP endpoints.
|
||||
@@ -199,9 +201,11 @@ class EndpointRegistry:
|
||||
logger.info(f"Total: {len(self._endpoints)} endpoints")
|
||||
logger.info("=" * 60)
|
||||
|
||||
|
||||
# Singleton instance
|
||||
_registry: EndpointRegistry | None = None
|
||||
|
||||
|
||||
def get_endpoint_registry() -> EndpointRegistry:
|
||||
"""Get the global endpoint registry instance"""
|
||||
global _registry
|
||||
@@ -211,6 +215,7 @@ def get_endpoint_registry() -> EndpointRegistry:
|
||||
)
|
||||
return _registry
|
||||
|
||||
|
||||
def initialize_endpoint_registry(factory: MCPEndpointFactory) -> EndpointRegistry:
|
||||
"""
|
||||
Initialize the global endpoint registry.
|
||||
|
||||
@@ -27,6 +27,7 @@ from core.project_manager import ProjectManager
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@dataclass
|
||||
class HealthMetric:
|
||||
"""Individual health metric data point."""
|
||||
@@ -47,6 +48,7 @@ class HealthMetric:
|
||||
"error_message": self.error_message,
|
||||
}
|
||||
|
||||
|
||||
@dataclass
|
||||
class SystemMetrics:
|
||||
"""System-wide metrics."""
|
||||
@@ -63,6 +65,7 @@ class SystemMetrics:
|
||||
"""Convert to dictionary."""
|
||||
return asdict(self)
|
||||
|
||||
|
||||
@dataclass
|
||||
class ProjectHealthStatus:
|
||||
"""Comprehensive health status for a project."""
|
||||
@@ -89,6 +92,7 @@ class ProjectHealthStatus:
|
||||
"details": self.details,
|
||||
}
|
||||
|
||||
|
||||
@dataclass
|
||||
class AlertThreshold:
|
||||
"""Alert threshold configuration."""
|
||||
@@ -109,6 +113,7 @@ class AlertThreshold:
|
||||
return value == self.threshold
|
||||
return False
|
||||
|
||||
|
||||
class HealthMonitor:
|
||||
"""
|
||||
Enhanced health monitoring system with metrics tracking and alerting.
|
||||
@@ -657,13 +662,16 @@ class HealthMonitor:
|
||||
self.request_timestamps.clear()
|
||||
logger.warning("All metrics have been reset")
|
||||
|
||||
|
||||
# Singleton instance
|
||||
_health_monitor: HealthMonitor | None = None
|
||||
|
||||
|
||||
def get_health_monitor() -> HealthMonitor | None:
|
||||
"""Get the global health monitor instance."""
|
||||
return _health_monitor
|
||||
|
||||
|
||||
def initialize_health_monitor(
|
||||
project_manager: ProjectManager, audit_logger: AuditLogger | None = None, **kwargs
|
||||
) -> HealthMonitor:
|
||||
|
||||
@@ -89,6 +89,7 @@ TRANSLATIONS: dict[str, dict[str, str]] = {
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def detect_language(accept_language: str | None = None, query_lang: str | None = None) -> str:
|
||||
"""
|
||||
Detect user's preferred language from Accept-Language header or query parameter.
|
||||
@@ -131,6 +132,7 @@ def detect_language(accept_language: str | None = None, query_lang: str | None =
|
||||
# Default: English
|
||||
return "en"
|
||||
|
||||
|
||||
def get_translation(lang: str, key: str, **kwargs) -> str:
|
||||
"""
|
||||
Get translated string for the given language and key.
|
||||
@@ -166,6 +168,7 @@ def get_translation(lang: str, key: str, **kwargs) -> str:
|
||||
|
||||
return text
|
||||
|
||||
|
||||
def get_all_translations(lang: str) -> dict[str, str]:
|
||||
"""
|
||||
Get all translations for a language as a dictionary.
|
||||
@@ -180,6 +183,7 @@ def get_all_translations(lang: str) -> dict[str, str]:
|
||||
"""
|
||||
return TRANSLATIONS.get(lang, TRANSLATIONS["en"])
|
||||
|
||||
|
||||
def get_language_name(lang: str) -> str:
|
||||
"""
|
||||
Get human-readable language name.
|
||||
|
||||
@@ -13,6 +13,7 @@ from .schemas import OAuthClient
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class ClientRegistry:
|
||||
"""
|
||||
OAuth Client Registry with JSON storage.
|
||||
@@ -143,9 +144,11 @@ class ClientRegistry:
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
# Singleton instance
|
||||
_client_registry: ClientRegistry | None = None
|
||||
|
||||
|
||||
def get_client_registry() -> ClientRegistry:
|
||||
"""Get singleton ClientRegistry instance"""
|
||||
global _client_registry
|
||||
|
||||
@@ -10,6 +10,7 @@ Part of Phase E: Custom OAuth Authorization Page
|
||||
import secrets
|
||||
import time
|
||||
|
||||
|
||||
class CSRFTokenManager:
|
||||
"""
|
||||
Manages CSRF tokens for OAuth authorization requests.
|
||||
@@ -107,9 +108,11 @@ class CSRFTokenManager:
|
||||
"token_lifetime_seconds": self._token_lifetime,
|
||||
}
|
||||
|
||||
|
||||
# Global CSRF token manager instance
|
||||
_csrf_manager: CSRFTokenManager | None = None
|
||||
|
||||
|
||||
def get_csrf_manager() -> CSRFTokenManager:
|
||||
"""
|
||||
Get the global CSRF token manager instance.
|
||||
|
||||
@@ -8,6 +8,7 @@ import hashlib
|
||||
import secrets
|
||||
from typing import Literal
|
||||
|
||||
|
||||
def generate_code_verifier(length: int = 64) -> str:
|
||||
"""
|
||||
Generate PKCE code verifier.
|
||||
@@ -34,6 +35,7 @@ def generate_code_verifier(length: int = 64) -> str:
|
||||
|
||||
return verifier
|
||||
|
||||
|
||||
def generate_code_challenge(code_verifier: str, method: Literal["S256"] = "S256") -> str:
|
||||
"""
|
||||
Generate PKCE code challenge from verifier.
|
||||
@@ -63,6 +65,7 @@ def generate_code_challenge(code_verifier: str, method: Literal["S256"] = "S256"
|
||||
|
||||
return challenge
|
||||
|
||||
|
||||
def validate_code_challenge(
|
||||
code_verifier: str, code_challenge: str, method: Literal["S256"] = "S256"
|
||||
) -> bool:
|
||||
|
||||
@@ -6,6 +6,7 @@ from datetime import UTC, datetime
|
||||
|
||||
from pydantic import BaseModel, Field, field_validator
|
||||
|
||||
|
||||
class OAuthClient(BaseModel):
|
||||
"""OAuth 2.1 Client Model"""
|
||||
|
||||
@@ -44,6 +45,7 @@ class OAuthClient(BaseModel):
|
||||
raise ValueError(f"Invalid grant type: {grant}")
|
||||
return v
|
||||
|
||||
|
||||
class AuthorizationCode(BaseModel):
|
||||
"""Authorization Code Model"""
|
||||
|
||||
@@ -77,6 +79,7 @@ class AuthorizationCode(BaseModel):
|
||||
raise ValueError("Only S256 code_challenge_method is supported (OAuth 2.1)")
|
||||
return v
|
||||
|
||||
|
||||
class AccessToken(BaseModel):
|
||||
"""Access Token Model"""
|
||||
|
||||
@@ -96,6 +99,7 @@ class AccessToken(BaseModel):
|
||||
expires = expires.replace(tzinfo=UTC)
|
||||
return now > expires
|
||||
|
||||
|
||||
class RefreshToken(BaseModel):
|
||||
"""Refresh Token Model"""
|
||||
|
||||
@@ -115,6 +119,7 @@ class RefreshToken(BaseModel):
|
||||
expires = expires.replace(tzinfo=UTC)
|
||||
return now > expires
|
||||
|
||||
|
||||
class TokenRequest(BaseModel):
|
||||
"""Token Request Model"""
|
||||
|
||||
@@ -135,6 +140,7 @@ class TokenRequest(BaseModel):
|
||||
client_secret: str | None = None
|
||||
scope: str | None = None
|
||||
|
||||
|
||||
class TokenResponse(BaseModel):
|
||||
"""Token Response Model"""
|
||||
|
||||
|
||||
@@ -16,6 +16,7 @@ from .token_manager import get_token_manager
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class OAuthError(Exception):
|
||||
"""OAuth error with error code and description"""
|
||||
|
||||
@@ -25,6 +26,7 @@ class OAuthError(Exception):
|
||||
self.status_code = status_code
|
||||
super().__init__(error_description)
|
||||
|
||||
|
||||
class OAuthServer:
|
||||
"""
|
||||
OAuth 2.1 Authorization Server
|
||||
@@ -385,9 +387,11 @@ class OAuthServer:
|
||||
scope=" ".join(requested_scopes),
|
||||
)
|
||||
|
||||
|
||||
# Singleton
|
||||
_oauth_server: OAuthServer | None = None
|
||||
|
||||
|
||||
def get_oauth_server() -> OAuthServer:
|
||||
"""Get singleton OAuthServer instance"""
|
||||
global _oauth_server
|
||||
|
||||
@@ -13,6 +13,7 @@ from .schemas import AccessToken, AuthorizationCode, RefreshToken
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class BaseStorage:
|
||||
"""Base storage interface"""
|
||||
|
||||
@@ -43,6 +44,7 @@ class BaseStorage:
|
||||
def revoke_refresh_token(self, token: str) -> bool:
|
||||
raise NotImplementedError
|
||||
|
||||
|
||||
class JSONStorage(BaseStorage):
|
||||
"""
|
||||
JSON file storage for OAuth tokens.
|
||||
@@ -199,6 +201,7 @@ class JSONStorage(BaseStorage):
|
||||
logger.info(f"Cleaned up {len(codes) - len(cleaned_codes)} expired authorization codes")
|
||||
logger.info(f"Cleaned up {len(tokens) - len(cleaned_tokens)} expired access tokens")
|
||||
|
||||
|
||||
def get_storage() -> BaseStorage:
|
||||
"""
|
||||
Get storage instance based on environment.
|
||||
|
||||
@@ -17,11 +17,13 @@ from .storage import get_storage
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class SecurityError(Exception):
|
||||
"""Security-related error (e.g., token reuse)"""
|
||||
|
||||
pass
|
||||
|
||||
|
||||
class TokenManager:
|
||||
"""
|
||||
OAuth 2.1 Token Manager.
|
||||
@@ -302,9 +304,11 @@ class TokenManager:
|
||||
# Access tokens cannot be revoked (JWT stateless)
|
||||
# They expire naturally after TTL
|
||||
|
||||
|
||||
# Singleton
|
||||
_token_manager: TokenManager | None = None
|
||||
|
||||
|
||||
def get_token_manager() -> TokenManager:
|
||||
"""Get singleton TokenManager instance"""
|
||||
global _token_manager
|
||||
|
||||
@@ -14,6 +14,7 @@ from plugins import BasePlugin, registry
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class ProjectManager:
|
||||
"""
|
||||
Manage multiple project instances.
|
||||
@@ -233,9 +234,11 @@ class ProjectManager:
|
||||
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
|
||||
|
||||
@@ -24,6 +24,7 @@ from typing import Any
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@dataclass
|
||||
class RateLimitConfig:
|
||||
"""Configuration for rate limits at different time intervals."""
|
||||
@@ -42,6 +43,7 @@ class RateLimitConfig:
|
||||
per_day=int(os.getenv(f"{env_prefix}RATE_LIMIT_PER_DAY", "10000")),
|
||||
)
|
||||
|
||||
|
||||
@dataclass
|
||||
class TokenBucket:
|
||||
"""
|
||||
@@ -110,6 +112,7 @@ class TokenBucket:
|
||||
tokens_needed = tokens - self.tokens
|
||||
return tokens_needed / self.refill_rate
|
||||
|
||||
|
||||
@dataclass
|
||||
class ClientRateLimitState:
|
||||
"""Track rate limit state for a single client."""
|
||||
@@ -188,6 +191,7 @@ class ClientRateLimitState:
|
||||
"uptime_seconds": uptime,
|
||||
}
|
||||
|
||||
|
||||
class RateLimiter:
|
||||
"""
|
||||
Rate limiter using Token Bucket algorithm.
|
||||
@@ -403,9 +407,11 @@ class RateLimiter:
|
||||
f"{config.per_minute}/min, {config.per_hour}/hour, {config.per_day}/day"
|
||||
)
|
||||
|
||||
|
||||
# Singleton instance
|
||||
_rate_limiter: RateLimiter | None = None
|
||||
|
||||
|
||||
def get_rate_limiter() -> RateLimiter:
|
||||
"""Get or create the global rate limiter instance."""
|
||||
global _rate_limiter
|
||||
|
||||
@@ -24,6 +24,7 @@ from pydantic import BaseModel, ConfigDict, Field, ValidationInfo, field_validat
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class SiteConfig(BaseModel):
|
||||
"""
|
||||
Type-safe site configuration.
|
||||
@@ -104,6 +105,7 @@ class SiteConfig(BaseModel):
|
||||
"""
|
||||
return self.model_dump()
|
||||
|
||||
|
||||
class SiteManager:
|
||||
"""
|
||||
Manage site configurations with type safety.
|
||||
@@ -519,9 +521,11 @@ class SiteManager:
|
||||
counts_str = ", ".join(f"{k}: {v}" for k, v in counts.items())
|
||||
return f"SiteManager(total={self.get_count()}, {counts_str})"
|
||||
|
||||
|
||||
# Singleton instance
|
||||
_site_manager: SiteManager | None = None
|
||||
|
||||
|
||||
def get_site_manager() -> SiteManager:
|
||||
"""
|
||||
Get the singleton site manager instance.
|
||||
|
||||
@@ -12,6 +12,7 @@ from typing import Any
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class SiteInfo:
|
||||
"""Information about a single site."""
|
||||
|
||||
@@ -50,6 +51,7 @@ class SiteInfo:
|
||||
"config_keys": list(self.config.keys()),
|
||||
}
|
||||
|
||||
|
||||
class SiteRegistry:
|
||||
"""
|
||||
Registry for managing site configurations across plugin types.
|
||||
@@ -360,9 +362,11 @@ class SiteRegistry:
|
||||
options.add(site_info.alias)
|
||||
return sorted(options)
|
||||
|
||||
|
||||
# Global site registry instance
|
||||
_site_registry: SiteRegistry | None = None
|
||||
|
||||
|
||||
def get_site_registry() -> SiteRegistry:
|
||||
"""Get the global site registry instance."""
|
||||
global _site_registry
|
||||
|
||||
@@ -14,6 +14,7 @@ from core.tool_registry import ToolDefinition
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
# Plugin type fallback mapping - used when a plugin has no sites configured
|
||||
# WooCommerce can fallback to WordPress sites (same URL, credentials)
|
||||
# NOTE: Using fallback is NOT recommended in production. Always configure
|
||||
@@ -23,6 +24,7 @@ PLUGIN_SITE_FALLBACK = {
|
||||
# Add more fallbacks as needed
|
||||
}
|
||||
|
||||
|
||||
def get_site_plugin_type_with_fallback(plugin_type: str, site_manager) -> str:
|
||||
"""
|
||||
Get the site configuration plugin type for a given plugin.
|
||||
@@ -60,6 +62,7 @@ def get_site_plugin_type_with_fallback(plugin_type: str, site_manager) -> str:
|
||||
# Return original type (may have no sites)
|
||||
return plugin_type
|
||||
|
||||
|
||||
class ToolGenerator:
|
||||
"""
|
||||
Generate tools directly from plugin classes.
|
||||
|
||||
@@ -13,6 +13,7 @@ from pydantic import BaseModel, ConfigDict, Field
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class ToolDefinition(BaseModel):
|
||||
"""
|
||||
Type-safe tool definition.
|
||||
@@ -42,6 +43,7 @@ class ToolDefinition(BaseModel):
|
||||
|
||||
model_config = ConfigDict(arbitrary_types_allowed=True) # Allow Callable type
|
||||
|
||||
|
||||
class ToolRegistry:
|
||||
"""
|
||||
Central registry for all MCP tools.
|
||||
@@ -208,9 +210,11 @@ class ToolRegistry:
|
||||
counts_str = ", ".join(f"{k}: {v}" for k, v in counts.items())
|
||||
return f"ToolRegistry(total={self.get_count()}, {counts_str})"
|
||||
|
||||
|
||||
# Singleton instance
|
||||
_tool_registry: ToolRegistry | None = None
|
||||
|
||||
|
||||
def get_tool_registry() -> ToolRegistry:
|
||||
"""
|
||||
Get the singleton tool registry instance.
|
||||
|
||||
@@ -18,6 +18,7 @@ from core.site_registry import get_site_registry
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class UnifiedToolGenerator:
|
||||
"""
|
||||
Generates unified tools from per-site tool definitions.
|
||||
|
||||
Reference in New Issue
Block a user