#!/usr/bin/env python3
"""
MCP Hub Server

Universal MCP server for managing self-hosted services through plugins.
Supports WordPress, WooCommerce, Gitea, n8n, Supabase, OpenPanel, Appwrite, and Directus.

Usage:
    # With stdio transport (Claude Desktop)
    python server.py

    # With HTTP transport (Streamable HTTP server)
    python server.py --transport streamable-http --port 8000

Environment Variables:
    MASTER_API_KEY: Master API key for authentication
    LOG_LEVEL: Logging level (DEBUG, INFO, WARNING, ERROR)

Sites are managed via the web dashboard and stored in SQLite (DB-based).
"""

import base64
import copy
import logging
import os
import sys
import time
import warnings
from datetime import UTC, datetime
from typing import Optional

from dotenv import find_dotenv, load_dotenv

load_dotenv(find_dotenv(usecwd=True))  # Always search from CWD (needed for PyPI mcphub.exe)

# Suppress noisy deprecation warning from websockets (transitive dependency)
warnings.filterwarnings("ignore", category=DeprecationWarning, module="websockets")

from fastmcp import FastMCP
from fastmcp.exceptions import ToolError
from fastmcp.server.dependencies import get_http_headers
from fastmcp.server.middleware import Middleware, MiddlewareContext
from starlette.requests import Request
from starlette.responses import JSONResponse, RedirectResponse
from starlette.templating import Jinja2Templates

# Import core modules
from core import (
    LogLevel,
    ToolGenerator,
    get_api_key_manager,
    get_audit_logger,
    get_auth_manager,
    get_rate_limiter,
    # Core architecture modules
    get_site_manager,
    get_tool_registry,
    set_api_key_context,
)
from core.capability_probe import api_site_capabilities, api_site_capabilities_badge
from core.dashboard.routes import (
    # F.7b: Per-site tool visibility
    api_bulk_toggle_site_tools,
    api_create_key,
    # E.3: Site Management routes
    api_create_site,
    api_delete_key,
    api_delete_site,
    api_get_config,
    # K.5: Settings routes
    api_get_settings,
    api_list_keys,
    api_list_site_tools,
    api_list_sites,
    api_patch_site_tool,
    api_reset_settings,
    api_save_setting,
    api_scope_presets,
    api_set_site_tool_scope,
    # F.18.8: Provider Keys Dashboard UI
    api_site_provider_default_model,
    api_site_provider_keys_delete,
    api_site_provider_keys_list,
    api_site_provider_keys_set,
    api_test_site,
    api_update_site,
    # E.2: OAuth Social Login routes
    auth_callback,
    auth_login_page,
    auth_logout,
    auth_provider_redirect,
    dashboard_api_audit_logs,
    dashboard_api_health,
    dashboard_api_keys_create,
    dashboard_api_keys_delete,
    # Track G: JSON list endpoint for SPA
    dashboard_api_keys_list_json,
    # K.3: API Keys routes
    dashboard_api_keys_revoke,
    dashboard_api_login,
    dashboard_api_project_detail,
    dashboard_api_projects,
    dashboard_api_stats,
    # K.4: Audit Logs routes
    dashboard_audit_logs_list,
    # E.3: Dashboard pages
    dashboard_health_page,
    dashboard_health_projects_partial,
    dashboard_home,
    # F.7b session 2: Unified keys page
    dashboard_keys_unified,
    dashboard_login_page,
    dashboard_login_submit,
    dashboard_logout,
    dashboard_oauth_clients_create,
    dashboard_oauth_clients_delete,
    # K.4: OAuth Clients routes
    dashboard_oauth_clients_list,
    # Track G: JSON list endpoint for SPA
    dashboard_oauth_clients_list_json,
    # E.2: Profile page
    dashboard_profile_page,
    dashboard_project_detail,
    dashboard_project_health_check,
    # K.2: Projects routes
    dashboard_projects_list,
    # F.3: Service pages
    dashboard_service_page,
    dashboard_services_list,
    dashboard_settings_page,
    # E.3: Site Management pages
    dashboard_sites_add,
    dashboard_sites_edit,
    dashboard_sites_list,
    dashboard_sites_view,
    # Bug C: User OAuth client routes
    dashboard_user_oauth_clients_create,
    dashboard_user_oauth_clients_delete,
    dashboard_user_oauth_clients_list,
)
from core.database import get_database, initialize_database
from core.i18n import detect_language, get_all_translations

# OAuth and CSRF (Phase E)
from core.oauth import get_csrf_manager
from plugins.ai_image.providers.openrouter import api_openrouter_models

# Configure logging
LOG_LEVEL = os.getenv("LOG_LEVEL", "INFO").upper()
logging.basicConfig(
    level=getattr(logging, LOG_LEVEL),
    format="%(asctime)s - %(name)s - %(levelname)s - %(message)s",
    handlers=[logging.StreamHandler(sys.stderr)],
)

logger = logging.getLogger(__name__)

# Server version — read from pyproject.toml, fallback to importlib.metadata
SERVER_VERSION = "3.1.0"
try:
    _pyproject = os.path.join(os.path.dirname(__file__), "pyproject.toml")
    with open(_pyproject) as _f:
        for _line in _f:
            if _line.strip().startswith("version"):
                SERVER_VERSION = _line.split("=")[1].strip().strip('"').strip("'")
                break
except Exception:
    try:
        from importlib.metadata import version as _pkg_version

        SERVER_VERSION = _pkg_version("mcphub-server")
    except Exception:
        pass

# OAuth Authorization Configuration
OAUTH_AUTH_MODE = os.getenv("OAUTH_AUTH_MODE", "trusted_domains").lower()
OAUTH_TRUSTED_DOMAINS = os.getenv(
    "OAUTH_TRUSTED_DOMAINS", "chatgpt.com,chat.openai.com,openai.com,platform.openai.com"
).split(",")
OAUTH_TRUSTED_DOMAINS = [domain.strip() for domain in OAUTH_TRUSTED_DOMAINS]  # Clean whitespace

# ========================================
# DCR (Dynamic Client Registration) Configuration
# ========================================
# Allowlist of redirect_uri patterns for open DCR (without Master API Key)
# These are trusted MCP clients (Claude, ChatGPT, etc.)
# Pattern format: regex patterns matched against redirect_uri
import re

DCR_ALLOWED_REDIRECT_PATTERNS = [
    # Claude AI
    r"^https://claude\.ai/.*",
    r"^https://claude\.com/.*",
    # ChatGPT / OpenAI
    r"^https://chatgpt\.com/.*",
    r"^https://chat\.openai\.com/.*",
    r"^https://platform\.openai\.com/.*",
    # Localhost for development
    r"^http://localhost:\d+/.*",
    r"^http://127\.0\.0\.1:\d+/.*",
]

# Load additional patterns from environment
DCR_EXTRA_PATTERNS = os.getenv("DCR_ALLOWED_REDIRECT_PATTERNS", "")
if DCR_EXTRA_PATTERNS:
    for pattern in DCR_EXTRA_PATTERNS.split(","):
        pattern = pattern.strip()
        if pattern:
            DCR_ALLOWED_REDIRECT_PATTERNS.append(pattern)

# DCR Rate Limiting (per IP)
DCR_RATE_LIMIT_PER_MINUTE = int(os.getenv("DCR_RATE_LIMIT_PER_MINUTE", "10"))
DCR_RATE_LIMIT_PER_HOUR = int(os.getenv("DCR_RATE_LIMIT_PER_HOUR", "30"))

# In-memory DCR rate limit tracking
_dcr_rate_limits: dict = (
    {}
)  # {ip: {"minute": count, "hour": count, "minute_reset": timestamp, "hour_reset": timestamp}}


def is_redirect_uri_allowed_for_open_dcr(redirect_uris: list) -> bool:
    """
    Check if all redirect_uris match the allowlist patterns.

    For open DCR (without Master API Key), all redirect_uris must match
    at least one pattern in DCR_ALLOWED_REDIRECT_PATTERNS.

    Args:
        redirect_uris: List of redirect URIs to check

    Returns:
        True if ALL redirect_uris match at least one allowed pattern
    """
    for uri in redirect_uris:
        uri_allowed = False
        for pattern in DCR_ALLOWED_REDIRECT_PATTERNS:
            if re.match(pattern, uri):
                uri_allowed = True
                break
        if not uri_allowed:
            return False
    return True


def check_dcr_rate_limit(client_ip: str) -> tuple[bool, str]:
    """
    Check if DCR request is within rate limits.

    Args:
        client_ip: Client IP address

    Returns:
        Tuple of (is_allowed, error_message)
    """
    import time

    now = time.time()

    if client_ip not in _dcr_rate_limits:
        _dcr_rate_limits[client_ip] = {
            "minute": 0,
            "hour": 0,
            "minute_reset": now + 60,
            "hour_reset": now + 3600,
        }

    limits = _dcr_rate_limits[client_ip]

    # Reset counters if time expired
    if now > limits["minute_reset"]:
        limits["minute"] = 0
        limits["minute_reset"] = now + 60

    if now > limits["hour_reset"]:
        limits["hour"] = 0
        limits["hour_reset"] = now + 3600

    # Check limits
    if limits["minute"] >= DCR_RATE_LIMIT_PER_MINUTE:
        return (
            False,
            f"DCR rate limit exceeded: {DCR_RATE_LIMIT_PER_MINUTE} registrations per minute",
        )

    if limits["hour"] >= DCR_RATE_LIMIT_PER_HOUR:
        return False, f"DCR rate limit exceeded: {DCR_RATE_LIMIT_PER_HOUR} registrations per hour"

    # Increment counters
    limits["minute"] += 1
    limits["hour"] += 1

    return True, ""


# Validate auth mode
if OAUTH_AUTH_MODE not in ["required", "optional", "trusted_domains"]:
    logger.warning(f"Invalid OAUTH_AUTH_MODE '{OAUTH_AUTH_MODE}'. Defaulting to 'trusted_domains'")
    OAUTH_AUTH_MODE = "trusted_domains"

logger.info(f"OAuth Authorization Mode: {OAUTH_AUTH_MODE}")
if OAUTH_AUTH_MODE == "trusted_domains":
    logger.info(f"OAuth Trusted Domains: {', '.join(OAUTH_TRUSTED_DOMAINS)}")

# Initialize MCP server
mcp = FastMCP("MCP Hub")

# Initialize Jinja2 templates
# Templates live in core/templates/ (included in pip package as package_data)
_TEMPLATES_DIR = os.path.join(os.path.dirname(__file__), "core", "templates")
if not os.path.isdir(_TEMPLATES_DIR):
    # Fallback for pip-installed package: resolve relative to core package
    import core as _core_pkg

    _TEMPLATES_DIR = os.path.join(os.path.dirname(os.path.abspath(_core_pkg.__file__)), "templates")
templates = Jinja2Templates(directory=_TEMPLATES_DIR)
logger.info("Jinja2 template engine initialized")

# Initialize managers
auth_manager = get_auth_manager()
api_key_manager = get_api_key_manager()
audit_logger = get_audit_logger()
csrf_manager = get_csrf_manager()


# Initialize site manager
from plugins import registry as plugin_registry

site_manager = get_site_manager()
plugin_types = plugin_registry.get_registered_types()
# Initialize tool registry (central tool management)
tool_registry = get_tool_registry()

# Initialize tool generator (replacement for UnifiedToolGenerator)
tool_generator = ToolGenerator(site_manager)

logger.info("=" * 60)
logger.info("MCP Hub Server - Initialized")
logger.info("=" * 60)
_mk = auth_manager.get_master_key()
if auth_manager._is_temporary_key:
    logger.info(f"Master API Key (temporary): {_mk}")
else:
    logger.info(f"Master API Key: {_mk[:8]}***{_mk[-4:]}")
logger.info(f"Plugin types registered: {', '.join(plugin_types)}")
logger.info("Sites are managed via the web dashboard (DB-based)")
logger.info("=" * 60)


# === MCP INSTRUCTIONS HELPER ===
# Phase K.2: Auto-discovery of available sites for AI assistants

# Example tool names per plugin type for instructions
_PLUGIN_EXAMPLE_TOOLS = {
    "wordpress": ("wordpress_list_posts(per_page=10)", "wordpress_get_post(post_id=123)"),
    "woocommerce": ("woocommerce_list_products(per_page=10)", "woocommerce_get_order(order_id=1)"),
    "wordpress_specialist": (
        "wordpress_specialist_wp_plugin_list()",
        "wordpress_specialist_wp_user_list(role='editor', per_page=10)",
    ),
    "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()"),
}


def generate_mcp_instructions(plugin_type: str = None, site_locked: str = None) -> str:
    """
    Generate MCP server instructions for AI assistants.

    These instructions are shown to AI clients (Claude, ChatGPT) when they connect,
    helping them understand available sites without needing to ask the user.

    Args:
        plugin_type: Optional plugin type to filter sites (e.g., 'wordpress')
                     If None, shows all sites (admin endpoint)
        site_locked: If set, indicates this is a per-project endpoint locked to a specific site

    Returns:
        Instructions string for the MCP server
    """
    if site_locked:
        # Per-project endpoint - site is auto-injected
        return f"""This endpoint is locked to site: {site_locked}

All tools are pre-configured for this site. You do NOT need to pass the 'site' parameter - it is automatically injected.

Just use the tools directly, for example:
- wordpress_list_posts(per_page=10)
- wordpress_get_post(post_id=123)

The site parameter will be automatically set to '{site_locked}'."""

    # Get available sites
    if plugin_type:
        # Plugin-specific endpoint
        sites = site_manager.get_sites_by_type(plugin_type)
        if not sites:
            return f"No {plugin_type} sites configured. Please check environment variables."

        # Phase K.2.5: Improved instructions for single-site vs multi-site
        if len(sites) == 1:
            # Single site - make it very clear
            site = sites[0]
            site_url = site.url if hasattr(site, "url") else ""
            site_name = site.alias or site.site_id

            # OpenPanel-specific instructions for project_id and organization_id
            openpanel_note = ""
            if plugin_type == "openpanel":
                project_id = getattr(site, "project_id", None)
                organization_id = getattr(site, "organization_id", None)
                config_parts = []

                if project_id:
                    config_parts.append(f"📊 Project ID: {project_id}")
                if organization_id:
                    config_parts.append(f"🏢 Organization ID: {organization_id}")

                if project_id:
                    openpanel_note = f"""

{chr(10).join(config_parts)}
These IDs are pre-configured. You do NOT need to pass project_id for export/read tools.
All export tools (get_event_count, get_unique_users, export_events, etc.) will automatically use this project."""
                else:
                    openpanel_note = """

⚠️ OpenPanel Note: No project_id configured.
For export/read operations (get_event_count, export_events, etc.), you need to ask the user for their Project ID.
The user can find it in OpenPanel Dashboard → Project Settings.
Track API operations (identify_user, track_event, etc.) work without project_id."""

            ex1, _ = _PLUGIN_EXAMPLE_TOOLS.get(
                plugin_type, ("wordpress_list_posts(per_page=10)", "")
            )
            return f"""🔗 SINGLE SITE MODE - Connected to: {site_name}
URL: {site_url}

You are connected to exactly ONE site. The 'site' parameter is OPTIONAL - you can omit it or use any value.

Examples (all equivalent):
- {ex1}
- {plugin_type}_list_posts(site="{site_name}", per_page=10)
- {plugin_type}_list_posts(site="default", per_page=10)

Just use the tools directly without asking which site to use.{openpanel_note}"""

        else:
            # Multiple sites - require site selection
            site_list = []
            for site in sites:
                alias_info = (
                    f" (alias: '{site.alias}')" if site.alias and site.alias != site.site_id else ""
                )
                url_info = f" - {site.url}" if hasattr(site, "url") and site.url else ""
                site_list.append(f"  • {site.site_id}{alias_info}{url_info}")

            sites_text = "\n".join(site_list)

            ex1, _ = _PLUGIN_EXAMPLE_TOOLS.get(
                plugin_type, ("wordpress_list_posts(per_page=10)", "")
            )
            # Replace per_page with site param for multi-site example
            multi_ex = ex1.replace("(", '(site="site1", ', 1) if "(" in ex1 else ex1

            return f"""📋 MULTI-SITE MODE - {len(sites)} sites available:

{sites_text}

When using tools, pass the 'site' parameter with either the site_id or alias.
Example: {multi_ex}

Use list_sites() to see all available sites."""

    else:
        # Admin endpoint - show all sites
        all_sites = site_manager.list_all_sites()
        if not all_sites:
            return "No sites configured. Please check environment variables."

        # Group by plugin type
        by_type = {}
        for site in all_sites:
            pt = site["plugin_type"]
            if pt not in by_type:
                by_type[pt] = []
            by_type[pt].append(site)

        sections = []
        for pt, sites in sorted(by_type.items()):
            site_entries = []
            for s in sites:
                alias_info = f" (alias: '{s['alias']}')" if s["alias"] != s["site_id"] else ""
                site_entries.append(f"    • {s['site_id']}{alias_info}")
            sections.append(f"  {pt.title()}: {len(sites)} site(s)\n" + "\n".join(site_entries))

        sites_summary = "\n\n".join(sections)

        return f"""This is the Admin endpoint with access to {len(all_sites)} site(s) across {len(by_type)} plugin type(s):

{sites_summary}

When using tools, pass the 'site' parameter with either the site_id or alias.
Example: wordpress_list_posts(site="myblog", per_page=10)

Use list_projects() to get detailed information about all configured sites.
Use get_endpoints() to see all available MCP endpoints.

If working with a single site, use it directly without asking the user."""


# Set instructions for admin endpoint (after sites are discovered)
mcp.instructions = generate_mcp_instructions()
logger.info("Admin MCP instructions configured with site discovery")


# === AUTHENTICATION MIDDLEWARE ===


def extract_project_from_tool(tool_name: str) -> str:
    """
    Extract project_id from tool name.

    Examples:
        "wordpress_list_posts" -> "*" (unified tool, project via param)
        "list_projects" -> "*" (system tool)
        "get_rate_limit_stats" -> "*" (system tool)

    Returns:
        "*" for now (project is passed as parameter in unified architecture)
    """
    # In unified architecture, project is passed as parameter, not in tool name
    # So we return "*" to indicate "any project" at this stage
    # The actual project will be validated when the tool is executed
    return "*"


def extract_plugin_type_from_tool(tool_name: str) -> str | None:
    """
    Extract plugin type from tool name for tool visibility filtering.

    Each API key should only see tools related to its plugin type.

    Examples:
        "wordpress_list_posts" -> "wordpress"
        "wordpress_specialist_wp_db_size" -> "wordpress_specialist"
        "wordpress_specialist_wp_bulk_post_update" -> "wordpress_specialist"
        "gitea_list_repositories" -> "gitea"
        "list_projects" -> None (system tool)
        "manage_api_keys_list" -> None (system tool)

    Returns:
        Plugin type string or None for system tools
    """
    # Remove MCP namespace prefix if present (e.g., "mcp__mcp-hub__wordpress_...")
    clean_name = tool_name
    if tool_name.startswith("mcp__") and "__" in tool_name[5:]:
        # Strip "mcp__{server-name}__" prefix
        clean_name = tool_name.split("__", 2)[-1]

    # Check for plugin types (order matters - check more specific first).
    # The two-word ``wordpress_specialist`` variant must be checked
    # before the bare ``wordpress_`` prefix. (``wordpress_advanced`` was
    # sunset in F.19.3.2-.3.)
    if clean_name.startswith("wordpress_specialist_"):
        return "wordpress_specialist"
    elif clean_name.startswith("wordpress_") or clean_name.startswith("woocommerce_"):
        return "wordpress"
    elif clean_name.startswith("gitea_"):
        return "gitea"
    elif clean_name.startswith("n8n_"):
        return "n8n"
    elif clean_name.startswith("supabase_"):
        return "supabase"
    elif clean_name.startswith("openpanel_"):
        return "openpanel"
    elif clean_name.startswith("ghost_"):
        return "ghost"

    # System tools (no plugin type)
    return None


def check_tool_visibility(tool_name: str, api_key_project_id: str) -> bool:
    """
    Check if API key has visibility to the requested tool.

    Rules:
    - Global API keys (project_id="*") see ALL tools
    - Plugin-specific keys (project_id="wordpress_xxx") see only that plugin's tools
    - System tools are visible to global keys only

    Args:
        tool_name: Name of the tool being accessed
        api_key_project_id: project_id from API key

    Returns:
        True if tool is visible, False otherwise

    Examples:
        >>> check_tool_visibility("wordpress_list_posts", "wordpress_site1")
        True
        >>> check_tool_visibility("wordpress_specialist_wp_db_size", "wordpress_specialist_site1")
        True
        >>> check_tool_visibility("gitea_list_repos", "wordpress_site1")
        False
        >>> check_tool_visibility("list_projects", "wordpress_site1")
        False  # System tools need global key
        >>> check_tool_visibility("wordpress_list_posts", "*")
        True  # Global key sees everything
    """
    # Global keys see everything
    if api_key_project_id == "*":
        return True

    # Extract plugin type from tool name
    tool_plugin_type = extract_plugin_type_from_tool(tool_name)

    # System tools (no plugin type) - only visible to global keys
    if tool_plugin_type is None:
        return False

    # Extract plugin type from API key project_id
    # project_id format: "{plugin_type}_{site_id}" e.g. "wordpress_site1" or
    # "wordpress_specialist_site1".
    # IMPORTANT: list multi-word variants BEFORE the bare ``wordpress`` so the
    # prefix match doesn't claim them as plain wordpress.
    known_plugin_types = [
        "wordpress_specialist",
        "wordpress",
        "gitea",
        "n8n",
        "supabase",
        "ghost",
    ]

    key_plugin_type = None
    for ptype in known_plugin_types:
        if api_key_project_id.startswith(ptype + "_"):
            key_plugin_type = ptype
            break

    if key_plugin_type is None:
        # Fallback: extract first part before underscore
        if "_" in api_key_project_id:
            key_plugin_type = api_key_project_id.split("_")[0]
        else:
            key_plugin_type = api_key_project_id

    # Check if plugin types match
    return tool_plugin_type == key_plugin_type


def determine_required_scope(tool_name: str) -> str:
    """
    Determine required scope for a tool.

    Read operations: list, get, check
    Write operations: create, update, delete, revoke, rotate
    Admin operations: manage keys, system operations

    Returns:
        "read", "write", or "admin"
    """
    tool_lower = tool_name.lower()

    # Admin operations
    if any(x in tool_lower for x in ["manage_api_keys", "reset_rate_limit", "export_"]):
        return "admin"

    # Write operations
    if any(
        x in tool_lower
        for x in ["create", "update", "delete", "revoke", "rotate", "upload", "flush"]
    ):
        return "write"

    # Everything else is read
    return "read"


class UserAuthMiddleware(Middleware):
    """
    Middleware to enforce Bearer token authentication for all MCP tool calls.

    Supports both per-project API keys and master API key.
    - Per-project keys: Validated with scope and project access
    - Master key: Full access to all projects

    Stores authentication metadata in context for audit and rate limiting.
    """

    async def on_call_tool(self, context: MiddlewareContext, call_next):
        """
        Intercept tool calls to validate authentication.

        Args:
            context: Middleware context containing request information
            call_next: Next middleware or tool handler in the chain

        Returns:
            Result from the tool if authentication succeeds

        Raises:
            ToolError: If authentication fails or token is missing/invalid
        """
        try:
            # Get HTTP headers from the request
            headers = get_http_headers()
            auth_header = headers.get("authorization")

            # Check if Authorization header exists
            if not auth_header:
                logger.warning("Request rejected: Missing Authorization header")
                raise ToolError(
                    "Authentication required. Please provide Authorization header with Bearer token."
                )

            # Check if it follows Bearer token format
            if not auth_header.startswith("Bearer "):
                logger.warning(
                    f"Request rejected: Invalid Authorization format: {auth_header[:20]}..."
                )
                raise ToolError("Invalid Authorization format. Expected: 'Bearer <token>'")

            # Extract token from "Bearer <token>"
            token = auth_header.removeprefix("Bearer ").strip()

            if not token:
                logger.warning("Request rejected: Empty token")
                raise ToolError("Invalid Authorization: Token is empty")

            # Get tool information
            # Extract tool name directly from context.message.name
            # context.message is CallToolRequestParams which has 'name' and 'arguments' attributes
            tool_name = "unknown"
            try:
                if hasattr(context, "message") and hasattr(context.message, "name"):
                    tool_name = context.message.name
                    logger.debug(f"Extracted tool_name: {tool_name}")
            except Exception as e:
                logger.warning(f"Failed to extract tool name: {e}")

            project_id = extract_project_from_tool(tool_name)
            required_scope = determine_required_scope(tool_name)

            # Determine if this is a unified tool (takes site parameter)
            # Unified tools will validate project access at execution time
            # Note: FastMCP adds namespace prefix to tool names

            # Check if this is a system tool first
            SYSTEM_TOOLS = [
                "list_projects",
                "get_project_info",
                "check_all_projects_health",
                "get_project_health",
                "get_system_metrics",
                "get_system_uptime",
                "get_rate_limit_stats",
                "export_health_metrics",
                "manage_api_keys_list",
                "manage_api_keys_get_info",
            ]
            is_system_tool = any(tool_name.endswith(st) for st in SYSTEM_TOOLS)

            # FALLBACK: If extraction fails, assume unified tool for safety
            # This allows per-project API keys to work even if extraction fails
            if tool_name == "unknown":
                is_unified_tool = True
            else:
                # All plugin tools that have a 'site' parameter are unified tools
                # They defer project access check to execution time
                # Clean any MCP namespace prefix before checking
                check_name = tool_name
                if tool_name.startswith("mcp__") and "__" in tool_name[5:]:
                    check_name = tool_name.split("__", 2)[-1]
                is_unified_tool = (
                    check_name.startswith("wordpress_")
                    or check_name.startswith("wordpress_specialist_")
                    or check_name.startswith("woocommerce_")
                    or check_name.startswith("gitea_")
                )

            logger.debug(
                f"Auth check: tool={tool_name}, project={project_id}, "
                f"scope={required_scope}, unified={is_unified_tool}, system={is_system_tool}"
            )

            # Try API key validation first (if it looks like an API key)
            key_id = None
            jwt_payload = None

            if token.startswith("cmp_"):
                # Skip project check for both unified tools AND system tools
                # - Unified tools: will validate at execution time
                # - System tools: will validate below that key is global
                skip_project_check = is_unified_tool or is_system_tool

                key_id = api_key_manager.validate_key(
                    token,
                    project_id=project_id,
                    required_scope=required_scope,
                    skip_project_check=skip_project_check,
                )

            elif not token.startswith("sk-"):  # Not master key format, might be JWT
                # Try OAuth JWT validation
                try:
                    import jwt as pyjwt

                    from core.oauth import get_token_manager

                    token_manager = get_token_manager()
                    jwt_payload = token_manager.validate_access_token(token)

                    # JWT validated successfully
                    logger.debug(f"OAuth JWT validated for tool {tool_name}")

                    # Extract scope from JWT and validate
                    jwt_scopes = jwt_payload.get("scope", "").split()

                    # Check if required scope is granted
                    if required_scope not in jwt_scopes and "admin" not in jwt_scopes:
                        logger.warning(
                            f"JWT scope insufficient: required={required_scope}, granted={jwt_scopes}"
                        )
                        raise ToolError(f"Insufficient scope: '{required_scope}' required")

                    # Store OAuth context (similar to API key context)
                    # Note: OAuth tokens can be scoped to specific projects
                    oauth_project_id = jwt_payload.get("project_id", "*")

                    set_api_key_context(
                        key_id=f"oauth_{jwt_payload.get('client_id')}",
                        project_id=oauth_project_id,
                        scope=" ".join(jwt_scopes),
                        is_global=oauth_project_id == "*",
                    )
                    logger.debug(
                        f"Stored OAuth context: client_id={jwt_payload.get('client_id')}, "
                        f"project_id={oauth_project_id}, scopes={jwt_scopes}"
                    )

                except pyjwt.ExpiredSignatureError:
                    logger.warning("JWT token expired")
                    raise ToolError("Authentication failed: Token expired")
                except pyjwt.InvalidTokenError as e:
                    logger.debug(f"Not a valid JWT token: {e}")
                    # Not a JWT, will try master key below
                    jwt_payload = None
                except Exception as e:
                    logger.warning(f"JWT validation error: {e}")
                    # Not a JWT, will try master key below
                    jwt_payload = None

            if key_id or jwt_payload:
                # API key or JWT validated successfully
                if key_id:
                    logger.debug(
                        f"API key {key_id} validated for tool {tool_name} (scope: {required_scope})"
                    )
                elif jwt_payload:
                    logger.debug(
                        f"OAuth JWT validated for tool {tool_name} (client_id: {jwt_payload.get('client_id')})"
                    )

                # Get full key info for context storage (skip for JWT)
                key = api_key_manager.keys.get(key_id) if key_id else None

                if key:
                    # Check if API key has visibility to this tool
                    if not check_tool_visibility(tool_name, key.project_id):
                        plugin_type = extract_plugin_type_from_tool(tool_name)
                        logger.warning(
                            f"Tool visibility denied: Key {key_id} (project: {key.project_id}) "
                            f"attempted to access {plugin_type or 'system'} tool: {tool_name}"
                        )
                        raise ToolError(
                            f"Access denied: Your API key does not have access to {plugin_type or 'system'} tools. "
                            f"Please use a global API key or the correct plugin-specific key."
                        )

                    # Additional check for system tools:
                    # System tools require global key (project_id="*")
                    if is_system_tool:
                        # Check if this is a global key
                        if key.project_id != "*":
                            logger.warning(
                                f"Key {key_id} (project: {key.project_id}) "
                                f"attempted to access system tool: {tool_name}"
                            )
                            raise ToolError("System tools require global API key (project_id='*')")

                    # Store API key info in context for unified handlers to check project access
                    # This enables per-project API keys to work correctly with unified tools
                    set_api_key_context(
                        key_id=key_id,
                        project_id=key.project_id,
                        scope=key.scope,
                        is_global=key.project_id == "*",
                    )
                    logger.debug(
                        f"Stored API key context: project_id={key.project_id}, is_global={key.project_id == '*'}"
                    )

            else:
                # Fallback to master key validation
                if not auth_manager.validate_master_key(token):
                    logger.warning("Request rejected: Invalid API key or master key")
                    raise ToolError("Authentication failed: Invalid API key")

                logger.debug(f"Master key validated for tool {tool_name}")

                # Set context for master key (global access)
                set_api_key_context(
                    key_id="master_key", project_id="*", scope="admin", is_global=True
                )
                logger.debug("Stored master key context: project_id=*, is_global=True")

            # Authentication successful - proceed
            return await call_next(context)

        except ToolError:
            # Re-raise ToolError as-is (already has proper message)
            raise
        except Exception as e:
            # Catch any unexpected errors during authentication
            logger.error(f"Authentication error: {e}", exc_info=True)
            raise ToolError(f"Authentication error: {str(e)}")


# Add authentication middleware to MCP server
mcp.add_middleware(UserAuthMiddleware())
logger.info("Authentication middleware enabled")


# === AUDIT LOGGING MIDDLEWARE ===


class AuditLoggingMiddleware(Middleware):
    """
    Middleware to log all tool calls for audit purposes.

    Logs tool name, parameters, duration, and results.
    Runs after authentication to ensure only valid calls are logged.
    """

    async def on_call_tool(self, context: MiddlewareContext, call_next):
        """
        Log tool calls before and after execution.

        Args:
            context: Middleware context containing tool call information
            call_next: Next middleware or tool handler in the chain

        Returns:
            Result from the tool
        """
        # Access tool information from context.message.params
        tool_name = "unknown"
        tool_args = {}

        try:
            if hasattr(context, "message") and hasattr(context.message, "params"):
                params = context.message.params
                tool_name = params.name if hasattr(params, "name") else "unknown"
                tool_args = params.arguments if hasattr(params, "arguments") else {}
        except Exception as e:
            logger.warning(f"Failed to extract tool info from context: {e}")

        # Extract site info from unified tools
        site = tool_args.get("site") if "site" in tool_args else None

        # Extract project_id from per-site tools
        project_id = None
        if "_site" in tool_name or "_" in tool_name:
            # Per-site tool format: wordpress_site1_get_post
            parts = tool_name.split("_")
            if len(parts) >= 2 and parts[1].startswith("site"):
                project_id = f"{parts[0]}_{parts[1]}"

        start_time = time.time()
        error_msg = None
        result_summary = None

        try:
            # Call the tool
            result = await call_next(context)

            # Extract brief summary from result
            if isinstance(result, str):
                # Limit summary to first 100 characters
                result_summary = result[:100] + "..." if len(result) > 100 else result
            else:
                result_summary = f"Result type: {type(result).__name__}"

            return result

        except Exception as e:
            error_msg = str(e)
            raise

        finally:
            # Calculate duration
            duration_ms = int((time.time() - start_time) * 1000)

            # Log the tool call
            try:
                audit_logger.log_tool_call(
                    tool_name=tool_name,
                    site=site,
                    project_id=project_id,
                    params=tool_args,
                    result_summary=result_summary,
                    error=error_msg,
                    duration_ms=duration_ms,
                )
            except Exception as log_error:
                # Don't let logging errors break the tool call
                logger.error(f"Failed to log audit entry: {log_error}", exc_info=True)


# Add audit logging middleware to MCP server
mcp.add_middleware(AuditLoggingMiddleware())
logger.info("Audit logging middleware enabled")


# === RATE LIMITING ===

# Initialize rate limiter
rate_limiter = get_rate_limiter()
logger.info("Rate limiter initialized")


class RateLimitMiddleware(Middleware):
    """
    Middleware to enforce rate limiting for all tool calls.

    Uses Token Bucket algorithm to prevent API abuse with multi-level limits:
    - Per minute: 60 requests (default)
    - Per hour: 1000 requests (default)
    - Per day: 10000 requests (default)

    Runs after authentication to use client ID for tracking.
    """

    async def on_call_tool(self, context: MiddlewareContext, call_next):
        """
        Check rate limits before allowing tool execution.

        Args:
            context: Middleware context containing tool call information
            call_next: Next middleware or tool handler in the chain

        Returns:
            Result from the tool if rate limit allows

        Raises:
            ToolError: If rate limit is exceeded
        """
        # Extract client ID from auth header
        client_id = "unknown"
        try:
            headers = get_http_headers()
            auth_header = headers.get("authorization", "")
            if auth_header.startswith("Bearer "):
                # Use the token as client ID (could hash for privacy in production)
                client_id = auth_header.removeprefix("Bearer ").strip()
        except Exception as e:
            logger.warning(f"Failed to extract client ID: {e}")

        # Extract tool information
        tool_name = "unknown"
        plugin_type = None
        try:
            if hasattr(context, "message") and hasattr(context.message, "params"):
                params = context.message.params
                tool_name = params.name if hasattr(params, "name") else "unknown"

                # Determine plugin type from tool name
                # Clean any MCP namespace prefix
                tn = tool_name
                if tool_name.startswith("mcp__") and "__" in tool_name[5:]:
                    tn = tool_name.split("__", 2)[-1]
                if tn.startswith("wordpress_"):
                    plugin_type = "wordpress"
                elif tool_name.startswith("woocommerce_"):
                    plugin_type = "woocommerce"
        except Exception as e:
            logger.warning(f"Failed to extract tool info: {e}")

        # Check rate limit
        allowed, message, retry_after = rate_limiter.check_rate_limit(
            client_id=client_id, tool_name=tool_name, plugin_type=plugin_type
        )

        if not allowed:
            # Log rejection via audit logger
            try:
                audit_logger.log_system_event(
                    event=f"Rate limit exceeded for client {client_id[:8]}...",
                    details={
                        "tool_name": tool_name,
                        "plugin_type": plugin_type,
                        "reason": message,
                        "retry_after_seconds": retry_after,
                    },
                    level=LogLevel.WARNING,
                )
            except Exception as log_error:
                logger.error(f"Failed to log rate limit rejection: {log_error}")

            # Return error with retry-after information
            raise ToolError(f"{message}. Please retry after {int(retry_after)} seconds.")

        # Rate limit check passed - proceed with tool execution
        return await call_next(context)


# Add rate limiting middleware to MCP server
mcp.add_middleware(RateLimitMiddleware())
logger.info("Rate limiting middleware enabled")


# === HEALTH MONITORING ===

from core import initialize_health_monitor

# Initialize health monitor
health_monitor = initialize_health_monitor(
    audit_logger=audit_logger,
    site_manager=site_manager,
    metrics_retention_hours=24,
    max_metrics_per_project=1000,
)
logger.info("Health monitor initialized")


class HealthMetricsMiddleware(Middleware):
    """
    Middleware to track health metrics for all tool calls.

    Tracks:
    - Response time
    - Success/failure rate
    - Error messages
    - Project-specific metrics
    """

    async def on_call_tool(self, context: MiddlewareContext, call_next):
        """
        Track health metrics for tool calls.

        Args:
            context: Middleware context containing tool call information
            call_next: Next middleware or tool handler in the chain

        Returns:
            Result from the tool
        """
        # Extract tool information
        tool_name = "unknown"
        tool_args = {}
        project_id = None

        try:
            if hasattr(context, "message") and hasattr(context.message, "params"):
                params = context.message.params
                tool_name = params.name if hasattr(params, "name") else "unknown"
                tool_args = params.arguments if hasattr(params, "arguments") else {}
        except Exception as e:
            logger.warning(f"Failed to extract tool info: {e}")

        # Extract project_id from unified tools (site parameter)
        if "site" in tool_args:
            site = tool_args["site"]
            # Resolve alias to full_id using site_manager
            try:
                # Try to determine plugin type from tool name
                plugin_type = tool_name.split("_")[0] if "_" in tool_name else "wordpress"
                site_config = site_manager.get_site_config(plugin_type, site)
                project_id = site_config.get_full_id()
            except (ValueError, Exception):
                # Fallback to site value if resolution fails
                pass

        # Extract project_id from per-site tools (tool name)
        if not project_id and "_" in tool_name:
            parts = tool_name.split("_")
            if len(parts) >= 2 and parts[1].startswith("site"):
                project_id = f"{parts[0]}_{parts[1]}"

        # Skip tracking for system tools (no project_id)
        if not project_id:
            return await call_next(context)

        # Track metrics
        start_time = time.time()
        error_msg = None
        success = False

        try:
            result = await call_next(context)
            success = True
            return result

        except Exception as e:
            error_msg = str(e)
            raise

        finally:
            # Calculate response time
            response_time_ms = (time.time() - start_time) * 1000

            # Record metric
            try:
                health_monitor.record_request(
                    project_id=project_id,
                    response_time_ms=response_time_ms,
                    success=success,
                    error_message=error_msg,
                )
            except Exception as metric_error:
                # Don't let metrics tracking errors break the tool call
                logger.error(f"Failed to record health metric: {metric_error}", exc_info=True)


# Add health metrics middleware
mcp.add_middleware(HealthMetricsMiddleware())
logger.info("Health metrics middleware enabled")


# === USER AUTHENTICATION (E.2: OAuth Social Login) ===

try:
    from core.user_auth import initialize_user_auth

    initialize_user_auth()
    logger.info("User authentication (OAuth Social Login) initialized")
except Exception as e:
    logger.info("User auth not initialized (OAuth not configured): %s", e)


# === ENCRYPTION KEY VALIDATION (E.1: Credential Encryption) ===

_encryption_key_raw = os.getenv("ENCRYPTION_KEY", "")
if _encryption_key_raw:
    try:
        import base64 as _b64

        _key_bytes = _b64.b64decode(_encryption_key_raw)
        if len(_key_bytes) != 32:
            logger.critical(
                "FATAL: ENCRYPTION_KEY must decode to exactly 32 bytes, got %d. "
                'Generate a valid key with: python -c "import os,base64; '
                'print(base64.b64encode(os.urandom(32)).decode())"',
                len(_key_bytes),
            )
            sys.exit(1)
        else:
            from core.encryption import initialize_credential_encryption

            initialize_credential_encryption(_encryption_key_raw)
            logger.info("Credential encryption initialized")
    except Exception as e:
        logger.critical(
            "FATAL: Invalid ENCRYPTION_KEY: %s. "
            'Generate a valid key with: python -c "import os,base64; '
            'print(base64.b64encode(os.urandom(32)).decode())"',
            e,
        )
        sys.exit(1)
else:
    logger.info("ENCRYPTION_KEY not set — user site management disabled")


# === USER API KEY MANAGER (E.3: Site Management) ===

try:
    from core.user_keys import initialize_user_key_manager

    initialize_user_key_manager()
    logger.info("User API Key Manager initialized")
except Exception as e:
    logger.info("User API Key Manager not initialized: %s", e)


# === ENDPOINT MIDDLEWARE HELPER ===


def add_endpoint_middleware(endpoint_mcp, endpoint_name: str = "unknown"):
    """
    Add authentication, audit, and rate limiting middleware to an endpoint.

    This ensures all sub-endpoints (plugin type and per-project) have proper
    authentication just like the main /mcp endpoint.

    Args:
        endpoint_mcp: FastMCP instance to add middleware to
        endpoint_name: Name for logging purposes
    """
    endpoint_mcp.add_middleware(UserAuthMiddleware())
    endpoint_mcp.add_middleware(AuditLoggingMiddleware())
    endpoint_mcp.add_middleware(RateLimitMiddleware())
    logger.debug(f"Added auth/audit/rate-limit middleware to {endpoint_name} endpoint")


# === SYSTEM TOOLS ===


# Internal implementation functions (not decorated) for system tools
async def _list_projects_impl() -> str:
    """Internal implementation for listing projects (reads from SiteManager)."""
    import json

    try:
        all_sites = site_manager.list_all_sites()
        projects = []

        for site_info in all_sites:
            alias = site_info.get("alias")
            site_id = site_info.get("site_id")
            full_id = site_info.get("full_id", "")
            path_suffix = alias if alias and alias != site_id else full_id
            projects.append(
                {
                    "id": full_id,
                    "type": site_info.get("plugin_type", ""),
                    "project_id": site_id,
                    "alias": alias,
                    "endpoint": f"/project/{path_suffix}/mcp",
                }
            )

        result = {"total": len(projects), "projects": projects}
        return json.dumps(result, indent=2)
    except Exception as e:
        logger.error(f"Error listing projects: {e}", exc_info=True)
        return f"Error: {str(e)}"


@mcp.tool()
async def list_projects() -> str:
    """
    List all discovered projects.

    Returns information about all projects that have been configured
    through environment variables.

    Returns:
        JSON string with list of projects
    """
    return await _list_projects_impl()


# Phase K.2.3: Helper for site discovery in plugin endpoints
async def _list_sites_impl(plugin_type: str) -> str:
    """
    Internal implementation for listing available sites for a plugin type.

    Args:
        plugin_type: Type of plugin (wordpress, woocommerce, wordpress_specialist)

    Returns:
        JSON string with list of available sites
    """
    import json

    try:
        sites = site_manager.get_sites_by_type(plugin_type)

        result = {"plugin_type": plugin_type, "total": len(sites), "sites": []}

        for site in sites:
            site_info = {"site": site.alias or site.url, "url": site.url, "alias": site.alias}
            result["sites"].append(site_info)

        if not sites:
            result["message"] = f"No {plugin_type} sites configured. Check environment variables."
        else:
            result["message"] = (
                f"Found {len(sites)} {plugin_type} site(s). Use any 'site' value as the site parameter."
            )

        return json.dumps(result, indent=2)
    except Exception as e:
        logger.error(f"Error listing sites for {plugin_type}: {e}", exc_info=True)
        return json.dumps(
            {"error": str(e), "message": f"Failed to list {plugin_type} sites"}, indent=2
        )


@mcp.tool()
async def get_project_info(project_id: str) -> str:
    """
    Get detailed information about a specific project.

    Args:
        project_id: Full project identifier (e.g., 'wordpress_site1')

    Returns:
        JSON string with project information
    """
    import json

    try:
        info = None
        for site_data in site_manager.list_all_sites():
            if site_data["full_id"] == project_id:
                info = site_data
                break

        if info is None:
            return f"Project '{project_id}' not found. Use list_projects to see available projects."

        return json.dumps(info, indent=2)
    except Exception as e:
        logger.error(f"Error getting project info: {e}", exc_info=True)
        return f"Error: {str(e)}"


@mcp.tool()
async def check_all_projects_health() -> str:
    """
    Check health status of all projects with enhanced metrics.

    Performs comprehensive health checks on all configured projects including:
    - Accessibility and response time
    - Error rates and recent failures
    - Alert threshold violations
    - Historical metrics (last hour)

    Returns:
        JSON string with detailed health status and metrics
    """
    try:
        # Use enhanced health monitor
        health_data = await health_monitor.check_all_projects_health(include_metrics=True)

        import json

        return json.dumps(health_data, indent=2)
    except Exception as e:
        logger.error(f"Error checking health: {e}", exc_info=True)
        return f"Error: {str(e)}"


@mcp.tool()
async def get_project_health(project_id: str) -> str:
    """
    Get detailed health information for a specific project.

    Args:
        project_id: Full project identifier (e.g., "wordpress_site1")

    Returns:
        JSON string with comprehensive health metrics including:
        - Current health status
        - Response time statistics
        - Error rate (last hour)
        - Recent errors
        - Active alerts
    """
    try:
        status = await health_monitor.check_project_health(project_id, include_metrics=True)

        import json

        return json.dumps(status.to_dict(), indent=2)
    except Exception as e:
        logger.error(f"Error getting project health: {e}", exc_info=True)
        return f"Error: {str(e)}"


@mcp.tool()
async def get_system_metrics() -> str:
    """
    Get overall MCP server metrics and statistics.

    Returns system-wide metrics including:
    - Uptime
    - Total requests (success/failure)
    - Average response time
    - Error rate percentage
    - Requests per minute

    Returns:
        JSON string with system metrics
    """
    try:
        metrics = health_monitor.get_system_metrics()

        import json

        return json.dumps(metrics.to_dict(), indent=2)
    except Exception as e:
        logger.error(f"Error getting system metrics: {e}", exc_info=True)
        return f"Error: {str(e)}"


@mcp.tool()
async def get_system_uptime() -> str:
    """
    Get MCP server uptime information.

    Returns:
        JSON string with uptime in various formats (seconds, minutes, hours, days)
    """
    try:
        uptime = health_monitor.get_uptime()

        import json

        return json.dumps(uptime, indent=2)
    except Exception as e:
        logger.error(f"Error getting uptime: {e}", exc_info=True)
        return f"Error: {str(e)}"


@mcp.tool()
async def get_project_metrics(project_id: str, hours: int = 1) -> str:
    """
    Get historical metrics for a specific project.

    Args:
        project_id: Full project identifier (e.g., "wordpress_site1")
        hours: Number of hours of history to analyze (default: 1, max: 24)

    Returns:
        JSON string with historical metrics including:
        - Request counts and success/failure rates
        - Response time statistics (min/avg/max)
        - Error rate over time
        - Recent error messages
    """
    try:
        # Limit to 24 hours max
        hours = min(hours, 24)

        metrics = health_monitor.get_project_metrics(project_id, hours=hours)

        import json

        return json.dumps(metrics, indent=2)
    except Exception as e:
        logger.error(f"Error getting project metrics: {e}", exc_info=True)
        return f"Error: {str(e)}"


@mcp.tool()
async def export_health_metrics(output_path: str = "logs/metrics_export.json") -> str:
    """
    Export all health metrics to a JSON file.

    Args:
        output_path: Path to output file (default: logs/metrics_export.json)

    Returns:
        Path to exported file
    """
    try:
        exported_path = health_monitor.export_metrics(output_path=output_path, format="json")
        return f"Metrics exported successfully to: {exported_path}"
    except Exception as e:
        logger.error(f"Error exporting metrics: {e}", exc_info=True)
        return f"Error: {str(e)}"


# Internal implementations for rate limiting tools
async def _get_rate_limit_stats_impl(client_id: str = None) -> str:
    """Internal implementation for getting rate limit stats."""
    try:
        import json

        if client_id:
            stats = rate_limiter.get_client_stats(client_id)
            if stats is None:
                return f"No rate limit data found for client: {client_id}"
            return json.dumps(stats, indent=2)
        else:
            stats = rate_limiter.get_all_stats()
            return json.dumps(stats, indent=2)
    except Exception as e:
        logger.error(f"Error getting rate limit stats: {e}", exc_info=True)
        return f"Error: {str(e)}"


async def _reset_rate_limit_impl(client_id: str = None) -> str:
    """Internal implementation for resetting rate limit."""
    try:
        if client_id:
            success = rate_limiter.reset_client(client_id)
            if success:
                return f"Rate limit state reset successfully for client: {client_id[:8]}..."
            else:
                return f"Client not found: {client_id}"
        else:
            count = rate_limiter.reset_all()
            return f"Rate limit state reset successfully for {count} client(s)"
    except Exception as e:
        logger.error(f"Error resetting rate limit: {e}", exc_info=True)
        return f"Error: {str(e)}"


@mcp.tool()
async def get_rate_limit_stats(client_id: str = None) -> str:
    """
    Get rate limiting statistics.

    Args:
        client_id: Optional client identifier to get specific client stats.
                   If not provided, returns global statistics for all clients.

    Returns:
        JSON string with rate limit statistics
    """
    return await _get_rate_limit_stats_impl(client_id)


@mcp.tool()
async def reset_rate_limit(client_id: str = None) -> str:
    """
    Reset rate limit state for a client or all clients.

    CAUTION: This is an administrative tool. Use with care.

    Args:
        client_id: Optional client identifier to reset.
                   If not provided, resets ALL clients.

    Returns:
        Confirmation message with number of clients reset
    """
    return await _reset_rate_limit_impl(client_id)


# === DYNAMIC TOOL REGISTRATION ===


def create_dynamic_tool(name: str, description: str, handler, input_schema: dict):
    """
    Create a dynamic tool wrapper that works with FastMCP's decorator pattern.

    Args:
        name: Tool name
        description: Tool description
        handler: The async function to call
        input_schema: JSON schema for input parameters

    Returns:
        Wrapped async function compatible with FastMCP
    """
    import inspect

    # Build function signature dynamically from input schema
    # FastMCP requires explicit parameters, not **kwargs

    params = []
    annotations = {}

    if input_schema and "properties" in input_schema:
        required_params = input_schema.get("required", [])

        for param_name, param_info in input_schema["properties"].items():
            # Map JSON schema types to Python types
            param_type = param_info.get("type", "string")

            if param_type == "string":
                py_type = str
            elif param_type == "integer":
                py_type = int
            elif param_type == "boolean":
                py_type = bool
            elif param_type == "array":
                py_type = list
            elif param_type == "object":
                py_type = dict
            else:
                py_type = str  # Default to string

            # If parameter is not required or has a default, make it Optional
            is_required = param_name in required_params
            has_default = "default" in param_info

            if not is_required or has_default:
                annotations[param_name] = Optional[py_type]
                default_value = param_info.get("default", None)
                params.append(
                    inspect.Parameter(
                        param_name,
                        inspect.Parameter.POSITIONAL_OR_KEYWORD,
                        default=default_value,
                        annotation=Optional[py_type],
                    )
                )
            else:
                annotations[param_name] = py_type
                params.append(
                    inspect.Parameter(
                        param_name, inspect.Parameter.POSITIONAL_OR_KEYWORD, annotation=py_type
                    )
                )

    # Sort params: required (no default) first, then optional (with default).
    # Python/inspect.Signature requires non-default args before default args.
    required_first = [p for p in params if p.default is inspect.Parameter.empty]
    optional_after = [p for p in params if p.default is not inspect.Parameter.empty]
    params = required_first + optional_after

    # Create signature
    sig = inspect.Signature(params)

    # Create wrapper function using closure (no exec)
    async def dynamic_wrapper(**kwargs):
        return await handler(**kwargs)

    # Set function metadata so FastMCP recognizes it correctly
    dynamic_wrapper.__name__ = name
    dynamic_wrapper.__qualname__ = name
    dynamic_wrapper.__doc__ = description
    dynamic_wrapper.__signature__ = sig
    annotations["return"] = str  # All our tools return strings
    dynamic_wrapper.__annotations__ = annotations

    return dynamic_wrapper


def register_project_tools():
    """
    Dynamically register all project tools from plugins.

    This function is called at startup to register all tools
    from all discovered projects.

    Note: FastMCP requires using the @mcp.tool() decorator or mcp.tool(function)
    for tool registration.
    """
    logger.info("=" * 60)
    logger.info("TOOL REGISTRATION")
    logger.info("=" * 60)

    logger.info("Generating tools with ToolGenerator...")

    from plugins.wordpress.plugin import WordPressPlugin

    # Generate tools for WordPress
    logger.info("Generating WordPress tools from plugin specifications...")
    try:
        wordpress_tools = tool_generator.generate_tools(WordPressPlugin, "wordpress")
        logger.info(f"Generated {len(wordpress_tools)} WordPress tools from ToolGenerator")

        # Register WordPress tools in ToolRegistry
        for tool_def in wordpress_tools:
            try:
                tool_registry.register(tool_def)
            except Exception as e:
                logger.error(f"Failed to register WordPress tool {tool_def.name}: {e}")

    except Exception as e:
        logger.error(f"Failed to generate WordPress tools: {e}", exc_info=True)

    # Generate tools for WooCommerce (Phase D.1 - Split from WordPress Core)
    logger.info("Generating WooCommerce tools from plugin specifications...")
    try:
        from plugins.woocommerce.plugin import WooCommercePlugin

        woocommerce_tools = tool_generator.generate_tools(WooCommercePlugin, "woocommerce")
        logger.info(f"Generated {len(woocommerce_tools)} WooCommerce tools from ToolGenerator")

        # Register WooCommerce tools in ToolRegistry
        for tool_def in woocommerce_tools:
            try:
                tool_registry.register(tool_def)
            except Exception as e:
                logger.error(f"Failed to register WooCommerce tool {tool_def.name}: {e}")

    except Exception as e:
        logger.error(f"Failed to generate WooCommerce tools: {e}", exc_info=True)

    # F.19.1: Generate tools for WordPress Specialist (companion-backed,
    # no Docker socket). Each registered plugin needs its own explicit
    # tool-generation pass; the plugin registry alone doesn't trigger
    # ToolGenerator. (The legacy wordpress_advanced plugin was sunset
    # in F.19.3.2-.3 — wordpress_specialist now owns db inspection +
    # bulk fan-out via companion v2.18.0.)
    logger.info("Generating WordPress Specialist tools from plugin specifications...")
    try:
        from plugins.wordpress_specialist.plugin import WordPressSpecialistPlugin

        wordpress_specialist_tools = tool_generator.generate_tools(
            WordPressSpecialistPlugin, "wordpress_specialist"
        )
        logger.info(
            f"Generated {len(wordpress_specialist_tools)} WordPress Specialist tools from ToolGenerator"
        )

        for tool_def in wordpress_specialist_tools:
            try:
                tool_registry.register(tool_def)
            except Exception as e:
                logger.error(f"Failed to register WordPress Specialist tool {tool_def.name}: {e}")

    except Exception as e:
        logger.error(f"Failed to generate WordPress Specialist tools: {e}", exc_info=True)

    # Generate tools for Gitea (Phase C)
    logger.info("Generating Gitea tools from plugin specifications...")
    try:
        from plugins.gitea.plugin import GiteaPlugin

        gitea_tools = tool_generator.generate_tools(GiteaPlugin, "gitea")
        logger.info(f"Generated {len(gitea_tools)} Gitea tools from ToolGenerator")

        # Register Gitea tools in ToolRegistry
        for tool_def in gitea_tools:
            try:
                tool_registry.register(tool_def)
            except Exception as e:
                logger.error(f"Failed to register Gitea tool {tool_def.name}: {e}")

    except Exception as e:
        logger.error(f"Failed to generate Gitea tools: {e}", exc_info=True)

    # Generate tools for n8n (Phase F)
    logger.info("Generating n8n tools from plugin specifications...")
    try:
        from plugins.n8n.plugin import N8nPlugin

        n8n_tools = tool_generator.generate_tools(N8nPlugin, "n8n")
        logger.info(f"Generated {len(n8n_tools)} n8n tools from ToolGenerator")

        # Register n8n tools in ToolRegistry
        for tool_def in n8n_tools:
            try:
                tool_registry.register(tool_def)
            except Exception as e:
                logger.error(f"Failed to register n8n tool {tool_def.name}: {e}")

    except Exception as e:
        logger.error(f"Failed to generate n8n tools: {e}", exc_info=True)

    # Generate tools for Supabase (Phase G - Self-Hosted)
    logger.info("Generating Supabase tools from plugin specifications...")
    try:
        from plugins.supabase.plugin import SupabasePlugin

        supabase_tools = tool_generator.generate_tools(SupabasePlugin, "supabase")
        logger.info(f"Generated {len(supabase_tools)} Supabase tools from ToolGenerator")

        # Register Supabase tools in ToolRegistry
        for tool_def in supabase_tools:
            try:
                tool_registry.register(tool_def)
            except Exception as e:
                logger.error(f"Failed to register Supabase tool {tool_def.name}: {e}")

    except Exception as e:
        logger.error(f"Failed to generate Supabase tools: {e}", exc_info=True)

    # Generate tools for OpenPanel (Phase H - Product Analytics)
    logger.info("Generating OpenPanel tools from plugin specifications...")
    try:
        from plugins.openpanel.plugin import OpenPanelPlugin

        openpanel_tools = tool_generator.generate_tools(OpenPanelPlugin, "openpanel")
        logger.info(f"Generated {len(openpanel_tools)} OpenPanel tools from ToolGenerator")

        # Register OpenPanel tools in ToolRegistry
        for tool_def in openpanel_tools:
            try:
                tool_registry.register(tool_def)
            except Exception as e:
                logger.error(f"Failed to register OpenPanel tool {tool_def.name}: {e}")

    except Exception as e:
        logger.error(f"Failed to generate OpenPanel tools: {e}", exc_info=True)

    # Generate tools for Coolify (Phase F.17 - Deployment Management)
    logger.info("Generating Coolify tools from plugin specifications...")
    try:
        from plugins.coolify.plugin import CoolifyPlugin

        coolify_tools = tool_generator.generate_tools(CoolifyPlugin, "coolify")
        logger.info(f"Generated {len(coolify_tools)} Coolify tools from ToolGenerator")

        # Register Coolify tools in ToolRegistry
        for tool_def in coolify_tools:
            try:
                tool_registry.register(tool_def)
            except Exception as e:
                logger.error(f"Failed to register Coolify tool {tool_def.name}: {e}")

    except Exception as e:
        logger.error(f"Failed to generate Coolify tools: {e}", exc_info=True)

    logger.info(f"Registered {tool_registry.get_count()} tools in ToolRegistry")

    # Register tools with FastMCP
    logger.info("Registering tools with FastMCP...")
    fastmcp_count = 0

    for tool_def in tool_registry.get_all():
        try:
            # Create a wrapper function compatible with FastMCP
            wrapped_tool = create_dynamic_tool(
                tool_def.name, tool_def.description, tool_def.handler, tool_def.input_schema
            )

            # Register with FastMCP
            mcp.tool()(wrapped_tool)

            fastmcp_count += 1
            logger.debug(f"Registered tool with FastMCP: {tool_def.name}")

        except Exception as e:
            logger.error(f"Error registering tool {tool_def.name} with FastMCP: {e}", exc_info=True)

    logger.info(f"Registered {fastmcp_count} tools with FastMCP")

    # Tool count breakdown
    tool_counts = tool_registry.get_count_by_plugin()
    logger.info("Tool count by plugin:")
    for plugin_type, count in tool_counts.items():
        logger.info(f"  - {plugin_type}: {count} tools")

    logger.info("=" * 60)

    # System tools count:
    # - 10 health/monitoring/rate-limit tools
    # - 4 system config tools (endpoints, system_info, audit_log, set_rate_limit_config)
    # - 6 API key management tools
    # - 4 OAuth management tools
    system_tools_count = 24

    total_tools = tool_registry.get_count() + system_tools_count
    logger.info(
        f"Total tools available: {total_tools} ({tool_registry.get_count()} plugin + {system_tools_count} system)"
    )
    logger.info("Tool count stays constant regardless of site count")

    return total_tools


# Register all project tools at startup
_total_tool_count = register_project_tools()


# === OAUTH 2.1 ENDPOINTS ===

from urllib.parse import urlencode

from core.oauth import OAuthError, get_oauth_server

oauth_server = get_oauth_server()


def get_oauth_base_url(request: Request) -> str:
    """
    Get the correct base URL for OAuth endpoints.

    Priority: OAUTH_BASE_URL env var > X-Forwarded headers > request.base_url

    This ensures consistency across all OAuth metadata endpoints,
    especially when behind a reverse proxy (like Coolify/Traefik).
    """
    oauth_base_url = os.getenv("OAUTH_BASE_URL")
    if oauth_base_url:
        return oauth_base_url.rstrip("/")

    # Check X-Forwarded headers (for reverse proxy/Coolify)
    x_forwarded_proto = request.headers.get("x-forwarded-proto", "http")
    x_forwarded_host = request.headers.get("x-forwarded-host")

    if x_forwarded_host:
        return f"{x_forwarded_proto}://{x_forwarded_host}"

    return str(request.base_url).rstrip("/")


@mcp.custom_route("/.well-known/oauth-authorization-server", methods=["GET"])
async def oauth_metadata(request: Request) -> JSONResponse:
    """
    OAuth 2.0 Authorization Server Metadata (RFC 8414)

    Returns OAuth configuration for client discovery.
    This endpoint allows clients (like Claude Desktop, OpenAI) to discover
    the OAuth implementation and its capabilities.

    Returns:
        JSONResponse with OAuth metadata
    """
    base_url = get_oauth_base_url(request)

    metadata = {
        # RFC 8414 Required Fields
        "issuer": base_url,
        "authorization_endpoint": f"{base_url}/oauth/authorize",
        "token_endpoint": f"{base_url}/oauth/token",
        "registration_endpoint": f"{base_url}/oauth/register",  # RFC 7591 (requires auth)
        "revocation_endpoint": f"{base_url}/oauth/revoke",
        # Supported Features
        "response_types_supported": ["code"],
        "grant_types_supported": ["authorization_code", "refresh_token", "client_credentials"],
        "code_challenge_methods_supported": ["S256"],  # PKCE mandatory
        "scopes_supported": ["read", "write", "admin"],
        "token_endpoint_auth_methods_supported": ["client_secret_post", "client_secret_basic"],
        # Token Configuration
        "revocation_endpoint_auth_methods_supported": ["client_secret_post", "client_secret_basic"],
        "introspection_endpoint_auth_methods_supported": ["client_secret_post"],
        # Additional OAuth 2.1 Features
        "response_modes_supported": ["query"],
        "subject_types_supported": ["public"],
        "id_token_signing_alg_values_supported": ["HS256", "RS256"],
        # Custom MCP Extensions
        "mcp_version": "1.0",
        "mcp_oauth_enabled": True,
        "service_documentation": f"{base_url}/docs/OAUTH_GUIDE.md",
        # Security Model
        "registration_endpoint_requires_auth": False,  # Open registration
        "authorization_endpoint_auth_mode": OAUTH_AUTH_MODE,  # Current auth mode
        "authorization_endpoint_auth_method": "api_key",  # Query parameter: api_key=xxx
        "authorization_endpoint_auth_param": "api_key",  # Parameter name
        "authorization_endpoint_trusted_domains": (
            OAUTH_TRUSTED_DOMAINS if OAUTH_AUTH_MODE == "trusted_domains" else []
        ),
        "oauth_token_inherits_api_key_scope": True,  # OAuth tokens inherit API Key permissions
    }

    return JSONResponse(metadata)


@mcp.custom_route("/mcp/.well-known/oauth-authorization-server", methods=["GET"])
async def oauth_metadata_mcp_path(request: Request) -> JSONResponse:
    """
    OAuth metadata endpoint for /mcp base path.

    Some clients may look for metadata at /mcp/.well-known/oauth-authorization-server
    when the MCP server is mounted at /mcp.
    """
    return await oauth_metadata(request)


@mcp.custom_route("/.well-known/oauth-protected-resource", methods=["GET"])
async def oauth_protected_resource(request: Request) -> JSONResponse:
    """
    OAuth 2.0 Protected Resource Metadata (RFC 9728)

    Returns metadata about this protected resource server.
    Some OAuth clients look for this endpoint.
    """
    base_url = get_oauth_base_url(request)

    metadata = {
        "resource": base_url,
        "authorization_servers": [base_url],
        "scopes_supported": ["read", "write", "admin"],
        "bearer_methods_supported": ["header"],
        "resource_signing_alg_values_supported": ["HS256", "RS256"],
    }

    return JSONResponse(metadata)


async def oauth_protected_resource_path(request: Request) -> JSONResponse:
    """
    OAuth 2.0 Protected Resource Metadata for path-specific requests (RFC 9728)

    Claude MCP client first tries path-specific metadata endpoints like:
    /.well-known/oauth-protected-resource/project/javadpoor/mcp

    This handler catches these requests and returns the same metadata as the root endpoint.
    """
    base_url = get_oauth_base_url(request)

    metadata = {
        "resource": base_url,
        "authorization_servers": [base_url],
        "scopes_supported": ["read", "write", "admin"],
        "bearer_methods_supported": ["header"],
        "resource_signing_alg_values_supported": ["HS256", "RS256"],
    }

    return JSONResponse(metadata)


@mcp.custom_route("/oauth/register", methods=["POST"])
async def oauth_register(request: Request) -> JSONResponse:
    """
    OAuth 2.0 Dynamic Client Registration (RFC 7591)

    🔓 SECURITY MODEL: Open DCR for Trusted Clients (MCP Spec Compliant)

    This endpoint supports Dynamic Client Registration as required by MCP specification.

    **Two Authentication Modes:**

    1. **Open DCR (No Auth Required)**: For trusted MCP clients (Claude, ChatGPT)
       - redirect_uri must match DCR_ALLOWED_REDIRECT_PATTERNS
       - Rate limited per IP address
       - Audit logged

    2. **Protected DCR (Master API Key Required)**: For custom redirect_uris
       - Master API Key in Authorization header
       - No rate limiting
       - Full access

    **Security Layers:**
    - DCR only provides client_id/secret (entry to "waiting room")
    - User must still provide valid API Key in /oauth/authorize
    - OAuth token inherits API Key's permissions (project_id, scope)

    **Flow:**
    1. MCP Client (Claude) → DCR → Gets client_id + secret
    2. User redirected to /oauth/authorize → Enters API Key
    3. API Key validated → Authorization code issued
    4. Token exchange → Token inherits API Key permissions

    Request Body (JSON):
        {
            "client_name": "My Application",
            "redirect_uris": ["https://claude.ai/api/mcp/auth_callback"],
            "grant_types": ["authorization_code", "refresh_token"],
            "scope": "read write",
            "token_endpoint_auth_method": "client_secret_post"
        }

    Returns:
        {
            "client_id": "cmp_client_xxx",
            "client_secret": "secret_xxx",
            "client_id_issued_at": 1234567890,
            "client_secret_expires_at": 0,
            "client_name": "My Application",
            "redirect_uris": ["https://claude.ai/api/mcp/auth_callback"],
            "grant_types": ["authorization_code", "refresh_token"],
            "token_endpoint_auth_method": "client_secret_post"
        }
    """
    try:
        from core.oauth import get_client_registry

        client_ip = request.client.host if request.client else "unknown"

        # ========================================
        # Parse request body FIRST (needed for auth decision)
        # ========================================
        try:
            body = await request.json()
        except Exception:
            return JSONResponse(
                {"error": "invalid_request", "error_description": "Invalid JSON in request body"},
                status_code=400,
            )

        # Validate required fields
        if "redirect_uris" not in body:
            return JSONResponse(
                {"error": "invalid_redirect_uri", "error_description": "redirect_uris is required"},
                status_code=400,
            )

        redirect_uris = body.get("redirect_uris", [])

        # Validate redirect URIs format
        if not redirect_uris or not isinstance(redirect_uris, list):
            return JSONResponse(
                {
                    "error": "invalid_redirect_uri",
                    "error_description": "redirect_uris must be a non-empty array",
                },
                status_code=400,
            )

        # ========================================
        # SECURITY: Determine Authentication Mode
        # ========================================
        # Check if redirect_uris are in the allowlist for open DCR
        is_open_dcr_allowed = is_redirect_uri_allowed_for_open_dcr(redirect_uris)

        auth_header = request.headers.get("Authorization", "")
        api_key = None

        if auth_header.startswith("Bearer "):
            api_key = auth_header[7:]
        elif "api_key" in request.query_params:
            api_key = request.query_params["api_key"]

        has_valid_master_key = api_key and auth_manager.validate_master_key(api_key)

        # Decide authentication mode
        if is_open_dcr_allowed:
            # ========================================
            # Open DCR Mode (Claude, ChatGPT, etc.)
            # ========================================
            # Check rate limiting
            rate_ok, rate_error = check_dcr_rate_limit(client_ip)
            if not rate_ok:
                logger.warning(f"DCR rate limit exceeded for {client_ip}: {rate_error}")
                return JSONResponse(
                    {"error": "too_many_requests", "error_description": rate_error}, status_code=429
                )

            logger.info(
                f"Open DCR registration from {client_ip} for redirect_uris: {redirect_uris}"
            )

            # Audit log for open DCR
            audit_logger.log_system_event(
                event="oauth_dcr_open",
                details={
                    "client_ip": client_ip,
                    "redirect_uris": redirect_uris,
                    "client_name": body.get("client_name", "Unnamed Client"),
                    "auth_mode": "open_dcr",
                },
            )

        elif has_valid_master_key:
            # ========================================
            # Protected DCR Mode (Master API Key)
            # ========================================
            logger.info(f"Protected DCR registration from {client_ip} with Master API Key")

            # Audit log for protected DCR
            audit_logger.log_system_event(
                event="oauth_dcr_protected",
                details={
                    "client_ip": client_ip,
                    "redirect_uris": redirect_uris,
                    "client_name": body.get("client_name", "Unnamed Client"),
                    "auth_mode": "master_key",
                },
            )

        else:
            # ========================================
            # Unauthorized: redirect_uri not in allowlist AND no Master Key
            # ========================================
            logger.warning(
                f"Unauthorized DCR attempt from {client_ip}: "
                f"redirect_uris {redirect_uris} not in allowlist and no valid Master API Key"
            )
            return JSONResponse(
                {
                    "error": "unauthorized",
                    "error_description": (
                        "Dynamic Client Registration requires either: "
                        "1) redirect_uri from trusted MCP clients (Claude, ChatGPT), or "
                        "2) Master API Key in Authorization header. "
                        f"Your redirect_uris {redirect_uris} are not in the allowlist."
                    ),
                },
                status_code=401,
            )

        # ========================================
        # Extract fields with defaults
        # ========================================
        client_name = body.get("client_name", "Unnamed Client")
        grant_types = body.get("grant_types", ["authorization_code", "refresh_token"])
        scope = body.get("scope", "read write")
        token_endpoint_auth_method = body.get("token_endpoint_auth_method", "client_secret_post")

        # Convert scope string to list
        if isinstance(scope, str):
            allowed_scopes = scope.split()
        else:
            allowed_scopes = scope

        logger.info(f"OAuth client registration: {client_name} with redirect_uris: {redirect_uris}")

        # Register client
        client_registry = get_client_registry()

        client_id, client_secret = client_registry.create_client(
            client_name=client_name,
            redirect_uris=redirect_uris,
            grant_types=grant_types,
            allowed_scopes=allowed_scopes,
            metadata={
                "registered_via": "dynamic_client_registration",
                "token_endpoint_auth_method": token_endpoint_auth_method,
            },
        )

        # Get client info
        client_registry.get_client(client_id)

        logger.info(f"Dynamic client registration: {client_id} ({client_name})")

        # Return RFC 7591 compliant response
        import time

        response = {
            "client_id": client_id,
            "client_secret": client_secret,
            "client_id_issued_at": int(time.time()),
            "client_secret_expires_at": 0,  # Never expires
            "client_name": client_name,
            "redirect_uris": redirect_uris,
            "grant_types": grant_types,
            "response_types": ["code"],
            "token_endpoint_auth_method": token_endpoint_auth_method,
            "scope": " ".join(allowed_scopes),
        }

        return JSONResponse(response, status_code=201)

    except Exception as e:
        logger.error(f"Error in dynamic client registration: {e}", exc_info=True)
        return JSONResponse({"error": "server_error", "error_description": str(e)}, status_code=500)


@mcp.custom_route("/oauth/authorize", methods=["GET"])
async def oauth_authorize(request: Request):
    """
    OAuth 2.1 authorization endpoint - Phase E Web UI.

    Displays an HTML authorization page where users can enter their API Key.

    Query Parameters:
        client_id: OAuth client ID
        redirect_uri: Callback URI for authorization code
        response_type: Must be "code" (OAuth 2.1)
        code_challenge: PKCE code challenge (S256)
        code_challenge_method: Must be "S256"
        scope: Optional requested scopes (space-separated)
        state: Optional CSRF protection token

    Returns:
        HTML authorization page
    """
    try:
        # Get query parameters
        params = dict(request.query_params)

        # Validate basic OAuth parameters
        required = [
            "client_id",
            "redirect_uri",
            "response_type",
            "code_challenge",
            "code_challenge_method",
        ]
        missing = [p for p in required if p not in params]
        if missing:
            raise OAuthError(
                error="invalid_request",
                error_description=f"Missing parameters: {', '.join(missing)}",
            )

        # Validate authorization request
        validated = oauth_server.validate_authorization_request(
            client_id=params["client_id"],
            redirect_uri=params["redirect_uri"],
            response_type=params["response_type"],
            code_challenge=params["code_challenge"],
            code_challenge_method=params["code_challenge_method"],
            scope=params.get("scope"),
            state=params.get("state"),
        )

        # RFC 8707: Accept resource parameter
        resource = params.get("resource")
        if resource:
            expected_resource = get_oauth_base_url(request)
            if resource.rstrip("/") != expected_resource.rstrip("/"):
                logger.warning(
                    f"OAuth resource mismatch: got {resource}, expected {expected_resource}"
                )

        # Generate CSRF token
        csrf_token = csrf_manager.generate_token()

        # Detect existing dashboard session for session-based consent
        session_user = None
        try:
            from core.dashboard.auth import get_dashboard_auth

            dashboard_auth = get_dashboard_auth()

            # Try OAuth user session first (GitHub/Google login)
            user_session = dashboard_auth.get_user_session_from_request(request)
            if user_session and user_session.get("user_id"):
                session_user = {
                    "user_id": user_session["user_id"],
                    "email": user_session.get("email", ""),
                    "name": user_session.get("name", ""),
                    "role": user_session.get("role", "user"),
                    "type": "oauth_user",
                }
            else:
                # Try admin session
                admin_session = dashboard_auth.get_session_from_request(request)
                if admin_session:
                    session_user = {
                        "user_id": "admin",
                        "email": "",
                        "name": "Admin",
                        "role": "admin",
                        "type": admin_session.user_type,
                    }
        except Exception as e:
            logger.debug(f"No dashboard session detected for OAuth consent: {e}")

        # Get client info
        from core.oauth import get_client_registry

        client_registry = get_client_registry()
        client = client_registry.get_client(validated["client_id"])
        client_name = client.client_name if client else validated["client_id"]

        # Parse requested scopes
        scopes = validated["scope"].split() if validated["scope"] else []

        # Detect language (Phase E - Multi-language support)
        accept_language = request.headers.get("accept-language")
        query_lang = params.get("lang")
        lang = detect_language(accept_language, query_lang)
        translations = get_all_translations(lang)

        # Render authorization page
        logger.info(
            f"Rendering authorization page for client_id={validated['client_id']}, lang={lang}"
        )

        return templates.TemplateResponse(
            request,
            "oauth/authorize.html",
            {
                "client_id": validated["client_id"],
                "client_name": client_name,
                "redirect_uri": validated["redirect_uri"],
                "response_type": params["response_type"],  # from original params, not validated
                "code_challenge": validated["code_challenge"],
                "code_challenge_method": validated["code_challenge_method"],
                "scope": validated["scope"],
                "scopes": scopes,
                "state": validated.get("state", ""),
                "resource": resource or "",
                "csrf_token": csrf_token,
                "lang": lang,  # Language code (en/fa)
                "t": translations,  # All translations for this language
                "session_user": session_user,
                "return_url": str(request.url.path)
                + (("?" + str(request.url.query)) if request.url.query else ""),
            },
        )

    except OAuthError as e:
        # Render error page
        logger.warning(f"OAuth error: {e.error} - {e.error_description}")

        # Detect language for error page
        accept_language = request.headers.get("accept-language")
        query_lang = params.get("lang") if "params" in locals() else None
        lang = detect_language(accept_language, query_lang)
        translations = get_all_translations(lang)

        return templates.TemplateResponse(
            request,
            "oauth/error.html",
            {
                "error": e.error,
                "error_description": e.error_description,
                "redirect_uri": params.get("redirect_uri") if "params" in locals() else None,
                "state": params.get("state") if "params" in locals() else None,
                "lang": lang,
                "t": translations,
            },
            status_code=e.status_code,
        )

    except Exception as e:
        logger.error(f"OAuth authorize error: {e}", exc_info=True)

        # Detect language for error page
        accept_language = request.headers.get("accept-language")
        query_lang = params.get("lang") if "params" in locals() else None
        lang = detect_language(accept_language, query_lang)
        translations = get_all_translations(lang)

        # For unexpected errors, render error page
        return templates.TemplateResponse(
            request,
            "oauth/error.html",
            {
                "error": "server_error",
                "error_description": str(e),
                "redirect_uri": params.get("redirect_uri") if "params" in locals() else None,
                "state": params.get("state") if "params" in locals() else None,
                "lang": lang,
                "t": translations,
            },
            status_code=500,
        )


@mcp.custom_route("/oauth/authorize/confirm", methods=["POST"])
async def oauth_authorize_confirm(request: Request):
    """
    OAuth 2.1 authorization confirmation endpoint - Phase E.

    Handles form submission from the authorization page.
    Validates API Key and creates authorization code.

    Form Parameters:
        client_id, redirect_uri, response_type, code_challenge, code_challenge_method, scope, state
        csrf_token: CSRF protection token
        api_key: User's API Key
        action: "approve" or "deny"

    Returns:
        Redirect to redirect_uri with authorization code or error
    """
    try:
        # Parse form data
        form = await request.form()
        params = dict(form)
        resource = params.get("resource", "")

        # Validate action
        action = params.get("action")
        if action == "deny":
            # User denied authorization
            error_params = {
                "error": "access_denied",
                "error_description": "User denied authorization",
            }
            if params.get("state"):
                error_params["state"] = params["state"]

            error_url = f"{params['redirect_uri']}?{urlencode(error_params)}"
            logger.info(f"User denied authorization, redirecting to {error_url}")
            return RedirectResponse(url=error_url, status_code=302)

        # Validate CSRF token
        csrf_token = params.get("csrf_token")
        if not csrf_token or not csrf_manager.validate_token(csrf_token, consume=True):
            raise OAuthError(
                error="invalid_request",
                error_description="Invalid or expired CSRF token. Please try again.",
            )

        # Validate basic OAuth parameters
        required = [
            "client_id",
            "redirect_uri",
            "response_type",
            "code_challenge",
            "code_challenge_method",
        ]
        missing = [p for p in required if p not in params]
        if missing:
            raise OAuthError(
                error="invalid_request",
                error_description=f"Missing parameters: {', '.join(missing)}",
            )

        # Validate authorization request
        validated = oauth_server.validate_authorization_request(
            client_id=params["client_id"],
            redirect_uri=params["redirect_uri"],
            response_type=params["response_type"],
            code_challenge=params["code_challenge"],
            code_challenge_method=params["code_challenge_method"],
            scope=params.get("scope"),
            state=params.get("state"),
        )

        # Validate authentication (API Key or dashboard session)
        api_key = params.get("api_key", "")
        if not api_key:
            raise OAuthError(
                error="invalid_request", error_description="Authentication is required"
            )

        api_key_id = None
        api_key_project_id = None
        api_key_scope = None
        granted_scope = validated["scope"]

        try:
            # Path 1: Project API key (cmp_)
            if api_key.startswith("cmp_"):
                key_id = api_key_manager.validate_key(
                    api_key,
                    project_id="*",
                    required_scope="read",
                    skip_project_check=True,
                )
                api_key_info = api_key_manager.keys.get(key_id)
                api_key_id = key_id
                api_key_project_id = api_key_info.project_id
                api_key_scope = api_key_info.scope

            # Path 2: User API key (mhu_)
            elif api_key.startswith("mhu_"):
                from core.user_keys import get_user_key_manager

                user_key_mgr = get_user_key_manager()
                key_info = await user_key_mgr.validate_key(api_key)
                if key_info:
                    api_key_id = f"user:{key_info['user_id']}"
                    api_key_project_id = "*"
                    api_key_scope = key_info.get("scopes", "read write")
                    logger.info(
                        f"OAuth authorization: User API key validated - "
                        f"user_id={key_info['user_id']}, scope={api_key_scope}"
                    )
                else:
                    raise OAuthError(
                        error="access_denied",
                        error_description="Invalid or expired User API Key.",
                    )

            # Path 3: Dashboard session (social login)
            elif api_key == "__session__":
                from core.dashboard.auth import get_dashboard_auth

                dashboard_auth = get_dashboard_auth()

                # Try OAuth user session (GitHub/Google)
                user_session = dashboard_auth.get_user_session_from_request(request)
                if user_session and user_session.get("user_id"):
                    user_role = user_session.get("role", "user")
                    api_key_id = f"user:{user_session['user_id']}"
                    api_key_project_id = "*"
                    # All authenticated users get admin scope for their own services
                    api_key_scope = "read write admin"
                    logger.info(
                        f"OAuth authorization: Session-based consent - "
                        f"user_id={user_session['user_id']}, "
                        f"email={user_session.get('email', '')}, "
                        f"role={user_role}, scope={api_key_scope}"
                    )
                else:
                    # Try admin session
                    admin_session = dashboard_auth.get_session_from_request(request)
                    if admin_session:
                        api_key_id = f"admin:{admin_session.session_id}"
                        api_key_project_id = "*"
                        api_key_scope = "read write admin"
                        logger.info(
                            f"OAuth authorization: Admin session consent - "
                            f"type={admin_session.user_type}"
                        )
                    else:
                        raise OAuthError(
                            error="access_denied",
                            error_description="Session expired. Please log in again.",
                        )

            # Path 4: Master API Key
            elif auth_manager.validate_master_key(api_key):
                api_key_id = "master"
                api_key_project_id = "*"
                api_key_scope = "read write admin"

            else:
                raise OAuthError(
                    error="access_denied",
                    error_description="Invalid API Key. Please provide a valid API Key.",
                )

            # Limit requested scope to API Key's scope
            requested_scope = validated["scope"].split()
            allowed_scope = (
                api_key_scope.split() if isinstance(api_key_scope, str) else api_key_scope
            )

            # Filter to only allowed scopes
            granted_scope = " ".join([s for s in requested_scope if s in allowed_scope])

            if not granted_scope:
                # If no overlap, grant all API Key scopes
                granted_scope = (
                    api_key_scope if isinstance(api_key_scope, str) else " ".join(api_key_scope)
                )

            logger.info(
                f"OAuth authorization: API Key validated - key_id={api_key_id}, "
                f"project_id={api_key_project_id}, scope={api_key_scope}, "
                f"granted_scope='{granted_scope}'"
            )

        except Exception as e:
            logger.warning(f"OAuth authorization failed: Invalid API Key - {e}")
            raise OAuthError(error="access_denied", error_description="Invalid or expired API Key.")

        # Create authorization code with API Key metadata
        code = oauth_server.create_authorization_code(
            client_id=validated["client_id"],
            redirect_uri=validated["redirect_uri"],
            scope=granted_scope,
            code_challenge=validated["code_challenge"],
            code_challenge_method=validated["code_challenge_method"],
            api_key_id=api_key_id,
            api_key_project_id=api_key_project_id,
            api_key_scope=api_key_scope,
            resource=resource,
        )

        # Redirect back with authorization code
        callback_params = {"code": code}
        if validated.get("state"):
            callback_params["state"] = validated["state"]

        redirect_url = f"{validated['redirect_uri']}?{urlencode(callback_params)}"

        # Return HTTP 302 redirect to redirect_uri with authorization code
        logger.info(f"Authorization approved, redirecting to {redirect_url}")
        return RedirectResponse(url=redirect_url, status_code=302)

    except OAuthError as e:
        accept_language = request.headers.get("accept-language")
        query_lang = params.get("lang") if "params" in locals() else None
        lang = detect_language(accept_language, query_lang)
        translations = get_all_translations(lang)

        # If redirect_uri is available, redirect with error
        redirect_uri = params.get("redirect_uri") if "params" in locals() else None
        if redirect_uri:
            error_params = {"error": e.error, "error_description": e.error_description}
            if params.get("state"):
                error_params["state"] = params["state"]

            error_url = f"{redirect_uri}?{urlencode(error_params)}"
            logger.warning(f"OAuth error, redirecting to {error_url}")
            return RedirectResponse(url=error_url, status_code=302)
        else:
            # No redirect_uri, render error page
            return templates.TemplateResponse(
                request,
                "oauth/error.html",
                {
                    "error": e.error,
                    "error_description": e.error_description,
                    "lang": lang,
                    "t": translations,
                },
                status_code=e.status_code,
            )

    except Exception as e:
        logger.error(f"OAuth authorize confirm error: {e}", exc_info=True)
        accept_language = request.headers.get("accept-language")
        query_lang = params.get("lang") if "params" in locals() else None
        lang = detect_language(accept_language, query_lang)
        translations = get_all_translations(lang)

        # For unexpected errors, try to redirect or render error page
        redirect_uri = params.get("redirect_uri") if "params" in locals() else None
        if redirect_uri:
            error_url = f"{redirect_uri}?error=server_error&error_description={str(e)}"
            return RedirectResponse(url=error_url, status_code=302)
        else:
            return templates.TemplateResponse(
                request,
                "oauth/error.html",
                {
                    "error": "server_error",
                    "error_description": str(e),
                    "lang": lang,
                    "t": translations,
                },
                status_code=500,
            )


@mcp.custom_route("/oauth/token", methods=["POST"])
async def oauth_token(request: Request) -> JSONResponse:
    """
    OAuth 2.1 token endpoint.

    Handles token grants:
        - authorization_code: Exchange code for tokens (Step 3 of Authorization Code flow)
        - refresh_token: Refresh access token
        - client_credentials: Machine-to-machine authentication

    Request Body (form-urlencoded or JSON):
        grant_type: "authorization_code" | "refresh_token" | "client_credentials"
        client_id: OAuth client ID
        client_secret: Client secret

        For authorization_code:
            code: Authorization code from /authorize
            redirect_uri: Same redirect_uri used in /authorize
            code_verifier: PKCE code verifier

        For refresh_token:
            refresh_token: Current refresh token

        For client_credentials:
            scope: Optional requested scopes

    Returns:
        TokenResponse with access_token (and refresh_token for authorization_code)
    """
    try:
        # Parse request body (support both form and JSON)
        content_type = request.headers.get("content-type", "")
        if "application/json" in content_type:
            body = await request.json()
        else:
            # application/x-www-form-urlencoded
            form = await request.form()
            body = dict(form)

        # Validate required parameters
        if "grant_type" not in body:
            raise OAuthError(
                error="invalid_request", error_description="Missing grant_type parameter"
            )

        # Support client_secret_basic: extract client credentials from Authorization header
        # RFC 6749 Section 2.3.1 — client_id:client_secret as HTTP Basic Auth
        if "client_id" not in body or "client_secret" not in body:
            auth_header = request.headers.get("authorization", "")
            if auth_header.startswith("Basic "):
                try:
                    decoded = base64.b64decode(auth_header[6:]).decode("utf-8")
                    basic_client_id, basic_client_secret = decoded.split(":", 1)
                    body.setdefault("client_id", basic_client_id)
                    body.setdefault("client_secret", basic_client_secret)
                except Exception:
                    raise OAuthError(
                        error="invalid_client",
                        error_description="Invalid Basic authentication header",
                        status_code=401,
                    )

        if "client_id" not in body or "client_secret" not in body:
            raise OAuthError(
                error="invalid_request",
                error_description="Missing client_id or client_secret",
            )

        grant_type = body["grant_type"]

        # Handle different grant types
        if grant_type == "authorization_code":
            # Authorization Code Grant
            required = ["code", "redirect_uri", "code_verifier"]
            missing = [p for p in required if p not in body]
            if missing:
                raise OAuthError(
                    error="invalid_request",
                    error_description=f"Missing parameters: {', '.join(missing)}",
                )

            token_response = oauth_server.exchange_code_for_tokens(
                client_id=body["client_id"],
                client_secret=body["client_secret"],
                code=body["code"],
                redirect_uri=body["redirect_uri"],
                code_verifier=body["code_verifier"],
            )

        elif grant_type == "refresh_token":
            # Refresh Token Grant
            if "refresh_token" not in body:
                raise OAuthError(
                    error="invalid_request", error_description="Missing refresh_token parameter"
                )

            token_response = oauth_server.handle_refresh_token_grant(
                client_id=body["client_id"],
                client_secret=body["client_secret"],
                refresh_token=body["refresh_token"],
            )

        elif grant_type == "client_credentials":
            # Client Credentials Grant
            token_response = oauth_server.handle_client_credentials_grant(
                client_id=body["client_id"],
                client_secret=body["client_secret"],
                scope=body.get("scope"),
            )

        else:
            raise OAuthError(
                error="unsupported_grant_type",
                error_description=f"Grant type '{grant_type}' is not supported",
            )

        # Return token response
        return JSONResponse(token_response.model_dump())

    except OAuthError as e:
        return JSONResponse(
            {"error": e.error, "error_description": e.error_description}, status_code=e.status_code
        )
    except Exception as e:
        logger.error(f"OAuth token error: {e}", exc_info=True)
        return JSONResponse({"error": "server_error", "error_description": str(e)}, status_code=500)


@mcp.custom_route("/oauth/revoke", methods=["POST"])
async def oauth_revoke(request: Request) -> JSONResponse:
    """OAuth 2.0 Token Revocation (RFC 7009)."""
    try:
        content_type = request.headers.get("content-type", "")
        if "application/json" in content_type:
            body = await request.json()
        else:
            form = await request.form()
            body = dict(form)

        # Support client_secret_basic
        if "client_id" not in body or "client_secret" not in body:
            auth_header = request.headers.get("authorization", "")
            if auth_header.startswith("Basic "):
                try:
                    decoded = base64.b64decode(auth_header[6:]).decode("utf-8")
                    basic_client_id, basic_client_secret = decoded.split(":", 1)
                    body.setdefault("client_id", basic_client_id)
                    body.setdefault("client_secret", basic_client_secret)
                except Exception:
                    pass  # Per RFC 7009, don't error on this

        # Validate client credentials
        client_id = body.get("client_id")
        client_secret = body.get("client_secret")

        if not client_id or not client_secret:
            raise OAuthError(
                error="invalid_client",
                error_description="Client authentication required",
                status_code=401,
            )

        from core.oauth import get_client_registry

        client_registry = get_client_registry()
        if not client_registry.validate_client_secret(client_id, client_secret):
            raise OAuthError(
                error="invalid_client",
                error_description="Invalid client credentials",
                status_code=401,
            )

        # Get token to revoke
        token = body.get("token")
        if not token:
            return JSONResponse({}, status_code=200)

        token_type_hint = body.get("token_type_hint", "")

        # Revoke the token
        from core.oauth import get_token_manager

        token_manager = get_token_manager()

        # Try revoking as refresh token (access tokens are JWT/stateless, can't be revoked)
        token_manager.revoke_token(token, token_type="refresh")

        logger.info(f"OAuth token revoked: client_id={client_id}, hint={token_type_hint}")

        return JSONResponse({}, status_code=200)

    except OAuthError as e:
        return JSONResponse(
            {"error": e.error, "error_description": e.error_description},
            status_code=e.status_code,
        )
    except Exception as e:
        logger.error(f"Token revocation error: {e}")
        return JSONResponse({}, status_code=200)


# === OAUTH CLIENT MANAGEMENT TOOLS ===


# Internal implementation for OAuth register
async def _oauth_register_client_impl(
    client_name: str,
    redirect_uris: str,
    grant_types: str = "authorization_code,refresh_token",
    allowed_scopes: str = "read,write",
    metadata: dict = None,
) -> dict:
    """Internal implementation for registering OAuth client."""
    from core.oauth import get_client_registry

    try:
        client_registry = get_client_registry()
        redirect_uri_list = [uri.strip() for uri in redirect_uris.split(",")]
        grant_type_list = [gt.strip() for gt in grant_types.split(",")]
        scope_list = [s.strip() for s in allowed_scopes.split(",")]
        client_id, client_secret = client_registry.create_client(
            client_name=client_name,
            redirect_uris=redirect_uri_list,
            grant_types=grant_type_list,
            allowed_scopes=scope_list,
            metadata=metadata or {},
        )
        logger.info(f"Registered OAuth client: {client_id} ({client_name})")
        return {
            "client_id": client_id,
            "client_secret": client_secret,
            "client_name": client_name,
            "redirect_uris": redirect_uri_list,
            "grant_types": grant_type_list,
            "allowed_scopes": scope_list,
            "warning": "Save the client_secret now! It will not be shown again.",
        }
    except Exception as e:
        logger.error(f"Error registering OAuth client: {e}", exc_info=True)
        raise ToolError(f"Failed to register client: {e}")


@mcp.tool()
async def oauth_register_client(
    client_name: str,
    redirect_uris: str,
    grant_types: str = "authorization_code,refresh_token",
    allowed_scopes: str = "read,write",
    metadata: dict = None,
) -> dict:
    """
    Register a new OAuth 2.1 client.

    Args:
        client_name: Human-readable client name (e.g., "My OpenAI GPT")
        redirect_uris: Comma-separated redirect URIs
        grant_types: Comma-separated grant types (default: "authorization_code,refresh_token")
        allowed_scopes: Comma-separated scopes (default: "read,write")
        metadata: Optional metadata dict

    Returns:
        Dict with client_id and client_secret (SAVE THIS - shown only once!)
    """
    return await _oauth_register_client_impl(
        client_name, redirect_uris, grant_types, allowed_scopes, metadata
    )


# Internal implementations for OAuth tools
async def _oauth_list_clients_impl() -> dict:
    """Internal implementation for listing OAuth clients."""
    from core.oauth import get_client_registry

    try:
        client_registry = get_client_registry()
        clients = client_registry.list_clients()
        client_list = [
            {
                "client_id": client.client_id,
                "client_name": client.client_name,
                "redirect_uris": client.redirect_uris,
                "grant_types": client.grant_types,
                "allowed_scopes": client.allowed_scopes,
                "created_at": client.created_at.isoformat(),
                "metadata": client.metadata,
            }
            for client in clients
        ]
        return {"clients": client_list, "count": len(client_list)}
    except Exception as e:
        logger.error(f"Error listing OAuth clients: {e}", exc_info=True)
        raise ToolError(f"Failed to list clients: {e}")


async def _oauth_revoke_client_impl(client_id: str) -> dict:
    """Internal implementation for revoking OAuth client."""
    from core.oauth import get_client_registry

    try:
        client_registry = get_client_registry()
        client = client_registry.get_client(client_id)
        if not client:
            raise ToolError(f"Client not found: {client_id}")
        success = client_registry.delete_client(client_id)
        if success:
            logger.info(f"Revoked OAuth client: {client_id} ({client.client_name})")
            return {
                "success": True,
                "client_id": client_id,
                "client_name": client.client_name,
                "message": f"Client '{client.client_name}' has been revoked",
            }
        else:
            raise ToolError(f"Failed to delete client: {client_id}")
    except ToolError:
        raise
    except Exception as e:
        logger.error(f"Error revoking OAuth client: {e}", exc_info=True)
        raise ToolError(f"Failed to revoke client: {e}")


async def _oauth_get_client_info_impl(client_id: str) -> dict:
    """Internal implementation for getting OAuth client info."""
    from core.oauth import get_client_registry

    try:
        client_registry = get_client_registry()
        client = client_registry.get_client(client_id)
        if not client:
            return {"success": False, "error": f"Client not found: {client_id}"}
        return {
            "success": True,
            "client": {
                "client_id": client.client_id,
                "client_name": client.client_name,
                "redirect_uris": client.redirect_uris,
                "grant_types": client.grant_types,
                "allowed_scopes": client.allowed_scopes,
                "created_at": client.created_at.isoformat(),
                "metadata": client.metadata,
            },
        }
    except Exception as e:
        logger.error(f"Error getting OAuth client info: {e}", exc_info=True)
        return {"success": False, "error": str(e)}


@mcp.tool()
async def oauth_list_clients() -> dict:
    """
    List all registered OAuth clients.

    Returns client information (excluding secrets).

    Returns:
        Dict with list of clients
    """
    return await _oauth_list_clients_impl()


@mcp.tool()
async def oauth_revoke_client(client_id: str) -> dict:
    """
    Revoke (delete) an OAuth client.

    This will delete the client and prevent further authentication.
    Existing tokens will continue to work until they expire.

    Args:
        client_id: Client ID to revoke

    Returns:
        Dict with success status
    """
    return await _oauth_revoke_client_impl(client_id)


@mcp.tool()
async def oauth_get_client_info(client_id: str) -> dict:
    """
    Get detailed information about a specific OAuth client.

    Args:
        client_id: Client ID to query

    Returns:
        Dict with client information (excluding secret)
    """
    return await _oauth_get_client_info_impl(client_id)


# === PHASE X.3: SYSTEM TOOLS ===


# Internal implementations for system tools
async def _get_endpoints_impl() -> dict:
    """Internal implementation for listing endpoints."""
    try:
        all_sites = site_manager.list_all_sites()
        endpoints = [
            {
                "path": "/mcp",
                "name": "MCP Hub Admin",
                "description": "Full administrative access to all tools",
                "require_master_key": True,
                "plugin_types": ["all"],
            },
            {
                "path": "/system/mcp",
                "name": "System Manager",
                "description": "System management tools (API keys, OAuth, health, rate limiting)",
                "require_master_key": True,
                "plugin_types": ["system"],
                "tool_count": 24,
            },
            {
                "path": "/wordpress/mcp",
                "name": "WordPress Manager",
                "description": "WordPress content management",
                "require_master_key": False,
                "plugin_types": ["wordpress"],
            },
            {
                "path": "/woocommerce/mcp",
                "name": "WooCommerce Manager",
                "description": "WooCommerce e-commerce tools",
                "require_master_key": False,
                "plugin_types": ["woocommerce"],
            },
            {
                "path": "/wordpress-specialist/mcp",
                "name": "WordPress Specialist",
                "description": "Specialist WordPress management (plugins, themes, users, options, cron) — companion-backed",
                "require_master_key": False,
                "plugin_types": ["wordpress_specialist"],
            },
            {
                "path": "/gitea/mcp",
                "name": "Gitea Manager",
                "description": "Git repository management",
                "require_master_key": False,
                "plugin_types": ["gitea"],
            },
            {
                "path": "/n8n/mcp",
                "name": "n8n Automation",
                "description": "Workflow automation management",
                "require_master_key": False,
                "plugin_types": ["n8n"],
            },
            {
                "path": "/supabase/mcp",
                "name": "Supabase Manager",
                "description": "Supabase self-hosted management",
                "require_master_key": False,
                "plugin_types": ["supabase"],
            },
            {
                "path": "/openpanel/mcp",
                "name": "OpenPanel Analytics",
                "description": "OpenPanel product analytics",
                "require_master_key": False,
                "plugin_types": ["openpanel"],
            },
        ]
        project_endpoints = []
        for site_info in all_sites:
            alias = site_info.get("alias")
            full_id = site_info["full_id"]
            path_suffix = alias if alias and alias != site_info["site_id"] else full_id
            project_endpoints.append(
                {
                    "path": f"/project/{path_suffix}/mcp",
                    "name": f"Project: {full_id}",
                    "description": f"Site-locked tools for {full_id}",
                    "require_master_key": False,
                    "plugin_types": [site_info["plugin_type"]],
                    "site_id": site_info["site_id"],
                    "full_id": full_id,
                }
            )
        return {
            "success": True,
            "total_plugin_endpoints": len(endpoints),
            "total_project_endpoints": len(project_endpoints),
            "endpoints": endpoints,
            "project_endpoints": project_endpoints,
        }
    except Exception as e:
        logger.error(f"Error listing endpoints: {e}", exc_info=True)
        return {"success": False, "error": str(e)}


async def _get_system_info_impl() -> dict:
    """Internal implementation for getting system info."""
    try:
        import platform

        uptime_seconds = int(time.time() - server_start_time)
        return {
            "success": True,
            "server": {
                "name": "MCP Hub",
                "version": SERVER_VERSION,
                "endpoint_type": "system",
            },
            "uptime": {
                "seconds": uptime_seconds,
                "formatted": f"{uptime_seconds // 3600}h {(uptime_seconds % 3600) // 60}m {uptime_seconds % 60}s",
            },
            "counts": {
                "total_tools": _total_tool_count,
                "total_sites": site_manager.get_count(),
                "sites_by_type": site_manager.get_count_by_type(),
            },
            "environment": {
                "python_version": platform.python_version(),
                "platform": platform.system(),
                "platform_version": platform.release(),
            },
            "oauth": {
                "auth_mode": OAUTH_AUTH_MODE,
                "trusted_domains": (
                    OAUTH_TRUSTED_DOMAINS if OAUTH_AUTH_MODE == "trusted_domains" else []
                ),
            },
        }
    except Exception as e:
        logger.error(f"Error getting system info: {e}", exc_info=True)
        return {"success": False, "error": str(e)}


async def _get_audit_log_impl(
    limit: int = 50, event_type: str = None, project_id: str = None
) -> dict:
    """Internal implementation for getting audit log."""
    try:
        from core.audit_log import EventType as AuditEventType

        limit = min(limit, 500)

        # Convert string event_type to EventType enum if provided
        audit_event_type = None
        if event_type:
            try:
                audit_event_type = AuditEventType(event_type)
            except ValueError:
                pass  # Invalid event type, ignore filter

        entries = audit_logger.get_logs(
            limit=limit, event_type=audit_event_type, project_id=project_id
        )
        return {
            "success": True,
            "total": len(entries),
            "filters": {"limit": limit, "event_type": event_type, "project_id": project_id},
            "entries": entries,
        }
    except Exception as e:
        logger.error(f"Error getting audit log: {e}", exc_info=True)
        return {"success": False, "error": str(e)}


async def _set_rate_limit_config_impl(
    requests_per_minute: int = None, requests_per_hour: int = None, requests_per_day: int = None
) -> dict:
    """Internal implementation for setting rate limit config."""
    try:
        current_config = {
            "requests_per_minute": rate_limiter.limits.get("per_minute", 60),
            "requests_per_hour": rate_limiter.limits.get("per_hour", 1000),
            "requests_per_day": rate_limiter.limits.get("per_day", 10000),
        }
        if requests_per_minute is not None:
            rate_limiter.limits["per_minute"] = requests_per_minute
        if requests_per_hour is not None:
            rate_limiter.limits["per_hour"] = requests_per_hour
        if requests_per_day is not None:
            rate_limiter.limits["per_day"] = requests_per_day
        new_config = {
            "requests_per_minute": rate_limiter.limits.get("per_minute", 60),
            "requests_per_hour": rate_limiter.limits.get("per_hour", 1000),
            "requests_per_day": rate_limiter.limits.get("per_day", 10000),
        }
        return {
            "success": True,
            "message": "Rate limit configuration updated",
            "previous_config": current_config,
            "new_config": new_config,
        }
    except Exception as e:
        logger.error(f"Error setting rate limit config: {e}", exc_info=True)
        return {"success": False, "error": str(e)}


@mcp.tool()
async def get_endpoints() -> dict:
    """List all available MCP endpoints (Phase X.3)."""
    return await _get_endpoints_impl()


@mcp.tool()
async def get_system_info() -> dict:
    """Get comprehensive system information (Phase X.3)."""
    return await _get_system_info_impl()


@mcp.tool()
async def get_audit_log(limit: int = 50, event_type: str = None, project_id: str = None) -> dict:
    """Get audit log entries (Phase X.3)."""
    return await _get_audit_log_impl(limit, event_type, project_id)


@mcp.tool()
async def set_rate_limit_config(
    requests_per_minute: int = None, requests_per_hour: int = None, requests_per_day: int = None
) -> dict:
    """Configure rate limiting settings (Phase X.3)."""
    return await _set_rate_limit_config_impl(
        requests_per_minute, requests_per_hour, requests_per_day
    )


# === HEALTH CHECK ENDPOINT ===

# Track server start time for uptime calculation
server_start_time = time.time()


@mcp.custom_route("/health", methods=["GET"])
async def health_check(request: Request) -> JSONResponse:
    """
    Health check endpoint for container orchestration (Coolify, Docker, Kubernetes).

    Returns JSON with server status, uptime, and project/tool counts.
    This endpoint is used by Docker HEALTHCHECK and container health probes.

    Returns:
        JSONResponse with health status:
        - status: "healthy" if server is running
        - uptime: seconds since server started
        - projects: number of discovered projects
        - tools: total number of registered tools
        - timestamp: current UTC timestamp
    """
    uptime_seconds = int(time.time() - server_start_time)

    return JSONResponse(
        {
            "status": "healthy",
            "uptime": uptime_seconds,
            "sites": site_manager.get_count(),
            "tools": _total_tool_count,  # Total tools (plugin + system)
            "timestamp": datetime.now(UTC).isoformat().replace("+00:00", "Z"),
        }
    )


# === API KEYS MANAGEMENT TOOLS ===


# Internal implementation functions (not decorated)
async def _manage_api_keys_create_impl(
    project_id: str, scope: str = "read", expires_in_days: int = None, description: str = None
) -> dict:
    """Internal implementation for creating API keys."""
    try:
        result = api_key_manager.create_key(
            project_id=project_id,
            scope=scope,
            expires_in_days=expires_in_days,
            description=description,
        )
        return {
            "success": True,
            "message": "API key created successfully",
            "warning": "SAVE THIS KEY - It will not be shown again!",
            "key": result["key"],
            "key_id": result["key_id"],
            "project_id": result["project_id"],
            "scope": result["scope"],
            "expires_at": result.get("expires_at"),
            "created_at": datetime.now().isoformat(),
        }
    except ValueError as e:
        logger.error(f"Invalid scope for API key: {e}")
        return {"success": False, "error": f"Invalid scope: {e}"}
    except Exception as e:
        logger.error(f"Error creating API key: {e}")
        return {"success": False, "error": str(e)}


async def _manage_api_keys_list_impl(project_id: str = None, include_revoked: bool = False) -> dict:
    """Internal implementation for listing API keys."""
    try:
        keys = api_key_manager.list_keys(project_id=project_id, include_revoked=include_revoked)
        return {"success": True, "total": len(keys), "keys": keys}
    except Exception as e:
        logger.error(f"Error listing API keys: {e}")
        return {"success": False, "error": str(e)}


async def _manage_api_keys_get_info_impl(key_id: str) -> dict:
    """Internal implementation for getting API key info."""
    try:
        info = api_key_manager.get_key_info(key_id)
        if info is None:
            return {"success": False, "error": f"Key not found: {key_id}"}
        return {"success": True, "key": info}
    except Exception as e:
        logger.error(f"Error getting key info: {e}")
        return {"success": False, "error": str(e)}


async def _manage_api_keys_revoke_impl(key_id: str) -> dict:
    """Internal implementation for revoking API key."""
    try:
        success = api_key_manager.revoke_key(key_id)
        if not success:
            return {"success": False, "error": f"Key not found or already revoked: {key_id}"}
        return {"success": True, "message": f"API key {key_id} revoked successfully"}
    except Exception as e:
        logger.error(f"Error revoking API key: {e}")
        return {"success": False, "error": str(e)}


async def _manage_api_keys_delete_impl(key_id: str) -> dict:
    """Internal implementation for deleting API key."""
    try:
        success = api_key_manager.delete_key(key_id)
        if not success:
            return {"success": False, "error": f"Key not found: {key_id}"}
        return {"success": True, "message": f"API key {key_id} permanently deleted"}
    except Exception as e:
        logger.error(f"Error deleting API key: {e}")
        return {"success": False, "error": str(e)}


async def _manage_api_keys_rotate_impl(project_id: str) -> dict:
    """Internal implementation for rotating API keys."""
    try:
        new_keys = api_key_manager.rotate_keys(project_id)
        return {
            "success": True,
            "message": f"Rotated {len(new_keys)} keys for project {project_id}",
            "warning": "SAVE THESE NEW KEYS - They will not be shown again!",
            "new_keys": new_keys,
            "rotated_count": len(new_keys),
        }
    except Exception as e:
        logger.error(f"Error rotating API keys: {e}")
        return {"success": False, "error": str(e)}


@mcp.tool()
async def manage_api_keys_create(
    project_id: str, scope: str = "read", expires_in_days: int = None, description: str = None
) -> dict:
    """
    Create a new API key for a project.

    Args:
        project_id: Project ID or "*" for all projects
        scope: Access scope - single ("read", "write", "admin") or multiple space-separated ("read write admin")
              Examples: "read", "write", "admin", "read write", "read write admin"
        expires_in_days: Optional expiration in days (default: never expires)
        description: Optional description for the key

    Returns:
        dict: Key information including the actual key (SAVE IT - won't be shown again!)

    Examples:
        # Single scope
        manage_api_keys_create(project_id="wordpress_site1", scope="read")

        # Multiple scopes (space-separated)
        manage_api_keys_create(project_id="wordpress_site1", scope="read write admin")

        # All scopes for all projects
        manage_api_keys_create(project_id="*", scope="read write admin")
    """
    return await _manage_api_keys_create_impl(project_id, scope, expires_in_days, description)


@mcp.tool()
async def manage_api_keys_list(project_id: str = None, include_revoked: bool = False) -> dict:
    """
    List API keys with optional filtering.

    **Requires**: Global API key (Master API Key or global admin key)

    **Filtering Behavior**:
    - `project_id=null` (default): Returns ALL keys (global + project-specific)
    - `project_id="*"`: Returns ONLY global keys
    - `project_id="wordpress_site1"`: Returns keys for that project + global keys

    Args:
        project_id: Optional filter by project ID (default: null = all keys)
        include_revoked: Include revoked keys (default: False)

    Returns:
        dict: List of API keys with metadata (without actual key values)

    Examples:
        # List all keys
        manage_api_keys_list()

        # List only global keys
        manage_api_keys_list(project_id="*")

        # List keys for specific project
        manage_api_keys_list(project_id="wordpress_site1")

    Note:
        See docs/API_KEYS_USAGE.md for detailed documentation
    """
    return await _manage_api_keys_list_impl(project_id, include_revoked)


@mcp.tool()
async def manage_api_keys_get_info(key_id: str) -> dict:
    """
    Get detailed information about a specific API key.

    Args:
        key_id: Key ID to query

    Returns:
        dict: Key information (without actual key value)
    """
    return await _manage_api_keys_get_info_impl(key_id)


@mcp.tool()
async def manage_api_keys_revoke(key_id: str) -> dict:
    """
    Revoke an API key (soft delete - can be restored).

    Args:
        key_id: Key ID to revoke

    Returns:
        dict: Success status
    """
    return await _manage_api_keys_revoke_impl(key_id)


@mcp.tool()
async def manage_api_keys_delete(key_id: str) -> dict:
    """
    Permanently delete an API key (cannot be undone).

    Args:
        key_id: Key ID to delete

    Returns:
        dict: Success status
    """
    return await _manage_api_keys_delete_impl(key_id)


@mcp.tool()
async def manage_api_keys_rotate(project_id: str) -> dict:
    """
    Rotate all keys for a project.

    Creates new keys with the same scopes and revokes old ones.
    Use this for security rotation or if keys may be compromised.

    Args:
        project_id: Project ID to rotate keys for

    Returns:
        dict: List of new keys (SAVE THEM - won't be shown again!)
    """
    return await _manage_api_keys_rotate_impl(project_id)


# === SERVER STARTUP ===

# Create separate MCP instances for each endpoint


def create_system_mcp():
    """
    Create System-only MCP instance.

    Contains 24 system management tools:
    - API Key Management (6): create, list, get_info, revoke, delete, rotate
    - OAuth Management (4): register_client, list_clients, revoke_client, get_client_info
    - Rate Limiting (3): get_stats, reset, set_config
    - Health & Monitoring (7): check_all, get_project, get_metrics, get_uptime, get_project_metrics, export
    - Status & Discovery (4): list_projects, get_endpoints, get_system_info, get_audit_log
    """
    from fastmcp import FastMCP

    system_instructions = """This is the System Management endpoint (24 tools).

Available tools:
• API Key Management: create, list, get_info, revoke, delete, rotate
• OAuth Management: register_client, list_clients, revoke_client, get_client_info
• Rate Limiting: get_stats, reset, set_config
• Health & Monitoring: check_all_projects_health, get_project_health, get_system_metrics,
  get_system_uptime, get_project_metrics, export_health_metrics, get_project_info
• Status & Discovery: list_projects, get_endpoints, get_system_info, get_audit_log

Use list_projects() to see all configured sites across all plugin types.
Use get_endpoints() to see all available MCP endpoints."""

    system_mcp = FastMCP("System Manager", instructions=system_instructions)

    # Register system tools with proper wrappers
    # Each tool calls the _impl function directly (not the decorated version)

    @system_mcp.tool()
    async def manage_api_keys_create(
        project_id: str, scope: str = "read", expires_in_days: int = None, description: str = None
    ) -> dict:
        """Create a new API key for a project."""
        return await _manage_api_keys_create_impl(project_id, scope, expires_in_days, description)

    @system_mcp.tool()
    async def manage_api_keys_list(project_id: str = None, include_revoked: bool = False) -> dict:
        """List API keys with optional filtering."""
        return await _manage_api_keys_list_impl(project_id, include_revoked)

    @system_mcp.tool()
    async def manage_api_keys_get_info(key_id: str) -> dict:
        """Get detailed information about a specific API key."""
        return await _manage_api_keys_get_info_impl(key_id)

    @system_mcp.tool()
    async def manage_api_keys_revoke(key_id: str) -> dict:
        """Revoke an API key (soft delete)."""
        return await _manage_api_keys_revoke_impl(key_id)

    @system_mcp.tool()
    async def manage_api_keys_delete(key_id: str) -> dict:
        """Permanently delete an API key."""
        return await _manage_api_keys_delete_impl(key_id)

    @system_mcp.tool()
    async def manage_api_keys_rotate(project_id: str) -> dict:
        """Rotate all keys for a project."""
        return await _manage_api_keys_rotate_impl(project_id)

    @system_mcp.tool()
    async def list_projects() -> str:
        """List all discovered projects."""
        return await _list_projects_impl()

    @system_mcp.tool()
    async def get_endpoints() -> dict:
        """List all available MCP endpoints."""
        return await _get_endpoints_impl()

    @system_mcp.tool()
    async def get_system_info() -> dict:
        """Get comprehensive system information."""
        return await _get_system_info_impl()

    @system_mcp.tool()
    async def get_audit_log(
        limit: int = 50, event_type: str = None, project_id: str = None
    ) -> dict:
        """Get audit log entries."""
        return await _get_audit_log_impl(limit, event_type, project_id)

    @system_mcp.tool()
    async def oauth_register_client(
        client_name: str,
        redirect_uris: str,
        grant_types: str = "authorization_code,refresh_token",
        allowed_scopes: str = "read,write",
        metadata: dict = None,
    ) -> dict:
        """Register a new OAuth 2.1 client."""
        return await _oauth_register_client_impl(
            client_name, redirect_uris, grant_types, allowed_scopes, metadata
        )

    @system_mcp.tool()
    async def oauth_list_clients() -> dict:
        """List all registered OAuth clients."""
        return await _oauth_list_clients_impl()

    @system_mcp.tool()
    async def oauth_revoke_client(client_id: str) -> dict:
        """Revoke (delete) an OAuth client."""
        return await _oauth_revoke_client_impl(client_id)

    @system_mcp.tool()
    async def oauth_get_client_info(client_id: str) -> dict:
        """Get detailed information about a specific OAuth client."""
        return await _oauth_get_client_info_impl(client_id)

    @system_mcp.tool()
    async def get_rate_limit_stats(client_id: str = None) -> str:
        """Get rate limiting statistics."""
        return await _get_rate_limit_stats_impl(client_id)

    @system_mcp.tool()
    async def reset_rate_limit(client_id: str = None) -> str:
        """Reset rate limit state for a client or all clients."""
        return await _reset_rate_limit_impl(client_id)

    @system_mcp.tool()
    async def set_rate_limit_config(
        requests_per_minute: int = None, requests_per_hour: int = None, requests_per_day: int = None
    ) -> dict:
        """Configure rate limiting settings."""
        return await _set_rate_limit_config_impl(
            requests_per_minute, requests_per_hour, requests_per_day
        )

    # Health & Monitoring tools (7)
    @system_mcp.tool()
    async def get_project_info(project_id: str) -> str:
        """Get detailed information about a specific project."""
        import json

        try:
            info = None
            for site_data in site_manager.list_all_sites():
                if site_data["full_id"] == project_id:
                    info = site_data
                    break
            if info is None:
                return f"Project '{project_id}' not found. Use list_projects to see available projects."
            return json.dumps(info, indent=2)
        except Exception as e:
            logger.error(f"Error getting project info: {e}", exc_info=True)
            return f"Error: {str(e)}"

    @system_mcp.tool()
    async def check_all_projects_health() -> str:
        """Check health status of all projects with enhanced metrics."""
        try:
            health_data = await health_monitor.check_all_projects_health(include_metrics=True)
            import json

            return json.dumps(health_data, indent=2)
        except Exception as e:
            logger.error(f"Error checking health: {e}", exc_info=True)
            return f"Error: {str(e)}"

    @system_mcp.tool()
    async def get_project_health(project_id: str) -> str:
        """Get detailed health information for a specific project."""
        try:
            status = await health_monitor.check_project_health(project_id, include_metrics=True)
            import json

            return json.dumps(status.to_dict(), indent=2)
        except Exception as e:
            logger.error(f"Error getting project health: {e}", exc_info=True)
            return f"Error: {str(e)}"

    @system_mcp.tool()
    async def get_system_metrics() -> str:
        """Get overall MCP server metrics and statistics."""
        try:
            metrics = health_monitor.get_system_metrics()
            import json

            return json.dumps(metrics.to_dict(), indent=2)
        except Exception as e:
            logger.error(f"Error getting system metrics: {e}", exc_info=True)
            return f"Error: {str(e)}"

    @system_mcp.tool()
    async def get_system_uptime() -> str:
        """Get MCP server uptime information."""
        try:
            uptime = health_monitor.get_uptime()
            import json

            return json.dumps(uptime, indent=2)
        except Exception as e:
            logger.error(f"Error getting uptime: {e}", exc_info=True)
            return f"Error: {str(e)}"

    @system_mcp.tool()
    async def get_project_metrics(project_id: str, hours: int = 1) -> str:
        """Get historical metrics for a specific project."""
        try:
            hours = min(hours, 24)
            metrics = health_monitor.get_project_metrics(project_id, hours=hours)
            import json

            return json.dumps(metrics, indent=2)
        except Exception as e:
            logger.error(f"Error getting project metrics: {e}", exc_info=True)
            return f"Error: {str(e)}"

    @system_mcp.tool()
    async def export_health_metrics(output_path: str = "logs/metrics_export.json") -> str:
        """Export all health metrics to a JSON file."""
        try:
            exported_path = health_monitor.export_metrics(output_path=output_path, format="json")
            return f"Metrics exported successfully to: {exported_path}"
        except Exception as e:
            logger.error(f"Error exporting metrics: {e}", exc_info=True)
            return f"Error: {str(e)}"

    logger.info("Created System endpoint with 24 tools")
    return system_mcp


def create_wordpress_mcp():
    """Create WordPress-only MCP instance"""
    from fastmcp import FastMCP

    wp_instructions = generate_mcp_instructions(plugin_type="wordpress")
    wp_mcp = FastMCP("WordPress Manager", instructions=wp_instructions)

    # Add authentication middleware (fix: endpoints must have auth)
    add_endpoint_middleware(wp_mcp, "WordPress")

    # Get WordPress tools only (excluding advanced)
    count = 0
    for tool_def in tool_registry.get_all():
        tool_name = tool_def.name
        # Exclude wordpress_specialist_ tools (they go to the specialist endpoint).
        if tool_name.startswith("wordpress_specialist_"):
            continue

        # Include wordpress_ tools only (woocommerce_ moved to /woocommerce/mcp in Phase D.1)
        if tool_name.startswith("wordpress_"):
            try:
                wrapped = create_dynamic_tool(
                    tool_def.name, tool_def.description, tool_def.handler, tool_def.input_schema
                )
                wp_mcp.tool()(wrapped)
                count += 1
            except Exception as e:
                logger.error(f"Failed to register {tool_def.name}: {e}")

    # Phase K.2.3: Add list_sites tool for site discovery
    @wp_mcp.tool()
    async def list_sites() -> str:
        """
        List all available WordPress sites.

        Use this tool to discover which sites you can access.
        The returned 'site' values can be used as the site parameter in other tools.

        Returns:
            JSON string with list of available sites
        """
        return await _list_sites_impl("wordpress")

    count += 1
    logger.info(f"Created WordPress endpoint with {count} tools (including list_sites)")
    return wp_mcp


def create_wordpress_specialist_mcp():
    """Create WordPress Specialist MCP instance (F.19.1).

    Companion-backed advanced WordPress management — plugins, themes,
    users, options, cron, maintenance, db inspection, bulk fan-out.
    Exposes only ``wordpress_specialist_*`` tools on its own URL, with
    no Docker-socket dependency. (The legacy ``wordpress_advanced``
    plugin was sunset in F.19.3.2-.3 / 2026-05-04.)
    """
    from fastmcp import FastMCP

    wp_spec_instructions = generate_mcp_instructions(plugin_type="wordpress_specialist")
    wp_spec_mcp = FastMCP("WordPress Specialist", instructions=wp_spec_instructions)

    add_endpoint_middleware(wp_spec_mcp, "WordPress Specialist")

    count = 0
    for tool_def in tool_registry.get_all():
        tool_name = tool_def.name
        if tool_name.startswith("wordpress_specialist_"):
            try:
                wrapped = create_dynamic_tool(
                    tool_def.name, tool_def.description, tool_def.handler, tool_def.input_schema
                )
                wp_spec_mcp.tool()(wrapped)
                count += 1
            except Exception as e:
                logger.error(f"Failed to register {tool_def.name}: {e}")

    @wp_spec_mcp.tool()
    async def list_sites() -> str:
        """List all available WordPress sites for the specialist surface."""
        return await _list_sites_impl("wordpress_specialist")

    count += 1
    logger.info(f"Created WordPress Specialist endpoint with {count} tools (including list_sites)")
    return wp_spec_mcp


def create_woocommerce_mcp():
    """Create WooCommerce-only MCP instance (Phase D.1)"""
    from fastmcp import FastMCP

    woo_instructions = generate_mcp_instructions(plugin_type="woocommerce")
    woo_mcp = FastMCP("WooCommerce Manager", instructions=woo_instructions)

    # Add authentication middleware (fix: endpoints must have auth)
    add_endpoint_middleware(woo_mcp, "WooCommerce")

    # Get WooCommerce tools only
    count = 0
    for tool_def in tool_registry.get_all():
        if tool_def.name.startswith("woocommerce_"):
            try:
                wrapped = create_dynamic_tool(
                    tool_def.name, tool_def.description, tool_def.handler, tool_def.input_schema
                )
                woo_mcp.tool()(wrapped)
                count += 1
            except Exception as e:
                logger.error(f"Failed to register {tool_def.name}: {e}")

    # Phase K.2.3: Add list_sites tool for site discovery
    @woo_mcp.tool()
    async def list_sites() -> str:
        """
        List all available WooCommerce sites.

        Use this tool to discover which sites you can access.
        The returned 'site' values can be used as the site parameter in other tools.

        Returns:
            JSON string with list of available sites
        """
        return await _list_sites_impl("woocommerce")

    count += 1
    logger.info(f"Created WooCommerce endpoint with {count} tools (including list_sites)")
    return woo_mcp


def create_gitea_mcp():
    """Create Gitea-only MCP instance"""
    from fastmcp import FastMCP

    gitea_instructions = generate_mcp_instructions(plugin_type="gitea")
    gitea_mcp = FastMCP("Gitea Manager", instructions=gitea_instructions)

    # Add authentication middleware (fix: endpoints must have auth)
    add_endpoint_middleware(gitea_mcp, "Gitea")

    # Get Gitea tools only
    count = 0
    for tool_def in tool_registry.get_all():
        if tool_def.name.startswith("gitea_"):
            try:
                wrapped = create_dynamic_tool(
                    tool_def.name, tool_def.description, tool_def.handler, tool_def.input_schema
                )
                gitea_mcp.tool()(wrapped)
                count += 1
            except Exception as e:
                logger.error(f"Failed to register {tool_def.name}: {e}")

    # Phase K.2.3: Add list_sites tool for site discovery
    @gitea_mcp.tool()
    async def list_sites() -> str:
        """
        List all available Gitea sites.

        Use this tool to discover which sites you can access.
        The returned 'site' values can be used as the site parameter in other tools.

        Returns:
            JSON string with list of available sites
        """
        return await _list_sites_impl("gitea")

    count += 1
    logger.info(f"Created Gitea endpoint with {count} tools (including list_sites)")
    return gitea_mcp


def create_openpanel_mcp():
    """Create OpenPanel-only MCP instance"""
    from fastmcp import FastMCP

    openpanel_instructions = generate_mcp_instructions(plugin_type="openpanel")
    openpanel_mcp = FastMCP("OpenPanel Analytics", instructions=openpanel_instructions)

    # Add authentication middleware (fix: endpoints must have auth)
    add_endpoint_middleware(openpanel_mcp, "OpenPanel")

    # Get OpenPanel tools only
    count = 0
    for tool_def in tool_registry.get_all():
        if tool_def.name.startswith("openpanel_"):
            try:
                wrapped = create_dynamic_tool(
                    tool_def.name, tool_def.description, tool_def.handler, tool_def.input_schema
                )
                openpanel_mcp.tool()(wrapped)
                count += 1
            except Exception as e:
                logger.error(f"Failed to register {tool_def.name}: {e}")

    # Phase K.2.3: Add list_sites tool for site discovery
    @openpanel_mcp.tool()
    async def list_sites() -> str:
        """
        List all available OpenPanel sites.

        Use this tool to discover which sites you can access.
        The returned 'site' values can be used as the site parameter in other tools.

        Returns:
            JSON string with list of available sites
        """
        return await _list_sites_impl("openpanel")

    count += 1
    logger.info(f"Created OpenPanel endpoint with {count} tools (including list_sites)")
    return openpanel_mcp


def create_n8n_mcp():
    """Create n8n-only MCP instance"""
    from fastmcp import FastMCP

    n8n_instructions = generate_mcp_instructions(plugin_type="n8n")
    n8n_mcp = FastMCP("n8n Automation", instructions=n8n_instructions)

    # Add authentication middleware (fix: endpoints must have auth)
    add_endpoint_middleware(n8n_mcp, "n8n")

    # Get n8n tools only
    count = 0
    for tool_def in tool_registry.get_all():
        if tool_def.name.startswith("n8n_"):
            try:
                wrapped = create_dynamic_tool(
                    tool_def.name, tool_def.description, tool_def.handler, tool_def.input_schema
                )
                n8n_mcp.tool()(wrapped)
                count += 1
            except Exception as e:
                logger.error(f"Failed to register {tool_def.name}: {e}")

    # Phase K.2.3: Add list_sites tool for site discovery
    @n8n_mcp.tool()
    async def list_sites() -> str:
        """
        List all available n8n sites.

        Use this tool to discover which sites you can access.
        The returned 'site' values can be used as the site parameter in other tools.

        Returns:
            JSON string with list of available sites
        """
        return await _list_sites_impl("n8n")

    count += 1
    logger.info(f"Created n8n endpoint with {count} tools (including list_sites)")
    return n8n_mcp


def create_supabase_mcp():
    """Create Supabase-only MCP instance"""
    from fastmcp import FastMCP

    supabase_instructions = generate_mcp_instructions(plugin_type="supabase")
    supabase_mcp = FastMCP("Supabase Manager", instructions=supabase_instructions)

    # Add authentication middleware (fix: endpoints must have auth)
    add_endpoint_middleware(supabase_mcp, "Supabase")

    # Get Supabase tools only
    count = 0
    for tool_def in tool_registry.get_all():
        if tool_def.name.startswith("supabase_"):
            try:
                wrapped = create_dynamic_tool(
                    tool_def.name, tool_def.description, tool_def.handler, tool_def.input_schema
                )
                supabase_mcp.tool()(wrapped)
                count += 1
            except Exception as e:
                logger.error(f"Failed to register {tool_def.name}: {e}")

    # Phase K.2.3: Add list_sites tool for site discovery
    @supabase_mcp.tool()
    async def list_sites() -> str:
        """
        List all available Supabase sites.

        Use this tool to discover which sites you can access.
        The returned 'site' values can be used as the site parameter in other tools.

        Returns:
            JSON string with list of available sites
        """
        return await _list_sites_impl("supabase")

    count += 1
    logger.info(f"Created Supabase endpoint with {count} tools (including list_sites)")
    return supabase_mcp


def create_project_mcp(project_id: str, plugin_type: str, site_id: str, alias: str = None):
    """
    Create MCP instance for a specific project with site-locked tools.

    Args:
        project_id: Full project ID (e.g., "wordpress_site1")
        plugin_type: Plugin type (e.g., "wordpress", "wordpress_specialist", "gitea")
        site_id: Site identifier for tool injection
        alias: Optional friendly alias for display name

    Returns:
        FastMCP instance with tools locked to the specific site
    """
    from functools import wraps

    from fastmcp import FastMCP

    display_name = alias or project_id

    # Generate instructions for site-locked endpoint
    project_instructions = generate_mcp_instructions(site_locked=display_name)
    project_mcp = FastMCP(f"Project: {display_name}", instructions=project_instructions)

    # Add authentication middleware (fix: endpoints must have auth)
    add_endpoint_middleware(project_mcp, f"Project:{display_name}")

    # Determine tool prefix based on plugin type
    if plugin_type == "wordpress_specialist":
        tool_prefix = "wordpress_specialist_"
    elif plugin_type == "wordpress":
        tool_prefix = "wordpress_"
    elif plugin_type == "woocommerce":  # Phase D.1
        tool_prefix = "woocommerce_"
    elif plugin_type == "gitea":
        tool_prefix = "gitea_"
    else:
        tool_prefix = f"{plugin_type}_"

    count = 0
    for tool_def in tool_registry.get_all():
        tool_name = tool_def.name

        # For wordpress plugin type, exclude wordpress_specialist_ and
        # woocommerce_ tools (each has its own endpoint).
        if plugin_type == "wordpress":
            if tool_name.startswith("wordpress_specialist_"):
                continue
            if tool_name.startswith("woocommerce_"):
                continue

        # Check if tool matches this plugin type
        if not tool_name.startswith(tool_prefix):
            continue

        # Create a site-locked wrapper
        original_handler = tool_def.handler

        def make_site_locked_handler(handler, locked_site_id):
            """Create a handler that always injects the locked site_id"""

            @wraps(handler)
            async def site_locked_handler(**kwargs):
                # Always override the site parameter
                kwargs["site"] = locked_site_id
                return await handler(**kwargs)

            return site_locked_handler

        site_locked_handler = make_site_locked_handler(original_handler, site_id)

        # Remove 'site' parameter from schema for per-project endpoints
        # since it's auto-injected by the site_locked_handler
        project_schema = copy.deepcopy(tool_def.input_schema)
        if "properties" in project_schema:
            project_schema["properties"].pop("site", None)
        if "required" in project_schema and "site" in project_schema["required"]:
            project_schema["required"].remove("site")

        try:
            wrapped = create_dynamic_tool(
                tool_def.name, tool_def.description, site_locked_handler, project_schema
            )
            project_mcp.tool()(wrapped)
            count += 1
        except Exception as e:
            logger.error(f"Failed to register {tool_def.name} for project {project_id}: {e}")

    logger.info(f"Created Project endpoint for {project_id} with {count} tools")
    return project_mcp


def create_per_project_endpoints():
    """
    Create MCP endpoints for each discovered site.

    Each site gets its own endpoint at /project/{alias_or_site_id}
    with tools filtered and locked to that specific site.

    Returns:
        List of tuples: (mount_path, mcp_instance, display_name)
    """
    project_endpoints = []

    # Get all discovered sites
    all_sites = site_manager.list_all_sites()

    if not all_sites:
        logger.info("No sites discovered, skipping per-project endpoints")
        return project_endpoints

    logger.info(f"Creating per-project endpoints for {len(all_sites)} sites...")

    for site_info in all_sites:
        plugin_type = site_info["plugin_type"]
        site_id = site_info["site_id"]
        alias = site_info.get("alias")
        full_id = site_info["full_id"]

        # Determine mount path - use alias if different from site_id
        path_suffix = alias if alias and alias != site_id else full_id
        mount_path = f"/project/{path_suffix}"

        try:
            project_mcp = create_project_mcp(
                project_id=full_id, plugin_type=plugin_type, site_id=site_id, alias=alias
            )
            project_endpoints.append((mount_path, project_mcp, full_id))
            logger.info(f"  ✓ {mount_path}/mcp: {full_id} ({plugin_type})")
        except Exception as e:
            logger.error(f"  ✗ Failed to create {mount_path}: {e}")

    logger.info(f"Created {len(project_endpoints)} per-project endpoints")
    return project_endpoints


def create_multi_endpoint_app(transport: str = "streamable-http"):
    """Create Starlette app with multiple MCP endpoints"""
    from contextlib import asynccontextmanager

    from starlette.applications import Starlette
    from starlette.middleware import Middleware as StarletteMiddleware
    from starlette.middleware.base import BaseHTTPMiddleware
    from starlette.responses import Response
    from starlette.routing import Mount, Route

    # ========================================
    # OAuth 401 Middleware for MCP Endpoints
    # ========================================
    # Returns 401 + WWW-Authenticate header for unauthenticated MCP requests
    # This triggers OAuth flow in Claude and other MCP clients
    class OAuthRequiredMiddleware(BaseHTTPMiddleware):
        """
        Middleware to return 401 with WWW-Authenticate header for MCP endpoints.

        This is required by MCP OAuth spec (RFC 9728) to trigger OAuth flow.
        Without this, clients like Claude won't know that OAuth is required
        and will show "Configure" instead of "Connect".
        """

        # Paths that require OAuth (MCP endpoints)
        MCP_PATHS = ["/mcp", "/sse"]

        # Paths that should be excluded from auth check
        EXCLUDED_PATHS = [
            "/.well-known/",
            "/oauth/",
            "/health",
            "/dashboard",
            "/api/dashboard",
            "/static",
        ]

        async def dispatch(self, request: Request, call_next):
            path = request.url.path

            # Check if this is an MCP endpoint that needs OAuth
            is_mcp_endpoint = any(mcp_path in path for mcp_path in self.MCP_PATHS)
            is_excluded = any(path.startswith(excl) for excl in self.EXCLUDED_PATHS)

            if is_mcp_endpoint and not is_excluded:
                # Check for Authorization header
                auth_header = request.headers.get("authorization", "")

                if not auth_header or not auth_header.startswith("Bearer "):
                    # No valid auth header - return 401 with WWW-Authenticate
                    base_url = get_oauth_base_url(request)
                    resource_metadata_url = f"{base_url}/.well-known/oauth-protected-resource"

                    logger.debug(f"MCP OAuth: Returning 401 for {path} (no auth header)")

                    return Response(
                        content='{"error": "unauthorized", "error_description": "Bearer token required"}',
                        status_code=401,
                        media_type="application/json",
                        headers={
                            "WWW-Authenticate": f'Bearer resource_metadata="{resource_metadata_url}"',
                            "Access-Control-Allow-Origin": "*",
                            "Access-Control-Allow-Headers": "Authorization, Content-Type",
                            "Access-Control-Expose-Headers": "WWW-Authenticate",
                        },
                    )

                # Validate the token value (not just presence)
                token = auth_header.removeprefix("Bearer ").strip()
                if token and not self._is_valid_token(token):
                    base_url = get_oauth_base_url(request)
                    resource_metadata_url = f"{base_url}/.well-known/oauth-protected-resource"
                    logger.warning(f"MCP OAuth: Invalid token for {path}")

                    return Response(
                        content='{"error": "invalid_token", "error_description": "Bearer token is invalid"}',
                        status_code=401,
                        media_type="application/json",
                        headers={
                            "WWW-Authenticate": f'Bearer resource_metadata="{resource_metadata_url}", error="invalid_token"',
                            "Access-Control-Allow-Origin": "*",
                            "Access-Control-Allow-Headers": "Authorization, Content-Type",
                            "Access-Control-Expose-Headers": "WWW-Authenticate",
                        },
                    )

            return await call_next(request)

        @staticmethod
        def _is_valid_token(token: str) -> bool:
            """Check if a Bearer token is recognized by any auth backend."""
            # 1. Per-project API key (cheap prefix check)
            if token.startswith("cmp_"):
                key_hash = api_key_manager._hash_key(token)
                for key in api_key_manager.keys.values():
                    if key.key_hash == key_hash and key.is_valid():
                        return True
                return False

            # 2. User API key (prefix-matched, full validation in endpoint)
            if token.startswith("mhu_"):
                return True

            # 3. OAuth JWT token
            try:
                from core.oauth import get_token_manager

                token_manager = get_token_manager()
                if token_manager.validate_access_token(token):
                    return True
            except Exception:
                pass

            # 4. Master API key (fallback — no distinguishing prefix)
            return bool(auth_manager.validate_master_key(token))

    class DashboardCSRFMiddleware(BaseHTTPMiddleware):
        """
        Double-Submit Cookie CSRF protection for Dashboard APIs.
        Generates a stateless CSRF cookie and validates against the X-CSRF-Token header.
        """

        async def dispatch(self, request: Request, call_next):
            import os
            import secrets

            from starlette.responses import JSONResponse

            csrf_cookie = request.cookies.get("dashboard_csrf")
            is_new_cookie = False
            if not csrf_cookie:
                csrf_cookie = secrets.token_urlsafe(32)
                is_new_cookie = True

            request.state.csrf_token = csrf_cookie

            if request.method in ["POST", "PUT", "DELETE", "PATCH"]:
                path = request.url.path

                # Protect dashboard mutating endpoints
                is_protected = (
                    path.startswith("/api/dashboard/")
                    or path.startswith("/api/sites")
                    or path.startswith("/api/keys")
                    or path == "/dashboard-legacy/login"
                )

                # API Requests using Bearer tokens are exempt from CSRF
                auth_header = request.headers.get("authorization", "")
                is_bearer = auth_header.startswith("Bearer ")

                if is_protected and not is_bearer:
                    provided_token = request.headers.get("x-csrf-token")

                    # For form submissions — use raw body parsing to avoid
                    # consuming the body stream (BaseHTTPMiddleware bug:
                    # request.form() in middleware makes body empty for route handler)
                    if not provided_token and request.headers.get("content-type", "").startswith(
                        "application/x-www-form-urlencoded"
                    ):
                        try:
                            from urllib.parse import parse_qs

                            body = await request.body()
                            params = parse_qs(body.decode("utf-8", errors="replace"))
                            csrf_values = params.get("csrf_token", [])
                            if csrf_values:
                                provided_token = csrf_values[0]
                        except Exception:
                            pass

                    if not provided_token or provided_token != csrf_cookie:
                        logger.warning(f"CSRF validation failed for {path}")
                        return JSONResponse(
                            {"error": "CSRF validation failed. Please refresh the page."},
                            status_code=403,
                        )

            response = await call_next(request)

            if is_new_cookie:
                response.set_cookie(
                    "dashboard_csrf",
                    csrf_cookie,
                    max_age=86400,
                    httponly=True,
                    samesite="lax",
                    secure=os.environ.get("DASHBOARD_SECURE_COOKIE", "true").lower() == "true",
                )

            return response

    # Create MCP instances
    system_mcp = create_system_mcp()  # Phase X.3 - System endpoint
    wp_mcp = create_wordpress_mcp()
    woo_mcp = create_woocommerce_mcp()  # Phase D.1
    wp_spec_mcp = create_wordpress_specialist_mcp()  # F.19.1
    gitea_mcp = create_gitea_mcp()
    n8n_mcp = create_n8n_mcp()  # Phase F
    supabase_mcp = create_supabase_mcp()  # Phase G
    openpanel_mcp = create_openpanel_mcp()  # Phase H
    # Create per-project endpoints
    project_endpoints = create_per_project_endpoints()

    # Get the appropriate app for each transport
    # Use http_app() instead of deprecated streamable_http_app()
    try:
        # Try new API first
        main_app = mcp.http_app()
        system_app = system_mcp.http_app()  # Phase X.3
        wp_app = wp_mcp.http_app()
        woo_app = woo_mcp.http_app()  # Phase D.1
        wp_spec_app = wp_spec_mcp.http_app()  # F.19.1
        gitea_app = gitea_mcp.http_app()
        n8n_app = n8n_mcp.http_app()  # Phase F
        supabase_app = supabase_mcp.http_app()  # Phase G
        openpanel_app = openpanel_mcp.http_app()  # Phase H

        # Get apps for project endpoints
        project_apps = []
        for mount_path, project_mcp, project_id in project_endpoints:
            project_app = project_mcp.http_app()
            project_apps.append((mount_path, project_app, project_id))
    except AttributeError:
        # Fallback to old API
        if transport == "sse":
            main_app = mcp.sse_app()
            system_app = system_mcp.sse_app()  # Phase X.3
            wp_app = wp_mcp.sse_app()
            woo_app = woo_mcp.sse_app()  # Phase D.1
            wp_spec_app = wp_spec_mcp.sse_app()  # F.19.1
            gitea_app = gitea_mcp.sse_app()
            n8n_app = n8n_mcp.sse_app()  # Phase F
            supabase_app = supabase_mcp.sse_app()  # Phase G
            openpanel_app = openpanel_mcp.sse_app()  # Phase H

            project_apps = []
            for mount_path, project_mcp, project_id in project_endpoints:
                project_app = project_mcp.sse_app()
                project_apps.append((mount_path, project_app, project_id))
        else:
            main_app = mcp.streamable_http_app()
            system_app = system_mcp.streamable_http_app()  # Phase X.3
            wp_app = wp_mcp.streamable_http_app()
            woo_app = woo_mcp.streamable_http_app()  # Phase D.1
            wp_spec_app = wp_spec_mcp.streamable_http_app()  # F.19.1
            gitea_app = gitea_mcp.streamable_http_app()
            n8n_app = n8n_mcp.streamable_http_app()  # Phase F
            supabase_app = supabase_mcp.streamable_http_app()  # Phase G
            openpanel_app = openpanel_mcp.streamable_http_app()  # Phase H

            project_apps = []
            for mount_path, project_mcp, project_id in project_endpoints:
                project_app = project_mcp.streamable_http_app()
                project_apps.append((mount_path, project_app, project_id))

    # Store all sub-apps for lifespan management
    sub_apps = [
        ("main", main_app),
        ("system", system_app),  # Phase X.3
        ("wordpress", wp_app),
        ("woocommerce", woo_app),  # Phase D.1
        ("wordpress-specialist", wp_spec_app),  # F.19.1
        ("gitea", gitea_app),
        ("n8n", n8n_app),  # Phase F
        ("supabase", supabase_app),  # Phase G
        ("openpanel", openpanel_app),  # Phase H
    ]

    # Add project apps to sub_apps for lifespan management
    for _mount_path, project_app, project_id in project_apps:
        sub_apps.append((f"project:{project_id}", project_app))

    # Combined lifespan for all FastMCP http apps
    # This is REQUIRED for FastMCP's StreamableHTTPSessionManager task group
    @asynccontextmanager
    async def combined_lifespan(app):
        """Combine lifespans from all FastMCP http apps and initialize database"""

        # Initialize database first
        try:
            await initialize_database()
            logger.info("Database initialized successfully")
            # Load managed settings cache (4C.3)
            try:
                from core.settings import refresh_plugin_cache

                await refresh_plugin_cache()
            except Exception as e:
                logger.debug("Settings cache init skipped: %s", e)
        except Exception as e:
            logger.error(f"Failed to initialize database: {e}")
            raise

        active_contexts = []

        # Enter each sub-app's lifespan context
        for name, sub_app in sub_apps:
            if hasattr(sub_app, "lifespan_handler") and sub_app.lifespan_handler:
                try:
                    ctx = sub_app.lifespan_handler(sub_app)
                    await ctx.__aenter__()
                    active_contexts.append((name, ctx))
                    logger.info(f"Started lifespan for {name} endpoint")
                except Exception as e:
                    logger.warning(f"Failed to start lifespan for {name}: {e}")
            elif hasattr(sub_app, "router") and hasattr(sub_app.router, "lifespan_context"):
                # Starlette app with router
                try:
                    ctx = sub_app.router.lifespan_context(sub_app)
                    await ctx.__aenter__()
                    active_contexts.append((name, ctx))
                    logger.info(f"Started router lifespan for {name} endpoint")
                except Exception as e:
                    logger.warning(f"Failed to start router lifespan for {name}: {e}")

        # Start health monitor background checks
        from core.health import get_health_monitor

        hm = get_health_monitor()
        if hm:
            await hm.start_background_checks(interval_seconds=60)

        # F.5a.5: reap expired chunked-upload sessions
        from core.upload_sessions import CleanupTask as _UploadCleanupTask

        upload_cleanup = _UploadCleanupTask()
        await upload_cleanup.start()
        logger.info("Upload-session cleanup task started")

        try:
            yield
        finally:
            await upload_cleanup.stop()
            # Stop health monitor background checks
            if hm:
                await hm.stop_background_checks()

            # Exit all contexts in reverse order
            for name, ctx in reversed(active_contexts):
                try:
                    await ctx.__aexit__(None, None, None)
                    logger.debug(f"Stopped lifespan for {name}")
                except Exception as e:
                    logger.warning(f"Error during lifespan cleanup for {name}: {e}")

            # Close database connection
            try:
                db = get_database()
                await db.close()
                logger.info("Database connection closed")
            except Exception as e:
                logger.warning(f"Error closing database: {e}")

    # Public home page: serve the SPA shell at "/" so React can render the
    # landing page without requiring a dashboard session.
    async def root_redirect(request):
        from core.dashboard.spa_routes import serve_spa

        return await serve_spa(request)

    async def public_dashboard_redirect(request):
        target = f"/dashboard{request.url.path}"
        if request.url.query:
            target += f"?{request.url.query}"
        return RedirectResponse(url=target, status_code=308)

    # Build routes
    # Note: Order matters! More specific routes first
    from core.companion_audit import handle_companion_audit_request

    routes = [
        # Health check
        Route("/health", health_check, methods=["GET"]),
        # F.18.7: Companion plugin audit webhook receiver.
        Route(
            "/api/companion-audit",
            handle_companion_audit_request,
            methods=["POST"],
        ),
        # Root redirect
        Route("/", root_redirect, methods=["GET"]),
        Route("/landing", root_redirect, methods=["GET"]),
        Route("/login", public_dashboard_redirect, methods=["GET"]),
        Route("/onboarding", public_dashboard_redirect, methods=["GET"]),
        Route("/overview", public_dashboard_redirect, methods=["GET"]),
        Route("/sites", public_dashboard_redirect, methods=["GET"]),
        Route("/connect", public_dashboard_redirect, methods=["GET"]),
        Route("/api-keys", public_dashboard_redirect, methods=["GET"]),
        Route("/settings", public_dashboard_redirect, methods=["GET"]),
        # Auth routes (E.2: OAuth Social Login)
        Route("/auth/login", auth_login_page, methods=["GET"]),
        Route("/auth/callback/{provider}", auth_callback, methods=["GET"]),
        Route("/auth/logout", auth_logout, methods=["GET", "POST"]),
        Route("/auth/{provider}", auth_provider_redirect, methods=["GET"]),
        # Dashboard routes
        # G.12 cutover: the SPA now owns /dashboard/*. Every Jinja UI page has
        # been moved to /dashboard-legacy/* so the two coexist without
        # shadowing each other. The /api/dashboard/* JSON endpoints are
        # unchanged — they're consumed by both the SPA and the legacy pages.
        Route("/dashboard-legacy/login", dashboard_login_page, methods=["GET"]),
        Route("/dashboard-legacy/login", dashboard_login_submit, methods=["POST"]),
        # JSON login for SPA fetch() — same auth as the form variant.
        Route("/api/dashboard/login", dashboard_api_login, methods=["POST"]),
        # Logout is an action endpoint — keep the canonical /dashboard/logout
        # URL working (clears cookie + 303) so existing bookmarks/curl don't
        # break. Registered before the SPA catch-all (added near the bottom)
        # so Starlette matches the action route first.
        Route("/dashboard/logout", dashboard_logout, methods=["GET", "POST"]),
        Route("/dashboard-legacy/logout", dashboard_logout, methods=["GET", "POST"]),
        Route("/dashboard-legacy/profile", dashboard_profile_page, methods=["GET"]),
        Route("/dashboard-legacy/sites/add", dashboard_sites_add, methods=["GET"]),
        Route("/dashboard-legacy/sites/{id}/edit", dashboard_sites_edit, methods=["GET"]),
        Route("/dashboard-legacy/sites/{id}", dashboard_sites_view, methods=["GET"]),
        Route("/dashboard-legacy/sites", dashboard_sites_list, methods=["GET"]),
        Route(
            "/dashboard-legacy/connect/oauth-clients",
            dashboard_user_oauth_clients_list,
            methods=["GET"],
        ),
        Route(
            "/dashboard-legacy/connect",
            lambda r: RedirectResponse("/dashboard-legacy/keys", status_code=301),
            methods=["GET"],
        ),
        Route("/dashboard-legacy/services", dashboard_services_list, methods=["GET"]),
        Route(
            "/dashboard-legacy/services/{plugin_type}",
            dashboard_service_page,
            methods=["GET"],
        ),
        Route("/dashboard-legacy", dashboard_home, methods=["GET"]),
        Route("/dashboard-legacy/", dashboard_home, methods=["GET"]),
        Route("/api/dashboard/stats", dashboard_api_stats, methods=["GET"]),
        # Dashboard Projects routes (Phase K.2)
        Route("/dashboard-legacy/projects", dashboard_projects_list, methods=["GET"]),
        Route(
            "/dashboard-legacy/projects/{project_id:path}",
            dashboard_project_detail,
            methods=["GET"],
        ),
        Route("/api/dashboard/projects", dashboard_api_projects, methods=["GET"]),
        # Note: health-check route must come BEFORE generic project_id route
        Route(
            "/api/dashboard/projects/{project_id:path}/health-check",
            dashboard_project_health_check,
            methods=["POST"],
        ),
        Route(
            "/api/dashboard/projects/{project_id:path}",
            dashboard_api_project_detail,
            methods=["GET"],
        ),
        # Dashboard API Keys routes (Phase K.3 + F.7b session 2: unified /dashboard/keys)
        Route("/dashboard-legacy/keys", dashboard_keys_unified, methods=["GET"]),
        Route(
            "/dashboard-legacy/api-keys",
            lambda r: RedirectResponse("/dashboard-legacy/keys", status_code=301),
            methods=["GET"],
        ),
        Route("/api/dashboard/api-keys", dashboard_api_keys_list_json, methods=["GET"]),
        Route("/api/dashboard/api-keys/create", dashboard_api_keys_create, methods=["POST"]),
        Route(
            "/api/dashboard/api-keys/{key_id}/revoke", dashboard_api_keys_revoke, methods=["POST"]
        ),
        Route("/api/dashboard/api-keys/{key_id}", dashboard_api_keys_delete, methods=["DELETE"]),
        # Dashboard OAuth Clients routes (Phase K.4)
        Route("/dashboard-legacy/oauth-clients", dashboard_oauth_clients_list, methods=["GET"]),
        Route("/api/dashboard/oauth-clients", dashboard_oauth_clients_list_json, methods=["GET"]),
        Route(
            "/api/dashboard/oauth-clients/create", dashboard_oauth_clients_create, methods=["POST"]
        ),
        Route(
            "/api/dashboard/oauth-clients/{client_id}",
            dashboard_oauth_clients_delete,
            methods=["DELETE"],
        ),
        # Bug C: User OAuth client API routes
        Route(
            "/api/dashboard/user-oauth-clients/create",
            dashboard_user_oauth_clients_create,
            methods=["POST"],
        ),
        Route(
            "/api/dashboard/user-oauth-clients/{client_id}",
            dashboard_user_oauth_clients_delete,
            methods=["DELETE"],
        ),
        # Dashboard Audit Logs routes (Phase K.4)
        Route("/dashboard-legacy/audit-logs", dashboard_audit_logs_list, methods=["GET"]),
        Route("/api/dashboard/audit-logs", dashboard_api_audit_logs, methods=["GET"]),
        # Dashboard Health Monitoring routes (Phase K.5)
        Route("/dashboard-legacy/health", dashboard_health_page, methods=["GET"]),
        Route("/api/dashboard/health", dashboard_api_health, methods=["GET"]),
        Route("/api/dashboard/health/projects", dashboard_health_projects_partial, methods=["GET"]),
        # Dashboard Settings routes (Phase K.5 + 4C.3)
        Route("/dashboard-legacy/settings", dashboard_settings_page, methods=["GET"]),
        Route("/api/dashboard/settings", api_get_settings, methods=["GET"]),
        Route("/api/dashboard/settings", api_save_setting, methods=["POST"]),
        Route("/api/dashboard/settings/reset", api_reset_settings, methods=["POST"]),
        # Site Management API (E.3)
        Route("/api/sites", api_list_sites, methods=["GET"]),
        Route("/api/sites", api_create_site, methods=["POST"]),
        Route("/api/sites/{id}/test", api_test_site, methods=["POST"]),
        Route("/api/sites/{id}", api_delete_site, methods=["DELETE"]),
        Route("/api/sites/{id}", api_update_site, methods=["PATCH"]),
        # F.5a.9.x: per-site AI provider keys
        Route(
            "/api/sites/{id}/provider-keys",
            api_site_provider_keys_list,
            methods=["GET"],
        ),
        # F.7e: credential capability probe
        Route(
            "/api/sites/{id}/capabilities",
            api_site_capabilities,
            methods=["GET"],
        ),
        # F.X.fix #9: HTMX partial for the capability badge
        Route(
            "/api/sites/{id}/capabilities/badge",
            api_site_capabilities_badge,
            methods=["GET"],
        ),
        # F.X.fix #12: OpenRouter image-model discovery
        Route(
            "/api/providers/openrouter/models",
            api_openrouter_models,
            methods=["GET"],
        ),
        Route(
            "/api/sites/{id}/provider-keys/{provider}",
            api_site_provider_keys_set,
            methods=["POST"],
        ),
        Route(
            "/api/sites/{id}/provider-keys/{provider}",
            api_site_provider_keys_delete,
            methods=["DELETE"],
        ),
        # F.X.fix-pass3: per-site default image model
        Route(
            "/api/sites/{id}/provider-keys/{provider}/default-model",
            api_site_provider_default_model,
            methods=["PATCH"],
        ),
        # User API Key routes (E.3)
        Route("/api/keys", api_list_keys, methods=["GET"]),
        Route("/api/keys", api_create_key, methods=["POST"]),
        Route("/api/keys/{id}", api_delete_key, methods=["DELETE"]),
        # Config snippet API (E.3)
        Route("/api/config/{alias}", api_get_config, methods=["GET"]),
        # F.7b: Per-site tool visibility management
        Route("/api/sites/{site_id}/tools", api_list_site_tools, methods=["GET"]),
        Route(
            "/api/sites/{site_id}/tools/bulk-toggle",
            api_bulk_toggle_site_tools,
            methods=["POST"],
        ),
        Route(
            "/api/sites/{site_id}/tools/{tool_name}",
            api_patch_site_tool,
            methods=["PATCH"],
        ),
        Route(
            "/api/sites/{site_id}/tool-scope",
            api_set_site_tool_scope,
            methods=["PATCH"],
        ),
        Route("/api/scope-presets", api_scope_presets, methods=["GET"]),
        # OAuth endpoints
        Route("/.well-known/oauth-authorization-server", oauth_metadata, methods=["GET"]),
        # Path-specific OAuth protected resource metadata (must come before root)
        # Claude first tries /.well-known/oauth-protected-resource/{path} per RFC 9728
        Route(
            "/.well-known/oauth-protected-resource/{path:path}",
            oauth_protected_resource_path,
            methods=["GET"],
        ),
        Route("/.well-known/oauth-protected-resource", oauth_protected_resource, methods=["GET"]),
        Route("/oauth/register", oauth_register, methods=["POST"]),
        Route("/oauth/authorize", oauth_authorize, methods=["GET"]),
        Route("/oauth/authorize/confirm", oauth_authorize_confirm, methods=["POST"]),
        Route("/oauth/token", oauth_token, methods=["POST"]),
        Route("/oauth/revoke", oauth_revoke, methods=["POST"]),
        # Multi-Endpoint MCP (Phase X + D.1 + X.3 + F + G + H + I + J)
        # Mount each FastMCP app - they handle their own /mcp path internally
        Mount("/system", app=system_app, name="mcp_system"),  # Phase X.3
        Mount("/wordpress-specialist", app=wp_spec_app, name="mcp_wordpress_specialist"),  # F.19.1
        Mount("/woocommerce", app=woo_app, name="mcp_woocommerce"),  # Phase D.1
        Mount("/wordpress", app=wp_app, name="mcp_wordpress"),
        Mount("/gitea", app=gitea_app, name="mcp_gitea"),
        Mount("/n8n", app=n8n_app, name="mcp_n8n"),  # Phase F
        Mount("/supabase", app=supabase_app, name="mcp_supabase"),  # Phase G
        Mount("/openpanel", app=openpanel_app, name="mcp_openpanel"),  # Phase H
    ]

    # Add per-project routes (before main admin endpoint)
    for mount_path, project_app, project_id in project_apps:
        safe_name = project_id.replace("-", "_").replace(".", "_")
        routes.append(Mount(mount_path, app=project_app, name=f"mcp_project_{safe_name}"))

    # Per-user MCP endpoints (E.3: Site Management)
    try:
        from starlette.routing import Route

        from core.user_endpoints import user_mcp_handler

        routes.append(
            Route(
                "/u/{user_id}/{alias}/mcp",
                endpoint=user_mcp_handler,
                methods=["POST"],
                name="user_mcp_endpoint",
            )
        )
        logger.info("Per-user MCP endpoint registered at /u/{user_id}/{alias}/mcp")
    except Exception as e:
        logger.warning("Per-user MCP endpoint not registered: %s", e)

    # Static files (favicon, logo, SPA dist)
    _static_dir = os.path.join(os.path.dirname(__file__), "core", "templates", "static")
    if os.path.isdir(_static_dir):
        from starlette.staticfiles import StaticFiles

        routes.append(Mount("/static", app=StaticFiles(directory=_static_dir), name="static"))

    # Track G — SPA /dashboard support routes (must be registered BEFORE the catch-all)
    try:
        from core.dashboard.spa_routes import (
            api_i18n,
            api_me,
            api_plugins,
            redirect_dashboard_v2,
            serve_spa,
        )

        routes.extend(
            [
                Route("/api/me", api_me, methods=["GET"]),
                Route("/api/i18n/{lang}", api_i18n, methods=["GET"]),
                Route("/api/plugins", api_plugins, methods=["GET"]),
                Route("/dashboard", serve_spa, methods=["GET"]),
                Route("/dashboard/", serve_spa, methods=["GET"]),
                Route("/dashboard/{path:path}", serve_spa, methods=["GET"]),
                Route("/dashboard-v2", redirect_dashboard_v2, methods=["GET"]),
                Route("/dashboard-v2/", redirect_dashboard_v2, methods=["GET"]),
                Route("/dashboard-v2/{path:path}", redirect_dashboard_v2, methods=["GET"]),
            ]
        )
        logger.info(
            "Track G SPA routes registered "
            "(/api/me, /api/i18n, /api/plugins, /dashboard/*, /dashboard-v2 -> 308)"
        )
    except Exception as e:  # pragma: no cover — defensive
        logger.warning("Track G SPA routes not registered: %s", e)

    # Catch-all for unmatched GET requests — serve the same React shell as the
    # public landing page so the SPA NotFound route owns the visual treatment.
    async def catch_all_404(request):
        from core.dashboard.spa_routes import serve_spa

        return await serve_spa(request, status_code=404)

    routes.append(Route("/{path:path}", catch_all_404, methods=["GET"]))

    # Main admin endpoint (must be last - catches all remaining non-GET routes)
    routes.append(Mount("/", app=main_app, name="mcp_admin"))

    # Add middlewares
    middleware = [
        StarletteMiddleware(OAuthRequiredMiddleware),
        StarletteMiddleware(DashboardCSRFMiddleware),
    ]

    app = Starlette(
        routes=routes,
        lifespan=combined_lifespan,
        middleware=middleware,
    )

    logger.info("=" * 60)
    logger.info("Multi-Endpoint Architecture Active")
    logger.info("=" * 60)
    logger.info("Dashboard: /dashboard")
    logger.info("Endpoints:")
    logger.info(f"  /mcp                     - Admin (all {_total_tool_count} tools)")
    logger.info("  /system/mcp              - System (24 tools)")
    logger.info("  /wordpress/mcp           - WordPress Core (67 tools)")
    logger.info("  /woocommerce/mcp         - WooCommerce (28 tools)")
    logger.info("  /wordpress-specialist/mcp - WordPress Specialist (51 tools, companion-backed)")
    logger.info("  /gitea/mcp               - Gitea (56 tools)")
    logger.info("  /n8n/mcp                 - n8n Automation (56 tools)")
    logger.info("  /supabase/mcp            - Supabase (70 tools)")
    logger.info("  /openpanel/mcp           - OpenPanel Analytics (73 tools)")

    # Log per-project endpoints
    if project_apps:
        logger.info("Per-Project Endpoints:")
        for mount_path, _, project_id in project_apps:
            logger.info(f"  {mount_path}/mcp          - {project_id}")

    logger.info("=" * 60)

    # Debug: Log sub-app routes
    for name, sub_app in sub_apps:
        logger.info(f"Sub-app '{name}' type: {type(sub_app)}")
        if hasattr(sub_app, "routes"):
            for route in sub_app.routes:
                logger.info(f"  Route: {route}")
        elif hasattr(sub_app, "router") and hasattr(sub_app.router, "routes"):
            for route in sub_app.router.routes:
                logger.info(f"  Router route: {route}")

    return app


def main():
    """Main entry point for the MCP server."""
    import argparse

    import uvicorn

    # Parse command line arguments for transport configuration
    parser = argparse.ArgumentParser(description="MCP Hub Server")
    parser.add_argument(
        "--transport",
        type=str,
        default="stdio",
        choices=["stdio", "sse", "streamable-http"],
        help="Transport protocol to use",
    )
    parser.add_argument(
        "--host",
        type=str,
        default="0.0.0.0",
        help="Host to bind to (0.0.0.0 required for containers)",
    )
    parser.add_argument("--port", type=int, default=8000, help="Port to listen on")
    parser.add_argument(
        "--multi-endpoint", action="store_true", help="Enable multi-endpoint architecture (Phase X)"
    )

    args = parser.parse_args()

    logger.info("Starting MCP server...")
    logger.info(f"Transport: {args.transport}")
    if args.transport != "stdio":
        logger.info(f"Host: {args.host}")
        logger.info(f"Port: {args.port}")

    try:
        if args.transport == "stdio":
            # stdio doesn't support multi-endpoint
            mcp.run(transport="stdio")
        elif args.multi_endpoint or os.getenv("MULTI_ENDPOINT", "true").lower() == "true":
            # Multi-endpoint mode (default for HTTP transports)
            app = create_multi_endpoint_app(args.transport)
            uvicorn.run(app, host=args.host, port=args.port, log_level="info")
        else:
            # Legacy single endpoint mode
            mcp.run(transport=args.transport, host=args.host, port=args.port)
    except KeyboardInterrupt:
        logger.info("Server stopped by user")
    except Exception as e:
        logger.error(f"Server error: {e}", exc_info=True)
        sys.exit(1)


if __name__ == "__main__":
    main()
