- Fix Python formatting (sync no longer strips blank lines from .py files) - Remove test_community_build.py (tests private sync module) - Fix ruff warnings in test files - Add CODE_OF_CONDUCT.md - Add .github/PULL_REQUEST_TEMPLATE.md Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
364 lines
13 KiB
Python
364 lines
13 KiB
Python
"""
|
|
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
|