Three-month batch sync from internal repo (~80 commits) covering Tracks F.5a, F.7e, F.8, F.17, F.18, F.X. WordPress media pipeline - Pillow-based optimization, AI image generation (OpenAI / Stability / Replicate / Google Nano Banana / OpenRouter), chunked + resumable uploads, bulk delete/reassign, idempotent retries. Capability discovery (F.7e) - Per-site credential probe + adapters for WordPress / WooCommerce / Gitea, tier-fit unions granted ∪ roles, capability badge UI with HTMX partial re-check, install hint in every companion-unreachable error. Companion plugin overhaul - Renamed wordpress-plugin/airano-mcp-seo-bridge → wordpress-plugin/airano-mcp-bridge. - Eight new endpoints: /capabilities, /bulk-meta, /export, /cache-purge, /transient-flush, /site-health, /audit-hook, /upload-and-attach. - wp.org Plugin Check pass: i18n, WP_Filesystem, scheme allowlist on audit-hook URL. Other - Gitea ergonomics (F.17): batch files, tree, search, compare, releases, fork. - Opportunistic bcrypt upgrade for legacy SHA-256 admin keys (F.8). - n8n refactor: structured errors, capability probe, missing tools backfilled. - Idempotency-Key dedup for AI media upload retries; WP client fast-fails on unreachable sites. Docs - README + CLAUDE.md drop the fixed "633 tools" claim. The total grows with each release; per-plugin approximations + dashboard-surfaced counts replace it. - Tools/Tests badges removed in favour of "Plugins: 10". Deployment - PyPI mirror chain, optional BUILD_HTTP_PROXY, Alpine→Yandex apk mirror, Debian-slim Plan-B Dockerfile, mirror.gcr.io variant. CI - Black + Ruff clean on Python 3.12; pytest tests/ green. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
325 lines
11 KiB
Python
325 lines
11 KiB
Python
"""
|
|
Base Plugin Interface
|
|
|
|
All project plugins must inherit from this base class.
|
|
This ensures consistency across different project types.
|
|
"""
|
|
|
|
import logging
|
|
from abc import ABC, abstractmethod
|
|
from typing import Any
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
class BasePlugin(ABC):
|
|
"""
|
|
Base class for all project plugins.
|
|
|
|
Each plugin represents a specific project type (WordPress, Supabase, etc.)
|
|
and provides MCP tools for managing that project.
|
|
"""
|
|
|
|
def __init__(self, config: dict[str, Any], project_id: str | None = None):
|
|
"""
|
|
Initialize plugin with project configuration.
|
|
|
|
**Option B Architecture (New)**:
|
|
plugin = WordPressPlugin(config)
|
|
# project_id extracted from config or generated
|
|
|
|
**Legacy Architecture**:
|
|
plugin = WordPressPlugin(config, project_id="wordpress_site1")
|
|
# Explicit project_id
|
|
|
|
Args:
|
|
config: Project-specific configuration (URLs, credentials, etc.)
|
|
project_id: Optional unique identifier (auto-generated if not provided)
|
|
"""
|
|
# Auto-generate project_id if not provided (Option B)
|
|
if project_id is None:
|
|
# Generate from class name + config
|
|
class_name = self.__class__.__name__.lower().replace("plugin", "")
|
|
site_id = config.get("site_id", config.get("url", "unknown"))
|
|
# Simple hash for unique ID
|
|
import hashlib
|
|
|
|
if isinstance(site_id, str):
|
|
hash_suffix = hashlib.md5(site_id.encode()).hexdigest()[:8]
|
|
project_id = f"{class_name}_{hash_suffix}"
|
|
else:
|
|
import time
|
|
|
|
project_id = f"{class_name}_{int(time.time())}"
|
|
|
|
self.project_id = project_id
|
|
self.config = config
|
|
self.logger = logging.getLogger(f"{self.__class__.__name__}.{project_id}")
|
|
|
|
# Validate required configuration
|
|
self._validate_config()
|
|
|
|
self.logger.info(f"Initialized plugin for project: {project_id}")
|
|
|
|
@abstractmethod
|
|
def get_plugin_name(self) -> str:
|
|
"""
|
|
Return the plugin type name (e.g., 'wordpress', 'supabase').
|
|
|
|
Returns:
|
|
str: Plugin type identifier
|
|
"""
|
|
pass
|
|
|
|
@staticmethod
|
|
def get_tool_specifications() -> list[dict[str, Any]]:
|
|
"""
|
|
Return tool specifications for Option B architecture (ToolGenerator).
|
|
|
|
This is a STATIC method that returns tool specifications without
|
|
needing a plugin instance. ToolGenerator uses these specifications
|
|
to create unified tools with site parameter routing.
|
|
|
|
Each specification should contain:
|
|
- name: Tool name (without site prefix)
|
|
- method_name: Method to call on plugin instance
|
|
- description: What the tool does
|
|
- schema: JSON Schema for input validation (without site parameter)
|
|
- scope: Required scope (read, write, admin)
|
|
|
|
Example:
|
|
[
|
|
{
|
|
"name": "list_posts",
|
|
"method_name": "list_posts",
|
|
"description": "List WordPress posts",
|
|
"schema": {...},
|
|
"scope": "read"
|
|
}
|
|
]
|
|
|
|
Returns:
|
|
List[Dict]: List of tool specifications for ToolGenerator
|
|
|
|
Note:
|
|
Override this in subclasses for Option B architecture.
|
|
If not implemented, returns empty list (legacy plugins).
|
|
"""
|
|
return []
|
|
|
|
def get_tools(self) -> list[dict[str, Any]]:
|
|
"""
|
|
Return list of MCP tools provided by this plugin (LEGACY).
|
|
|
|
**DEPRECATED in Option B Architecture**
|
|
This method is kept for backward compatibility with legacy per-site
|
|
tool architecture. New plugins should implement get_tool_specifications()
|
|
instead, which is used by ToolGenerator for unified tools.
|
|
|
|
Legacy per-site tools format:
|
|
- name: Tool name (prefixed with plugin type and project_id)
|
|
- description: What the tool does
|
|
- inputSchema: JSON Schema for input validation
|
|
- handler: Async function that implements the tool
|
|
|
|
Returns:
|
|
List[Dict]: List of tool definitions (empty for Option B plugins)
|
|
|
|
Note:
|
|
Option B plugins should return [] here as tools are registered
|
|
via get_tool_specifications() + ToolGenerator instead.
|
|
"""
|
|
return []
|
|
|
|
def _validate_config(self) -> None:
|
|
"""
|
|
Validate that required configuration keys are present.
|
|
Override in subclasses to add specific validation.
|
|
"""
|
|
required_keys = self.get_required_config_keys()
|
|
missing_keys = [key for key in required_keys if key not in self.config]
|
|
|
|
if missing_keys:
|
|
raise ValueError(
|
|
f"Missing required configuration keys for {self.project_id}: "
|
|
f"{', '.join(missing_keys)}"
|
|
)
|
|
|
|
def get_required_config_keys(self) -> list[str]:
|
|
"""
|
|
Return list of required configuration keys.
|
|
Override in subclasses.
|
|
|
|
Returns:
|
|
List[str]: Required config keys
|
|
"""
|
|
return []
|
|
|
|
async def health_check(self) -> dict[str, Any]:
|
|
"""
|
|
Check if the project is accessible and healthy.
|
|
Override in subclasses to implement specific health checks.
|
|
|
|
Returns:
|
|
Dict with 'healthy' (bool) and 'message' (str) keys
|
|
"""
|
|
return {"healthy": True, "message": "Health check not implemented for this plugin"}
|
|
|
|
async def probe_credential_capabilities(self) -> dict[str, Any]:
|
|
"""F.7e — report what the saved credential can actually do.
|
|
|
|
Plugins should override this to call the upstream service
|
|
(WordPress companion ``/capabilities``, WooCommerce
|
|
``system_status``, Gitea ``/user`` header scopes, Coolify
|
|
``/api/v1/teams/0``, etc.) and return the subset of capability
|
|
names the caller's token actually grants.
|
|
|
|
The base implementation returns a well-shaped "probe not
|
|
available" payload so callers can handle all plugin types
|
|
uniformly without special-casing the ones that haven't
|
|
implemented a probe yet.
|
|
|
|
Returns:
|
|
Dict with:
|
|
probe_available: bool — True if the plugin knows how to
|
|
query its upstream for capabilities.
|
|
granted: list[str] — the capabilities the credential
|
|
actually grants (subset of the universe the plugin
|
|
understands). Empty when ``probe_available=False``.
|
|
source: str — which endpoint / header the list came from;
|
|
``"unavailable"`` when no probe is wired.
|
|
reason: str | None — optional human-readable diagnostic
|
|
when ``probe_available=False`` or when the call
|
|
failed in a way that still produced a usable answer.
|
|
"""
|
|
return {
|
|
"probe_available": False,
|
|
"granted": [],
|
|
"source": "unavailable",
|
|
"reason": "probe_not_implemented",
|
|
}
|
|
|
|
def get_project_info(self) -> dict[str, Any]:
|
|
"""
|
|
Return basic information about this project instance.
|
|
|
|
Returns:
|
|
Dict with project metadata
|
|
"""
|
|
return {
|
|
"project_id": self.project_id,
|
|
"plugin_type": self.get_plugin_name(),
|
|
"config_keys": list(self.config.keys()),
|
|
}
|
|
|
|
def _create_tool_name(self, action: str) -> str:
|
|
"""
|
|
Create a standardized tool name for per-site tools.
|
|
|
|
FORMAT: {plugin_type}_{site_id}_{action}
|
|
Example: wordpress_site1_list_posts
|
|
|
|
This is used for backward-compatible per-site tools.
|
|
Unified tools (wordpress_list_posts) are created separately by UnifiedToolGenerator.
|
|
|
|
Args:
|
|
action: The action this tool performs
|
|
|
|
Returns:
|
|
str: Formatted tool name with project_id for per-site tools
|
|
"""
|
|
# Extract just the site_id from project_id (e.g., "site1" from "wordpress_site1")
|
|
# project_id format is {plugin_type}_{site_id}
|
|
site_id = self.project_id.replace(f"{self.get_plugin_name()}_", "")
|
|
return f"{self.get_plugin_name()}_{site_id}_{action}"
|
|
|
|
def _format_error_response(self, error: Exception, action: str) -> str:
|
|
"""
|
|
Format an error into a user-friendly message.
|
|
|
|
Args:
|
|
error: The exception that occurred
|
|
action: The action that was being performed
|
|
|
|
Returns:
|
|
str: Formatted error message
|
|
"""
|
|
error_msg = f"Error performing {action} on {self.project_id}: {str(error)}"
|
|
self.logger.error(error_msg, exc_info=True)
|
|
return error_msg
|
|
|
|
def _format_success_response(self, data: Any, action: str) -> str:
|
|
"""
|
|
Format a successful response.
|
|
|
|
Args:
|
|
data: The data to return
|
|
action: The action that was performed
|
|
|
|
Returns:
|
|
str: Formatted success message
|
|
"""
|
|
if isinstance(data, (dict, list)):
|
|
import json
|
|
|
|
return json.dumps(data, indent=2, ensure_ascii=False)
|
|
return str(data)
|
|
|
|
|
|
class PluginRegistry:
|
|
"""
|
|
Registry for managing available plugin types.
|
|
"""
|
|
|
|
def __init__(self):
|
|
self._plugin_classes: dict[str, type] = {}
|
|
self.logger = logging.getLogger("PluginRegistry")
|
|
|
|
def register(self, plugin_type: str, plugin_class: type) -> None:
|
|
"""
|
|
Register a plugin class.
|
|
|
|
Args:
|
|
plugin_type: Type identifier (e.g., 'wordpress')
|
|
plugin_class: Plugin class (must inherit from BasePlugin)
|
|
"""
|
|
if not issubclass(plugin_class, BasePlugin):
|
|
raise TypeError(f"{plugin_class} must inherit from BasePlugin")
|
|
|
|
self._plugin_classes[plugin_type] = plugin_class
|
|
self.logger.info(f"Registered plugin type: {plugin_type}")
|
|
|
|
def create_instance(
|
|
self, plugin_type: str, project_id: str, config: dict[str, Any]
|
|
) -> BasePlugin:
|
|
"""
|
|
Create a plugin instance.
|
|
|
|
**Option B Compatible**: Uses new BasePlugin signature (config, project_id)
|
|
|
|
Args:
|
|
plugin_type: Type of plugin to create
|
|
project_id: Unique project identifier
|
|
config: Project configuration
|
|
|
|
Returns:
|
|
BasePlugin: Instantiated plugin
|
|
|
|
Raises:
|
|
KeyError: If plugin_type is not registered
|
|
"""
|
|
if plugin_type not in self._plugin_classes:
|
|
raise KeyError(f"Unknown plugin type: {plugin_type}")
|
|
|
|
plugin_class = self._plugin_classes[plugin_type]
|
|
# Option B signature: config first, project_id optional
|
|
return plugin_class(config, project_id=project_id)
|
|
|
|
def get_registered_types(self) -> list[str]:
|
|
"""Get list of registered plugin types."""
|
|
return list(self._plugin_classes.keys())
|
|
|
|
def is_registered(self, plugin_type: str) -> bool:
|
|
"""Check if a plugin type is registered."""
|
|
return plugin_type in self._plugin_classes
|