"""
Unified Tool Generator
Generates context-based tools that work across multiple sites.
Maintains backward compatibility by keeping per-site tools.
Architecture:
- Old: wordpress_site1_get_post(post_id)
- New: wordpress_get_post(site, post_id)
- Both work simultaneously!
"""
import logging
from collections.abc import Callable
from typing import Any
from core.site_registry import get_site_registry
logger = logging.getLogger(__name__)
class UnifiedToolGenerator:
"""
Generates unified tools from per-site tool definitions.
Takes existing plugin tools and creates context-based versions
that accept a 'site' parameter for dynamic routing.
"""
def __init__(self, project_manager):
"""
Initialize unified tool generator.
Args:
project_manager: ProjectManager instance with discovered projects
"""
self.project_manager = project_manager
self.site_registry = get_site_registry()
self.logger = logging.getLogger("UnifiedToolGenerator")
def generate_unified_tools(self, plugin_type: str) -> list[dict[str, Any]]:
"""
Generate unified tools for a specific plugin type.
Args:
plugin_type: Type of plugin (e.g., 'wordpress')
Returns:
List of unified tool definitions
"""
# Get all projects of this type
projects = self.project_manager.get_projects_by_type(plugin_type)
if not projects:
self.logger.warning(f"No projects found for plugin type: {plugin_type}")
return []
# Use the first project as a template to get tool definitions
first_project_id = list(projects.keys())[0]
template_plugin = projects[first_project_id]
template_tools = template_plugin.get_tools()
self.logger.info(
f"Generating unified tools for {plugin_type} "
f"from {len(template_tools)} template tools"
)
unified_tools = []
seen_actions = set()
for tool in template_tools:
# Extract action name from per-site tool name
# e.g., "wordpress_site1_get_post" -> "get_post"
tool_name = tool["name"]
parts = tool_name.split("_")
# Skip if not in expected format
if len(parts) < 3:
continue
# Extract action (everything after plugin_type_site_id_)
# e.g., wordpress_site1_get_post -> get_post
action = "_".join(parts[2:])
# Skip duplicates (we only need one unified tool per action)
if action in seen_actions:
continue
seen_actions.add(action)
# Create unified tool
unified_tool = self._create_unified_tool(
plugin_type=plugin_type, action=action, template_tool=tool
)
if unified_tool:
unified_tools.append(unified_tool)
self.logger.info(f"Generated {len(unified_tools)} unified tools for {plugin_type}")
return unified_tools
def _create_unified_tool(
self, plugin_type: str, action: str, template_tool: dict[str, Any]
) -> dict[str, Any] | None:
"""
Create a unified tool from a template.
Args:
plugin_type: Plugin type (e.g., 'wordpress')
action: Action name (e.g., 'get_post')
template_tool: Original per-site tool definition
Returns:
Unified tool definition
"""
try:
# Create unified tool name
unified_name = f"{plugin_type}_{action}"
# Get available sites for this plugin type
site_options = self.site_registry.get_site_options(plugin_type)
if not site_options:
self.logger.warning(f"No sites available for {plugin_type}, skipping {action}")
return None
# Modify input schema to add 'site' parameter
original_schema = template_tool.get("inputSchema", {})
unified_schema = self._add_site_parameter(original_schema, plugin_type, site_options)
# Update description to mention site parameter
original_description = template_tool.get("description", "")
unified_description = self._update_description(original_description, plugin_type)
# Create wrapper handler
original_handler = template_tool.get("handler")
unified_handler = self._create_unified_handler(plugin_type, action, original_handler)
return {
"name": unified_name,
"description": unified_description,
"inputSchema": unified_schema,
"handler": unified_handler,
}
except Exception as e:
self.logger.error(
f"Error creating unified tool for {plugin_type}_{action}: {e}", exc_info=True
)
return None
def _add_site_parameter(
self, original_schema: dict[str, Any], plugin_type: str, site_options: list[str]
) -> dict[str, Any]:
"""
Add 'site' parameter to input schema.
Args:
original_schema: Original input schema
plugin_type: Plugin type
site_options: Available site IDs/aliases
Returns:
Modified schema with site parameter
"""
# Deep copy to avoid modifying original
import copy
schema = copy.deepcopy(original_schema)
# Ensure schema has required structure
if "properties" not in schema:
schema["properties"] = {}
if "required" not in schema:
schema["required"] = []
# Add 'site' as first parameter
schema["properties"] = {
"site": {
"type": "string",
"description": (
f"Site ID or alias. Available options: {', '.join(site_options)}. "
f"Use list_projects() to see all configured sites."
),
"enum": site_options,
},
**schema["properties"],
}
# Make 'site' required
if "site" not in schema["required"]:
schema["required"].insert(0, "site")
return schema
def _update_description(self, original_description: str, plugin_type: str) -> str:
"""
Update tool description to mention unified context.
Args:
original_description: Original description
plugin_type: Plugin type
Returns:
Updated description
"""
# Remove site-specific mentions (e.g., "from site1", "in site2")
import re
cleaned = re.sub(
r"\b(?:from|in|for)\s+site\d+\b", "", original_description, flags=re.IGNORECASE
)
cleaned = re.sub(
r"\bsite\d+\b", f"the specified {plugin_type} site", cleaned, flags=re.IGNORECASE
)
# Add unified context note
prefix = "[UNIFIED] "
if not cleaned.startswith(prefix):
cleaned = prefix + cleaned
return cleaned.strip()
def _create_unified_handler(
self, plugin_type: str, action: str, original_handler: Callable | None
) -> Callable:
"""
Create a unified handler that routes to the correct site.
Args:
plugin_type: Plugin type
action: Action name
original_handler: Original handler (not used, we call plugin method directly)
Returns:
Async handler function
"""
async def unified_handler(site: str, **kwargs):
"""
Unified handler that routes to the correct site plugin.
Args:
site: Site ID or alias
**kwargs: Other parameters for the tool
"""
try:
# Get site info from registry
site_info = self.site_registry.get_site(plugin_type, site)
if not site_info:
available = self.site_registry.get_site_options(plugin_type)
return (
f"Error: Site '{site}' not found for {plugin_type}. "
f"Available sites: {', '.join(available)}"
)
# Get the plugin instance
full_id = site_info.get_full_id()
# 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 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_info = self.site_registry.get_site(
allowed_plugin_type, allowed_site_identifier
)
if allowed_site_info:
# Normalize to plugin_type_site_id format
allowed_project_normalized = allowed_site_info.get_full_id()
except (ValueError, Exception):
# Site not found, keep original for error message
pass
if allowed_project_normalized != full_id:
logger.warning(
f"Access denied: API key for project '{allowed_project}' "
f"attempted to access '{full_id}'"
)
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 '{full_id}'."
)
plugin = self.project_manager.get_project(full_id)
if not plugin:
return f"Error: Plugin instance not found for {full_id}"
# Find the original handler method in the plugin
# The original per-site tool name was: {plugin_type}_{site_id}_{action}
original_tool_name = f"{plugin_type}_{site_info.site_id}_{action}"
# Get all tools from plugin and find the matching handler
tools = plugin.get_tools()
handler = None
for tool in tools:
if tool["name"] == original_tool_name:
handler = tool.get("handler")
break
if not handler:
return (
f"Error: Handler not found for {original_tool_name}. "
f"This may be a plugin implementation issue."
)
# Filter out None values from kwargs to avoid validation errors
# WordPress API doesn't accept None values in query parameters
filtered_kwargs = {key: value for key, value in kwargs.items() if value is not None}
# Call the handler with filtered kwargs
result = await handler(**filtered_kwargs)
return result
except Exception as e:
logger.error(
f"Error in unified handler for {plugin_type}_{action}: {e}", exc_info=True
)
return f"Error: {str(e)}"
return unified_handler
def generate_all_unified_tools(self) -> list[dict[str, Any]]:
"""
Generate unified tools for all registered plugin types.
Returns:
List of all unified tool definitions
"""
all_tools = []
# Get all plugin types from registry
from plugins import registry
plugin_types = registry.get_registered_types()
for plugin_type in plugin_types:
tools = self.generate_unified_tools(plugin_type)
all_tools.extend(tools)
self.logger.info(
f"Generated {len(all_tools)} total unified tools "
f"across {len(plugin_types)} plugin types"
)
return all_tools