Files
mcphub/core/endpoints/factory.py
airano-ir 43fd2201a0 feat(v3.13.0): settings live DB reads, remove Directus/Appwrite, admin stats
Settings fixes:
- MAX_SITES_PER_USER, USER_RATE_LIMIT_PER_MIN/HR now read from DB settings
  table (DB > ENV > default), so dashboard/settings changes apply without
  restart. Sync cache refreshed on every save or delete.
- /api/me reports the live DB value for max_sites_per_user.

Admin improvements:
- Admin users bypass per-user rate limiting entirely (role=admin or ADMIN_EMAILS).
- Admin Overview now shows platform stats: registered users, new users (7d),
  total user sites, available tools.

Plugin cleanup:
- Appwrite and Directus plugins removed from the active registry (8 plugins
  now: WordPress, WooCommerce, WordPress Specialist, Gitea, n8n, Supabase,
  OpenPanel, Coolify). Plugin code is retained for future re-enabling.
- Settings page plugin visibility list updated to match.

Mobile onboarding:
- Stepper steps on narrow viewports stack vertically with correct full border
  and rounded corners on each step.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-20 23:33:20 +02:00

300 lines
9.0 KiB
Python

"""
MCP Endpoint Factory
Creates and configures FastMCP instances for different endpoints.
Each endpoint has its own set of tools based on configuration.
"""
import logging
from collections.abc import Callable
from functools import wraps
from typing import TYPE_CHECKING, Any
from fastmcp import FastMCP
from fastmcp.server.middleware import Middleware
from .config import EndpointConfig
if TYPE_CHECKING:
from core.site_manager import SiteManager
from core.tool_registry import ToolRegistry
logger = logging.getLogger(__name__)
class MCPEndpointFactory:
"""
Factory for creating scoped MCP endpoints.
Each endpoint is a separate FastMCP instance with only
the tools relevant to its configuration.
"""
def __init__(
self,
site_manager: "SiteManager",
tool_registry: "ToolRegistry",
middleware_classes: list[type] | None = None,
):
"""
Initialize the endpoint factory.
Args:
site_manager: Site manager for accessing site configurations
tool_registry: Central tool registry
middleware_classes: List of middleware classes to apply to endpoints
"""
self.site_manager = site_manager
self.tool_registry = tool_registry
self.middleware_classes = middleware_classes or []
self.endpoints: dict[str, FastMCP] = {}
self._tool_counts: dict[str, int] = {}
self._tool_handlers: dict[str, Callable] = {}
def register_tool_handler(self, tool_name: str, handler: Callable):
"""
Register a tool handler function.
Args:
tool_name: Name of the tool
handler: Async handler function for the tool
"""
self._tool_handlers[tool_name] = handler
def create_endpoint(
self, config: EndpointConfig, custom_middleware: list[Middleware] | None = None
) -> FastMCP:
"""
Create a new MCP endpoint with scoped tools.
Args:
config: Endpoint configuration
custom_middleware: Additional middleware for this endpoint
Returns:
Configured FastMCP instance
"""
logger.info(f"Creating endpoint: {config.path} ({config.name})")
# Create FastMCP instance
mcp = FastMCP(config.name)
# Get tools for this endpoint
tools = self._get_tools_for_endpoint(config)
logger.info(f" - Registering {len(tools)} tools for {config.path}")
# Register tools
for tool_info in tools:
self._register_tool(mcp, tool_info, config)
# Apply middleware
if custom_middleware:
for middleware in custom_middleware:
mcp.add_middleware(middleware)
# Store endpoint and tool count
self.endpoints[config.path] = mcp
self._tool_counts[config.path] = len(tools)
logger.info(f" - Endpoint {config.path} created successfully")
return mcp
def _get_tools_for_endpoint(self, config: EndpointConfig) -> list[dict[str, Any]]:
"""
Get list of tools that should be available on this endpoint.
Args:
config: Endpoint configuration
Returns:
List of tool definitions
"""
tools = []
# Get all tools from registry
all_tools = self.tool_registry.get_all()
for tool_def in all_tools:
tool_name = tool_def.name
# Check plugin type filter
plugin_type = self._extract_plugin_type(tool_name)
if plugin_type and not config.allows_plugin(plugin_type):
continue
# Check tool whitelist/blacklist
if not config.allows_tool(tool_name):
continue
# For project endpoints, filter by site
if config.site_filter and plugin_type:
# Tool should work with the specific site
pass # Site filtering happens at execution time
tools.append(
{
"name": tool_name,
"description": tool_def.description,
"parameters": tool_def.parameters,
"handler": tool_def.handler,
"plugin_type": plugin_type,
}
)
# Check max tools limit
if len(tools) > config.max_tools:
logger.warning(
f"Endpoint {config.path} has {len(tools)} tools, "
f"exceeding max_tools={config.max_tools}"
)
return tools
def _extract_plugin_type(self, tool_name: str) -> str | None:
"""
Extract plugin type from tool name.
Args:
tool_name: Name of the tool
Returns:
Plugin type or None for system tools
"""
# Check the multi-word WordPress variant BEFORE the bare
# ``wordpress_`` prefix — tool names are
# ``wordpress_specialist_wp_plugin_list``,
# ``wordpress_create_post`` etc. (``wordpress_advanced`` was
# sunset in F.19.3.2-.3 / 2026-05-04.)
if tool_name.startswith("wordpress_specialist_"):
return "wordpress_specialist"
if tool_name.startswith("wordpress_"):
return "wordpress"
elif tool_name.startswith("woocommerce_"):
return "woocommerce"
elif tool_name.startswith("gitea_"):
return "gitea"
elif tool_name.startswith("n8n_"):
return "n8n"
elif tool_name.startswith("supabase_"):
return "supabase"
elif tool_name.startswith("openpanel_"):
return "openpanel"
elif tool_name.startswith("appwrite_"):
return "appwrite"
elif tool_name.startswith("directus_"):
return "directus"
# System tools have no plugin type
return None
def _register_tool(self, mcp: FastMCP, tool_info: dict[str, Any], config: EndpointConfig):
"""
Register a single tool with the FastMCP instance.
Args:
mcp: FastMCP instance
tool_info: Tool definition
config: Endpoint configuration
"""
tool_name = tool_info["name"]
# Get handler
handler = tool_info.get("handler") or self._tool_handlers.get(tool_name)
if not handler:
logger.warning(f"No handler found for tool: {tool_name}")
return
# Wrap handler with endpoint context
wrapped_handler = self._wrap_handler(handler, tool_name, config)
# Register with FastMCP
# We need to create a function with the correct signature
mcp.tool()(wrapped_handler)
def _wrap_handler(self, handler: Callable, tool_name: str, config: EndpointConfig) -> Callable:
"""
Wrap a tool handler with endpoint-specific logic.
Args:
handler: Original handler function
tool_name: Name of the tool
config: Endpoint configuration
Returns:
Wrapped handler function
"""
@wraps(handler)
async def wrapped(*args, **kwargs):
# For project endpoints, always inject site filter
# This locks all tools to the specific project's site
if config.site_filter:
# Extract site_id from project's site_filter (format: plugin_type_site_id)
# The site parameter expects just the site identifier (site_id or alias)
if "_" in config.site_filter:
# site_filter is full_id like "wordpress_site1"
parts = config.site_filter.split("_", 1)
if len(parts) == 2:
# Use the site_id part (site1)
kwargs["site"] = parts[1]
else:
kwargs["site"] = config.site_filter
else:
kwargs["site"] = config.site_filter
# Call original handler
return await handler(*args, **kwargs)
# Preserve function metadata
wrapped.__name__ = tool_name
wrapped.__doc__ = handler.__doc__
return wrapped
def get_endpoint(self, path: str) -> FastMCP | None:
"""
Get an endpoint by path.
Args:
path: Endpoint path
Returns:
FastMCP instance or None
"""
return self.endpoints.get(path)
def get_all_endpoints(self) -> dict[str, FastMCP]:
"""Get all registered endpoints"""
return self.endpoints.copy()
def get_endpoint_info(self) -> list[dict[str, Any]]:
"""
Get information about all endpoints.
Returns:
List of endpoint info dictionaries
"""
info = []
for path, mcp in self.endpoints.items():
tool_count = self._tool_counts.get(path, 0)
info.append(
{
"path": path,
"name": mcp.name,
"tool_count": tool_count,
}
)
return info