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>
512 lines
20 KiB
Python
512 lines
20 KiB
Python
"""
|
|
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
|