Initial commit: MCP Hub Community Edition v3.0.0
Community edition generated from private repo via sync pipeline. Includes 9 plugins (WordPress, WooCommerce, WP Advanced, Gitea, n8n, Supabase, OpenPanel, Appwrite, Directus) with ~587 tools. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
511
core/tool_generator.py
Normal file
511
core/tool_generator.py
Normal file
@@ -0,0 +1,511 @@
|
||||
"""
|
||||
Tool Generator - Direct tool generation without per-site wrapper
|
||||
|
||||
Generates MCP tools directly from plugin specifications.
|
||||
Part of Option B clean architecture refactoring.
|
||||
"""
|
||||
|
||||
import copy
|
||||
import logging
|
||||
from collections.abc import Callable
|
||||
from typing import Any
|
||||
|
||||
from core.tool_registry import ToolDefinition
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Plugin type fallback mapping - used when a plugin has no sites configured
|
||||
# WooCommerce can fallback to WordPress sites (same URL, credentials)
|
||||
# NOTE: Using fallback is NOT recommended in production. Always configure
|
||||
# explicit WOOCOMMERCE_SITE*_... environment variables for stability.
|
||||
PLUGIN_SITE_FALLBACK = {
|
||||
"woocommerce": "wordpress", # WooCommerce can use WordPress site configs
|
||||
# Add more fallbacks as needed
|
||||
}
|
||||
|
||||
def get_site_plugin_type_with_fallback(plugin_type: str, site_manager) -> str:
|
||||
"""
|
||||
Get the site configuration plugin type for a given plugin.
|
||||
|
||||
Checks if the plugin has its own sites configured first.
|
||||
If not, falls back to a related plugin type (e.g., woocommerce -> wordpress).
|
||||
|
||||
WARNING: Using fallback is not recommended for production use.
|
||||
Always configure explicit environment variables for each plugin type
|
||||
to avoid issues with alias mismatches and credential problems.
|
||||
|
||||
Args:
|
||||
plugin_type: The plugin type
|
||||
site_manager: SiteManager instance to check for configured sites
|
||||
|
||||
Returns:
|
||||
The plugin type to use for site configuration lookup
|
||||
"""
|
||||
# First check if the plugin has its own sites
|
||||
if plugin_type in site_manager.sites and site_manager.sites[plugin_type]:
|
||||
return plugin_type
|
||||
|
||||
# Fallback to related plugin type if available
|
||||
fallback_type = PLUGIN_SITE_FALLBACK.get(plugin_type)
|
||||
if fallback_type and fallback_type in site_manager.sites:
|
||||
# Log a warning - fallback usage is not recommended
|
||||
logger.warning(
|
||||
f"FALLBACK: Using {fallback_type} site config for {plugin_type}. "
|
||||
f"This is NOT recommended for production. "
|
||||
f"Please configure explicit {plugin_type.upper()}_SITE*_... environment variables "
|
||||
f"to avoid potential issues with alias mismatches and credentials."
|
||||
)
|
||||
return fallback_type
|
||||
|
||||
# Return original type (may have no sites)
|
||||
return plugin_type
|
||||
|
||||
class ToolGenerator:
|
||||
"""
|
||||
Generate tools directly from plugin classes.
|
||||
|
||||
No longer wraps per-site tools - generates tools directly
|
||||
from plugin specifications with site parameter routing.
|
||||
|
||||
Attributes:
|
||||
site_manager: Manages site configurations
|
||||
logger: Logger instance
|
||||
|
||||
Examples:
|
||||
>>> generator = ToolGenerator(site_manager)
|
||||
>>> tools = generator.generate_tools(WordPressPlugin, "wordpress")
|
||||
>>> print(f"Generated {len(tools)} tools")
|
||||
"""
|
||||
|
||||
def __init__(self, site_manager):
|
||||
"""
|
||||
Initialize tool generator.
|
||||
|
||||
Args:
|
||||
site_manager: SiteManager instance for site configuration
|
||||
"""
|
||||
self.site_manager = site_manager
|
||||
self.logger = logging.getLogger("ToolGenerator")
|
||||
|
||||
def generate_tools(self, plugin_class: type, plugin_type: str) -> list[ToolDefinition]:
|
||||
"""
|
||||
Generate tools directly from plugin class.
|
||||
|
||||
Args:
|
||||
plugin_class: Plugin class (not instance)
|
||||
plugin_type: Plugin type name (e.g., 'wordpress')
|
||||
|
||||
Returns:
|
||||
List of tool definitions
|
||||
|
||||
Raises:
|
||||
ValueError: If plugin_class doesn't have get_tool_specifications
|
||||
|
||||
Examples:
|
||||
>>> tools = generator.generate_tools(WordPressPlugin, "wordpress")
|
||||
"""
|
||||
# Verify plugin class has required method
|
||||
if not hasattr(plugin_class, "get_tool_specifications"):
|
||||
raise ValueError(
|
||||
f"Plugin class {plugin_class.__name__} must implement "
|
||||
"get_tool_specifications() static method"
|
||||
)
|
||||
|
||||
# Get tool specifications from plugin
|
||||
try:
|
||||
tool_specs = plugin_class.get_tool_specifications()
|
||||
except Exception as e:
|
||||
self.logger.error(
|
||||
f"Failed to get tool specifications from {plugin_class.__name__}: {e}",
|
||||
exc_info=True,
|
||||
)
|
||||
return []
|
||||
|
||||
self.logger.info(
|
||||
f"Generating tools for {plugin_type} " f"from {len(tool_specs)} specifications"
|
||||
)
|
||||
|
||||
tools = []
|
||||
for spec in tool_specs:
|
||||
try:
|
||||
tool = self._create_tool_from_spec(plugin_class, plugin_type, spec)
|
||||
if tool:
|
||||
tools.append(tool)
|
||||
except Exception as e:
|
||||
self.logger.error(
|
||||
f"Failed to create tool from spec {spec.get('name', 'unknown')}: {e}",
|
||||
exc_info=True,
|
||||
)
|
||||
|
||||
self.logger.info(f"Generated {len(tools)} tools for {plugin_type}")
|
||||
return tools
|
||||
|
||||
def _create_tool_from_spec(
|
||||
self, plugin_class: type, plugin_type: str, spec: dict[str, Any]
|
||||
) -> ToolDefinition:
|
||||
"""
|
||||
Create a tool definition from a specification.
|
||||
|
||||
Args:
|
||||
plugin_class: Plugin class
|
||||
plugin_type: Plugin type name
|
||||
spec: Tool specification dictionary
|
||||
|
||||
Returns:
|
||||
Tool definition
|
||||
|
||||
Raises:
|
||||
ValueError: If spec is invalid
|
||||
|
||||
Tool spec format:
|
||||
{
|
||||
'name': 'list_posts',
|
||||
'method_name': 'list_posts',
|
||||
'description': 'List WordPress posts',
|
||||
'schema': {...}, # Input schema (without site param)
|
||||
'scope': 'read' # Optional, defaults to 'read'
|
||||
}
|
||||
"""
|
||||
# Validate required fields
|
||||
required_fields = ["name", "method_name", "description", "schema"]
|
||||
for field in required_fields:
|
||||
if field not in spec:
|
||||
raise ValueError(f"Tool spec missing required field: {field}")
|
||||
|
||||
# Extract spec fields
|
||||
action_name = spec["name"]
|
||||
method_name = spec["method_name"]
|
||||
description = spec["description"]
|
||||
schema = spec["schema"]
|
||||
scope = spec.get("scope", "read")
|
||||
|
||||
# Create full tool name
|
||||
tool_name = f"{plugin_type}_{action_name}"
|
||||
|
||||
# Add site parameter to schema
|
||||
enhanced_schema = self._add_site_parameter(schema, plugin_type)
|
||||
|
||||
# Add [UNIFIED] prefix to description if not present
|
||||
if not description.startswith("[UNIFIED]"):
|
||||
description = f"[UNIFIED] {description}"
|
||||
|
||||
# Create handler with site routing
|
||||
handler = self._create_handler(plugin_class, plugin_type, method_name)
|
||||
|
||||
return ToolDefinition(
|
||||
name=tool_name,
|
||||
description=description,
|
||||
input_schema=enhanced_schema,
|
||||
handler=handler,
|
||||
required_scope=scope,
|
||||
plugin_type=plugin_type,
|
||||
)
|
||||
|
||||
def _add_site_parameter(
|
||||
self, original_schema: dict[str, Any], plugin_type: str
|
||||
) -> dict[str, Any]:
|
||||
"""
|
||||
Add 'site' parameter to input schema.
|
||||
|
||||
Args:
|
||||
original_schema: Original input schema
|
||||
plugin_type: Plugin type for site options
|
||||
|
||||
Returns:
|
||||
Schema with site parameter added
|
||||
|
||||
Examples:
|
||||
>>> schema = {'type': 'object', 'properties': {'post_id': {...}}}
|
||||
>>> enhanced = generator._add_site_parameter(schema, 'wordpress')
|
||||
>>> assert 'site' in enhanced['properties']
|
||||
"""
|
||||
# Deep copy to avoid modifying original
|
||||
schema = copy.deepcopy(original_schema)
|
||||
|
||||
# Ensure schema has required structure
|
||||
if "properties" not in schema:
|
||||
schema["properties"] = {}
|
||||
if "required" not in schema:
|
||||
schema["required"] = []
|
||||
|
||||
# Get available sites for this plugin type
|
||||
# Use fallback if no sites configured (e.g., woocommerce -> wordpress)
|
||||
site_plugin_type = get_site_plugin_type_with_fallback(plugin_type, self.site_manager)
|
||||
site_options = self.site_manager.list_sites(site_plugin_type)
|
||||
|
||||
if not site_options:
|
||||
# No sites configured - add site param anyway for future use
|
||||
site_options = []
|
||||
|
||||
# Phase K.2.6: For single-site MCPs, make site parameter optional
|
||||
is_single_site = len(site_options) == 1
|
||||
|
||||
if is_single_site:
|
||||
# Single site - parameter is optional with helpful description
|
||||
single_site = site_options[0]
|
||||
schema["properties"] = {
|
||||
"site": {
|
||||
"type": "string",
|
||||
"description": (
|
||||
f"🔗 SINGLE SITE: Connected to '{single_site}'. "
|
||||
f"This parameter is OPTIONAL - you can omit it or use any value."
|
||||
),
|
||||
"default": single_site,
|
||||
},
|
||||
**schema["properties"],
|
||||
}
|
||||
# Don't add 'site' to required for single-site MCPs
|
||||
else:
|
||||
# Multiple sites or no sites - parameter is required
|
||||
schema["properties"] = {
|
||||
"site": {
|
||||
"type": "string",
|
||||
"description": (
|
||||
f"Site ID or alias. "
|
||||
f"Available options: {', '.join(site_options) if site_options else 'None configured'}. "
|
||||
f"Use list_sites() to see all configured sites."
|
||||
),
|
||||
**({"enum": site_options} if site_options else {}),
|
||||
},
|
||||
**schema["properties"],
|
||||
}
|
||||
# Make 'site' required for multi-site MCPs
|
||||
if "site" not in schema["required"]:
|
||||
schema["required"].insert(0, "site")
|
||||
|
||||
return schema
|
||||
|
||||
def _create_handler(self, plugin_class: type, plugin_type: str, method_name: str) -> Callable:
|
||||
"""
|
||||
Create async handler with site routing.
|
||||
|
||||
The handler:
|
||||
1. Extracts site from parameters
|
||||
2. Gets site configuration
|
||||
3. Creates plugin instance for this request
|
||||
4. Calls the specified method
|
||||
5. Returns result
|
||||
|
||||
Args:
|
||||
plugin_class: Plugin class to instantiate
|
||||
plugin_type: Plugin type name
|
||||
method_name: Method name to call on plugin instance
|
||||
|
||||
Returns:
|
||||
Async handler function
|
||||
|
||||
Examples:
|
||||
>>> handler = generator._create_handler(
|
||||
... WordPressPlugin, "wordpress", "list_posts"
|
||||
... )
|
||||
>>> result = await handler(site="site1", per_page=10)
|
||||
"""
|
||||
|
||||
async def unified_handler(site: str = None, **kwargs):
|
||||
"""
|
||||
Unified handler that routes to the correct site plugin.
|
||||
|
||||
Args:
|
||||
site: Site ID or alias (optional for single-site MCPs)
|
||||
**kwargs: Other parameters for the tool
|
||||
|
||||
Returns:
|
||||
Result from plugin method
|
||||
|
||||
Raises:
|
||||
ValueError: If site not found or access denied
|
||||
"""
|
||||
try:
|
||||
# Get site configuration
|
||||
# Use fallback if no sites configured (e.g., woocommerce -> wordpress)
|
||||
site_plugin_type = get_site_plugin_type_with_fallback(
|
||||
plugin_type, self.site_manager
|
||||
)
|
||||
|
||||
# Phase K.2.6: Auto-detect site for single-site MCPs
|
||||
if not site:
|
||||
available_sites = self.site_manager.list_sites(site_plugin_type)
|
||||
if len(available_sites) == 1:
|
||||
site = available_sites[0]
|
||||
elif len(available_sites) == 0:
|
||||
return "Error: No sites configured. Please check environment variables."
|
||||
else:
|
||||
return (
|
||||
f"Error: Multiple sites available ({', '.join(available_sites)}). "
|
||||
f"Please specify the 'site' parameter."
|
||||
)
|
||||
|
||||
site_config = self.site_manager.get_site_config(site_plugin_type, site)
|
||||
|
||||
# SECURITY: Check if API key has access to this project
|
||||
from core.context import get_api_key_context
|
||||
|
||||
api_key_info = get_api_key_context()
|
||||
|
||||
if api_key_info and not api_key_info.get("is_global"):
|
||||
# Per-project key - must match the project
|
||||
allowed_project = api_key_info.get("project_id")
|
||||
|
||||
# Resolve the current project - always use site_id for consistency
|
||||
# Use site_plugin_type for consistent project naming
|
||||
current_project = f"{site_plugin_type}_{site_config.site_id}"
|
||||
|
||||
# Resolve allowed_project to normalize alias vs site_id
|
||||
# API key might have been created with alias (wordpress_myblog)
|
||||
# or site_id (wordpress_site1)
|
||||
allowed_project_normalized = allowed_project
|
||||
if allowed_project and "_" in allowed_project:
|
||||
# Extract plugin type and site identifier from allowed_project
|
||||
allowed_parts = allowed_project.split("_", 1)
|
||||
if len(allowed_parts) == 2:
|
||||
allowed_plugin_type, allowed_site_identifier = allowed_parts
|
||||
# Try to resolve the site identifier to site_id
|
||||
try:
|
||||
allowed_site_config = self.site_manager.get_site_config(
|
||||
allowed_plugin_type, allowed_site_identifier
|
||||
)
|
||||
# Normalize to plugin_type_site_id format
|
||||
allowed_project_normalized = (
|
||||
f"{allowed_plugin_type}_{allowed_site_config.site_id}"
|
||||
)
|
||||
except ValueError:
|
||||
# Site not found, keep original for error message
|
||||
pass
|
||||
|
||||
if allowed_project_normalized != current_project:
|
||||
logger.warning(
|
||||
f"Access denied: API key for project '{allowed_project}' "
|
||||
f"attempted to access '{current_project}'"
|
||||
)
|
||||
return (
|
||||
f"Error: Access denied. This API key is restricted to project '{allowed_project}'. "
|
||||
f"Use a global API key or create a key for '{current_project}'."
|
||||
)
|
||||
|
||||
# Create plugin instance for this request
|
||||
# Convert SiteConfig Pydantic model to dict
|
||||
# Use model_dump() for Pydantic V2, fallback to dict() for V1
|
||||
if hasattr(site_config, "model_dump"):
|
||||
config_dict = site_config.model_dump()
|
||||
elif hasattr(site_config, "dict"):
|
||||
config_dict = site_config.dict()
|
||||
else:
|
||||
config_dict = site_config
|
||||
|
||||
plugin_instance = plugin_class(config_dict)
|
||||
|
||||
# Get the method from plugin instance
|
||||
if not hasattr(plugin_instance, method_name):
|
||||
return (
|
||||
f"Error: Method '{method_name}' not found in "
|
||||
f"{plugin_class.__name__}. This is a plugin implementation error."
|
||||
)
|
||||
|
||||
method = getattr(plugin_instance, method_name)
|
||||
|
||||
# Phase K.2.1: Enhanced parameter processing
|
||||
# 1. Parse JSON strings to objects (for billing, shipping, line_items, etc.)
|
||||
# 2. Filter out None and empty values
|
||||
import json as json_module
|
||||
|
||||
def process_value(value):
|
||||
"""Process parameter value - parse JSON strings if needed."""
|
||||
if value is None:
|
||||
return None
|
||||
if isinstance(value, str):
|
||||
# Skip empty strings
|
||||
if value.strip() == "":
|
||||
return None
|
||||
# Try to parse JSON strings
|
||||
stripped = value.strip()
|
||||
if (stripped.startswith("{") and stripped.endswith("}")) or (
|
||||
stripped.startswith("[") and stripped.endswith("]")
|
||||
):
|
||||
try:
|
||||
return json_module.loads(value)
|
||||
except json_module.JSONDecodeError:
|
||||
# Not valid JSON, return original string
|
||||
return value
|
||||
return value
|
||||
|
||||
filtered_kwargs = {}
|
||||
for key, value in kwargs.items():
|
||||
processed = process_value(value)
|
||||
if processed is not None:
|
||||
filtered_kwargs[key] = processed
|
||||
|
||||
# Call the method
|
||||
result = await method(**filtered_kwargs)
|
||||
return result
|
||||
|
||||
except ValueError as e:
|
||||
# Site not found or validation error
|
||||
logger.warning(f"Validation error in {plugin_type}_{method_name}: {e}")
|
||||
return f"Error: {str(e)}"
|
||||
|
||||
except Exception as e:
|
||||
# Import custom exceptions for better error handling
|
||||
from plugins.wordpress.client import AuthenticationError, ConfigurationError
|
||||
|
||||
error_type = type(e).__name__
|
||||
|
||||
if isinstance(e, ConfigurationError):
|
||||
# Configuration error - likely missing env vars
|
||||
logger.error(f"Configuration error in {plugin_type}_{method_name}: {e}")
|
||||
return (
|
||||
f"Configuration Error: {str(e)}\n\n"
|
||||
f"Hint: For {plugin_type}, ensure these environment variables are set:\n"
|
||||
f" - {plugin_type.upper()}_SITE*_URL\n"
|
||||
f" - {plugin_type.upper()}_SITE*_USERNAME\n"
|
||||
f" - {plugin_type.upper()}_SITE*_APP_PASSWORD"
|
||||
)
|
||||
|
||||
elif isinstance(e, AuthenticationError):
|
||||
# Authentication error - 401/403
|
||||
logger.warning(f"Authentication error in {plugin_type}_{method_name}: {e}")
|
||||
return f"Authentication Error: {str(e)}"
|
||||
|
||||
else:
|
||||
# Unexpected error
|
||||
logger.error(
|
||||
f"Error in unified handler for {plugin_type}_{method_name}: {e}",
|
||||
exc_info=True,
|
||||
)
|
||||
return f"Error ({error_type}): {str(e)}"
|
||||
|
||||
# Set function name for better debugging
|
||||
unified_handler.__name__ = f"{plugin_type}_{method_name}_handler"
|
||||
|
||||
return unified_handler
|
||||
|
||||
def generate_all_tools(self, plugin_classes: dict[str, type]) -> list[ToolDefinition]:
|
||||
"""
|
||||
Generate tools for all plugin classes.
|
||||
|
||||
Args:
|
||||
plugin_classes: Dict mapping plugin_type to plugin class
|
||||
|
||||
Returns:
|
||||
List of all tool definitions
|
||||
|
||||
Examples:
|
||||
>>> plugins = {
|
||||
... 'wordpress': WordPressPlugin,
|
||||
... 'gitea': GiteaPlugin
|
||||
... }
|
||||
>>> all_tools = generator.generate_all_tools(plugins)
|
||||
"""
|
||||
all_tools = []
|
||||
|
||||
for plugin_type, plugin_class in plugin_classes.items():
|
||||
try:
|
||||
tools = self.generate_tools(plugin_class, plugin_type)
|
||||
all_tools.extend(tools)
|
||||
except Exception as e:
|
||||
self.logger.error(f"Failed to generate tools for {plugin_type}: {e}", exc_info=True)
|
||||
|
||||
self.logger.info(
|
||||
f"Generated {len(all_tools)} total tools " f"across {len(plugin_classes)} plugin types"
|
||||
)
|
||||
|
||||
return all_tools
|
||||
Reference in New Issue
Block a user