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:
448
plugins/wordpress/client.py
Normal file
448
plugins/wordpress/client.py
Normal file
@@ -0,0 +1,448 @@
|
||||
"""
|
||||
WordPress REST API Client
|
||||
|
||||
Handles all HTTP communication with WordPress REST API.
|
||||
Separates API communication from business logic.
|
||||
"""
|
||||
|
||||
import base64
|
||||
import json
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
import aiohttp
|
||||
|
||||
class ConfigurationError(Exception):
|
||||
"""Raised when site configuration is invalid or incomplete."""
|
||||
|
||||
pass
|
||||
|
||||
class AuthenticationError(Exception):
|
||||
"""Raised when authentication fails (401/403)."""
|
||||
|
||||
pass
|
||||
|
||||
class WordPressClient:
|
||||
"""
|
||||
WordPress REST API client for HTTP communication.
|
||||
|
||||
Handles authentication, request formatting, and error handling
|
||||
for all WordPress and WooCommerce API endpoints.
|
||||
"""
|
||||
|
||||
def __init__(self, site_url: str, username: str, app_password: str):
|
||||
"""
|
||||
Initialize WordPress API client.
|
||||
|
||||
Args:
|
||||
site_url: WordPress site URL (e.g., https://example.com)
|
||||
username: WordPress username
|
||||
app_password: WordPress application password
|
||||
|
||||
Raises:
|
||||
ConfigurationError: If required parameters are missing or invalid
|
||||
"""
|
||||
# Validate required parameters
|
||||
if not site_url:
|
||||
raise ConfigurationError(
|
||||
"Site URL is not configured. "
|
||||
"Please set the URL environment variable (e.g., WORDPRESS_SITE1_URL)."
|
||||
)
|
||||
if not username:
|
||||
raise ConfigurationError(
|
||||
"Username is not configured. "
|
||||
"Please set the USERNAME environment variable (e.g., WORDPRESS_SITE1_USERNAME)."
|
||||
)
|
||||
if not app_password:
|
||||
raise ConfigurationError(
|
||||
"App password is not configured. "
|
||||
"Please set the APP_PASSWORD environment variable (e.g., WORDPRESS_SITE1_APP_PASSWORD)."
|
||||
)
|
||||
|
||||
self.site_url = site_url.rstrip("/")
|
||||
self.api_base = f"{self.site_url}/wp-json/wp/v2"
|
||||
self.wc_api_base = f"{self.site_url}/wp-json/wc/v3"
|
||||
self.username = username
|
||||
self.app_password = app_password
|
||||
|
||||
# Initialize logger
|
||||
self.logger = logging.getLogger(f"WordPressClient.{site_url}")
|
||||
|
||||
# Create auth header
|
||||
credentials = f"{self.username}:{self.app_password}"
|
||||
token = base64.b64encode(credentials.encode()).decode()
|
||||
self.auth_header = f"Basic {token}"
|
||||
|
||||
async def request(
|
||||
self,
|
||||
method: str,
|
||||
endpoint: str,
|
||||
params: dict | None = None,
|
||||
json_data: dict | None = None,
|
||||
data: Any | None = None,
|
||||
headers_override: dict | None = None,
|
||||
use_custom_namespace: bool = False,
|
||||
use_woocommerce: bool = False,
|
||||
) -> dict[str, Any]:
|
||||
"""
|
||||
Make authenticated request to WordPress REST API.
|
||||
|
||||
Args:
|
||||
method: HTTP method (GET, POST, PUT, DELETE, PATCH)
|
||||
endpoint: API endpoint (without base URL)
|
||||
params: Query parameters
|
||||
json_data: JSON body data
|
||||
data: Raw data or FormData for file uploads
|
||||
headers_override: Override default headers
|
||||
use_custom_namespace: If True, use wp-json root instead of wp/v2
|
||||
use_woocommerce: If True, use WooCommerce API base
|
||||
|
||||
Returns:
|
||||
Dict: API response as JSON
|
||||
|
||||
Raises:
|
||||
Exception: On API errors with status code and message
|
||||
"""
|
||||
# Build URL based on endpoint type
|
||||
if use_custom_namespace:
|
||||
# For custom namespaces like seo-api-bridge/v1
|
||||
url = f"{self.site_url}/wp-json/{endpoint}"
|
||||
elif use_woocommerce:
|
||||
# For WooCommerce endpoints
|
||||
url = f"{self.wc_api_base}/{endpoint}"
|
||||
else:
|
||||
# Standard WordPress endpoints
|
||||
url = f"{self.api_base}/{endpoint}"
|
||||
|
||||
# Setup headers
|
||||
headers = {"Authorization": self.auth_header}
|
||||
if headers_override:
|
||||
headers.update(headers_override)
|
||||
|
||||
# Filter out None, empty strings, and empty lists from params
|
||||
# to avoid WordPress/WooCommerce API validation errors
|
||||
# Phase K.2.1: Enhanced parameter filtering
|
||||
def should_include(v):
|
||||
"""Check if value should be included in request."""
|
||||
if v is None:
|
||||
return False
|
||||
if isinstance(v, str) and v.strip() == "":
|
||||
return False
|
||||
return not (isinstance(v, list) and len(v) == 0)
|
||||
|
||||
if params:
|
||||
params = {k: v for k, v in params.items() if should_include(v)}
|
||||
|
||||
# Filter None and empty values from JSON data for POST/PUT/PATCH requests
|
||||
if json_data:
|
||||
json_data = {k: v for k, v in json_data.items() if should_include(v)}
|
||||
|
||||
# Make request
|
||||
async with (
|
||||
aiohttp.ClientSession() as session,
|
||||
session.request(
|
||||
method, url, params=params, json=json_data, data=data, headers=headers
|
||||
) as response,
|
||||
):
|
||||
# Handle errors with structured error messages
|
||||
if response.status >= 400:
|
||||
error_text = await response.text()
|
||||
|
||||
# Parse structured error response
|
||||
error_info = self._parse_error_response(
|
||||
response.status, error_text, use_woocommerce
|
||||
)
|
||||
|
||||
# Log the error for debugging
|
||||
self.logger.error(
|
||||
f"API error: {error_info['error_code']} - {error_info['message']}"
|
||||
)
|
||||
|
||||
# Raise appropriate exception
|
||||
if response.status in (401, 403):
|
||||
raise AuthenticationError(
|
||||
f"[{error_info['error_code']}] {error_info['message']}"
|
||||
)
|
||||
|
||||
raise Exception(f"[{error_info['error_code']}] {error_info['message']}")
|
||||
|
||||
# Return JSON response
|
||||
return await response.json()
|
||||
|
||||
def _parse_error_response(
|
||||
self, status_code: int, error_text: str, use_woocommerce: bool = False
|
||||
) -> dict[str, Any]:
|
||||
"""
|
||||
Parse error response and return structured error info.
|
||||
|
||||
Args:
|
||||
status_code: HTTP status code
|
||||
error_text: Raw error response text
|
||||
use_woocommerce: Whether this is a WooCommerce API call
|
||||
|
||||
Returns:
|
||||
Dict with error_code, message, and details
|
||||
"""
|
||||
# Try to parse as JSON
|
||||
try:
|
||||
error_json = json.loads(error_text)
|
||||
wp_error_code = error_json.get("code", "unknown_error")
|
||||
wp_message = error_json.get("message", error_text)
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
wp_error_code = "unknown_error"
|
||||
wp_message = error_text
|
||||
|
||||
# Map status codes to structured error codes
|
||||
error_codes = {
|
||||
400: "BAD_REQUEST",
|
||||
401: "AUTH_FAILED",
|
||||
403: "ACCESS_DENIED",
|
||||
404: "NOT_FOUND",
|
||||
405: "METHOD_NOT_ALLOWED",
|
||||
409: "CONFLICT",
|
||||
422: "VALIDATION_ERROR",
|
||||
429: "RATE_LIMITED",
|
||||
500: "SERVER_ERROR",
|
||||
502: "BAD_GATEWAY",
|
||||
503: "SERVICE_UNAVAILABLE",
|
||||
}
|
||||
|
||||
error_code = error_codes.get(status_code, f"HTTP_{status_code}")
|
||||
|
||||
# Create user-friendly messages for common errors
|
||||
# Phase K.2.1: Enhanced error messages with helpful hints
|
||||
friendly_messages = {
|
||||
400: (
|
||||
f"Invalid request parameters. {wp_message}. "
|
||||
"Hints: Check parameter types match the expected format. "
|
||||
'For categories/tags use IDs (e.g., [62] or "62,63"). '
|
||||
"For billing/shipping use JSON objects."
|
||||
),
|
||||
401: (
|
||||
"Authentication failed. Please verify: "
|
||||
"1) Username is correct, "
|
||||
"2) Application Password is valid, "
|
||||
"3) User has required permissions. "
|
||||
f"Details: {wp_message}"
|
||||
),
|
||||
403: (
|
||||
"Access denied. The user does not have permission for this action. "
|
||||
"Some operations (like coupons) require admin-level WooCommerce permissions. "
|
||||
f"Details: {wp_message}"
|
||||
),
|
||||
404: (
|
||||
"Resource not found. The requested endpoint or item does not exist. "
|
||||
f"Details: {wp_message}"
|
||||
),
|
||||
}
|
||||
|
||||
# Add WooCommerce-specific hints
|
||||
if use_woocommerce and status_code == 401:
|
||||
friendly_messages[401] = (
|
||||
"WooCommerce authentication failed. Please verify: "
|
||||
"1) WooCommerce REST API is enabled, "
|
||||
"2) Application Password has WooCommerce permissions, "
|
||||
"3) User has 'manage_woocommerce' capability. "
|
||||
f"Details: {wp_message}"
|
||||
)
|
||||
|
||||
message = friendly_messages.get(status_code, f"{wp_message}")
|
||||
|
||||
return {
|
||||
"error_code": error_code,
|
||||
"status_code": status_code,
|
||||
"message": message,
|
||||
"wp_error_code": wp_error_code,
|
||||
"raw_response": error_text[:500], # Limit raw response length
|
||||
}
|
||||
|
||||
async def get(
|
||||
self,
|
||||
endpoint: str,
|
||||
params: dict | None = None,
|
||||
use_custom_namespace: bool = False,
|
||||
use_woocommerce: bool = False,
|
||||
) -> dict[str, Any]:
|
||||
"""
|
||||
Make GET request.
|
||||
|
||||
Args:
|
||||
endpoint: API endpoint
|
||||
params: Query parameters
|
||||
use_custom_namespace: Use custom namespace instead of wp/v2
|
||||
use_woocommerce: Use WooCommerce API base
|
||||
|
||||
Returns:
|
||||
Dict: API response
|
||||
"""
|
||||
return await self.request(
|
||||
"GET",
|
||||
endpoint,
|
||||
params=params,
|
||||
use_custom_namespace=use_custom_namespace,
|
||||
use_woocommerce=use_woocommerce,
|
||||
)
|
||||
|
||||
async def post(
|
||||
self,
|
||||
endpoint: str,
|
||||
json_data: dict | None = None,
|
||||
data: Any | None = None,
|
||||
headers_override: dict | None = None,
|
||||
use_custom_namespace: bool = False,
|
||||
use_woocommerce: bool = False,
|
||||
) -> dict[str, Any]:
|
||||
"""
|
||||
Make POST request.
|
||||
|
||||
Args:
|
||||
endpoint: API endpoint
|
||||
json_data: JSON body data
|
||||
data: Raw data or FormData for file uploads
|
||||
headers_override: Override default headers
|
||||
use_custom_namespace: Use custom namespace instead of wp/v2
|
||||
use_woocommerce: Use WooCommerce API base
|
||||
|
||||
Returns:
|
||||
Dict: API response
|
||||
"""
|
||||
return await self.request(
|
||||
"POST",
|
||||
endpoint,
|
||||
json_data=json_data,
|
||||
data=data,
|
||||
headers_override=headers_override,
|
||||
use_custom_namespace=use_custom_namespace,
|
||||
use_woocommerce=use_woocommerce,
|
||||
)
|
||||
|
||||
async def put(
|
||||
self,
|
||||
endpoint: str,
|
||||
json_data: dict,
|
||||
use_custom_namespace: bool = False,
|
||||
use_woocommerce: bool = False,
|
||||
) -> dict[str, Any]:
|
||||
"""
|
||||
Make PUT request.
|
||||
|
||||
Args:
|
||||
endpoint: API endpoint
|
||||
json_data: JSON body data
|
||||
use_custom_namespace: Use custom namespace instead of wp/v2
|
||||
use_woocommerce: Use WooCommerce API base
|
||||
|
||||
Returns:
|
||||
Dict: API response
|
||||
"""
|
||||
return await self.request(
|
||||
"PUT",
|
||||
endpoint,
|
||||
json_data=json_data,
|
||||
use_custom_namespace=use_custom_namespace,
|
||||
use_woocommerce=use_woocommerce,
|
||||
)
|
||||
|
||||
async def patch(
|
||||
self,
|
||||
endpoint: str,
|
||||
json_data: dict,
|
||||
use_custom_namespace: bool = False,
|
||||
use_woocommerce: bool = False,
|
||||
) -> dict[str, Any]:
|
||||
"""
|
||||
Make PATCH request.
|
||||
|
||||
Args:
|
||||
endpoint: API endpoint
|
||||
json_data: JSON body data
|
||||
use_custom_namespace: Use custom namespace instead of wp/v2
|
||||
use_woocommerce: Use WooCommerce API base
|
||||
|
||||
Returns:
|
||||
Dict: API response
|
||||
"""
|
||||
return await self.request(
|
||||
"PATCH",
|
||||
endpoint,
|
||||
json_data=json_data,
|
||||
use_custom_namespace=use_custom_namespace,
|
||||
use_woocommerce=use_woocommerce,
|
||||
)
|
||||
|
||||
async def delete(
|
||||
self,
|
||||
endpoint: str,
|
||||
params: dict | None = None,
|
||||
use_custom_namespace: bool = False,
|
||||
use_woocommerce: bool = False,
|
||||
) -> dict[str, Any]:
|
||||
"""
|
||||
Make DELETE request.
|
||||
|
||||
Args:
|
||||
endpoint: API endpoint
|
||||
params: Query parameters
|
||||
use_custom_namespace: Use custom namespace instead of wp/v2
|
||||
use_woocommerce: Use WooCommerce API base
|
||||
|
||||
Returns:
|
||||
Dict: API response
|
||||
"""
|
||||
return await self.request(
|
||||
"DELETE",
|
||||
endpoint,
|
||||
params=params,
|
||||
use_custom_namespace=use_custom_namespace,
|
||||
use_woocommerce=use_woocommerce,
|
||||
)
|
||||
|
||||
async def check_woocommerce(self) -> dict[str, Any]:
|
||||
"""
|
||||
Check if WooCommerce is installed and accessible.
|
||||
|
||||
Returns:
|
||||
Dict with 'available' bool and version info
|
||||
"""
|
||||
try:
|
||||
# Try to access WooCommerce system status endpoint
|
||||
response = await self.get("system_status", use_woocommerce=True)
|
||||
return {
|
||||
"available": True,
|
||||
"version": response.get("environment", {}).get("version", "unknown"),
|
||||
}
|
||||
except Exception:
|
||||
return {"available": False, "version": None}
|
||||
|
||||
async def check_site_health(self) -> dict[str, Any]:
|
||||
"""
|
||||
Check WordPress site health and accessibility.
|
||||
|
||||
Returns:
|
||||
Dict with health status information
|
||||
"""
|
||||
try:
|
||||
async with aiohttp.ClientSession() as session:
|
||||
async with session.get(f"{self.site_url}/wp-json") as response:
|
||||
if response.status == 200:
|
||||
data = await response.json()
|
||||
return {
|
||||
"healthy": True,
|
||||
"accessible": True,
|
||||
"name": data.get("name", "Unknown"),
|
||||
"description": data.get("description", "Unknown"),
|
||||
"url": data.get("url", self.site_url),
|
||||
"routes": len(data.get("routes", {})),
|
||||
}
|
||||
else:
|
||||
return {
|
||||
"healthy": False,
|
||||
"accessible": False,
|
||||
"message": f"Site returned status {response.status}",
|
||||
}
|
||||
except Exception as e:
|
||||
return {
|
||||
"healthy": False,
|
||||
"accessible": False,
|
||||
"message": f"Health check failed: {str(e)}",
|
||||
}
|
||||
Reference in New Issue
Block a user