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:
airano
2026-02-17 18:19:39 +03:30
parent c9083d86b1
commit c73e39f6e4
135 changed files with 1185 additions and 317 deletions

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

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

View File

@@ -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

View File

@@ -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.

View File

@@ -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:

View File

@@ -21,6 +21,7 @@ if TYPE_CHECKING:
logger = logging.getLogger(__name__)
class MCPEndpointFactory:
"""
Factory for creating scoped MCP endpoints.

View File

@@ -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.

View File

@@ -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.

View File

@@ -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:

View File

@@ -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.

View File

@@ -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

View File

@@ -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.

View File

@@ -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:

View File

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

View File

@@ -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

View File

@@ -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.

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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.

View File

@@ -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

View File

@@ -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.

View File

@@ -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.

View File

@@ -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.