sync: v3.2.0 — Supabase fixes, site edit, WP plugin rename, black formatting

- Supabase: fix filters, count_rows, upsert, anon_key optional, meta_url field
- Dashboard: site edit page + PATCH update route
- WP Plugin: rename SEO API Bridge → Airano MCP SEO Bridge
- supabase/client.py: black formatting fix
- .gitignore: exclude BUILD_REPORT.md

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
airano
2026-03-11 03:21:28 +03:30
parent a806671e2d
commit c0d55f9061
16 changed files with 669 additions and 140 deletions

1
.gitignore vendored
View File

@@ -29,6 +29,7 @@ ENV/
htmlcov/ htmlcov/
.tox/ .tox/
pytest-out.txt pytest-out.txt
scripts/community-build/BUILD_REPORT.md
.nox/ .nox/
coverage.xml coverage.xml
*.cover *.cover

View File

@@ -122,7 +122,11 @@ DASHBOARD_TRANSLATIONS = {
"no_sites": "No sites added yet", "no_sites": "No sites added yet",
"add_first_site": "Add your first site to get started", "add_first_site": "Add your first site to get started",
"site_added": "Site added successfully", "site_added": "Site added successfully",
"site_updated": "Site updated successfully",
"site_deleted": "Site deleted", "site_deleted": "Site deleted",
"edit_site": "Edit Site",
"updating_site": "Updating site...",
"keep_existing": "Leave blank to keep current value",
"connection_ok": "Connection OK", "connection_ok": "Connection OK",
"connection_failed": "Connection failed", "connection_failed": "Connection failed",
"credentials": "Credentials", "credentials": "Credentials",
@@ -218,7 +222,11 @@ DASHBOARD_TRANSLATIONS = {
"no_sites": "هنوز سایتی اضافه نشده", "no_sites": "هنوز سایتی اضافه نشده",
"add_first_site": "اولین سایت خود را اضافه کنید", "add_first_site": "اولین سایت خود را اضافه کنید",
"site_added": "سایت با موفقیت اضافه شد", "site_added": "سایت با موفقیت اضافه شد",
"site_updated": "سایت با موفقیت بروزرسانی شد",
"site_deleted": "سایت حذف شد", "site_deleted": "سایت حذف شد",
"edit_site": "ویرایش سایت",
"updating_site": "در حال بروزرسانی...",
"keep_existing": "خالی بگذارید تا مقدار فعلی حفظ شود",
"connection_ok": "اتصال برقرار", "connection_ok": "اتصال برقرار",
"connection_failed": "اتصال ناموفق", "connection_failed": "اتصال ناموفق",
"credentials": "مشخصات دسترسی", "credentials": "مشخصات دسترسی",
@@ -2811,6 +2819,82 @@ async def api_test_site(request: Request) -> Response:
return JSONResponse({"error": "Internal error"}, status_code=500) return JSONResponse({"error": "Internal error"}, status_code=500)
async def dashboard_sites_edit(request: Request) -> Response:
"""GET /dashboard/sites/{id}/edit — Render the Edit Site form."""
user_session, redirect = _require_user_session(request)
if redirect:
return redirect
site_id = request.path_params.get("id", "")
from core.site_api import get_user_credential_fields, get_user_plugin_names, get_user_site
site = await get_user_site(site_id, user_session["user_id"])
if site is None:
return RedirectResponse("/dashboard/sites?error=site_not_found", status_code=302)
accept_language = request.headers.get("accept-language")
query_lang = request.query_params.get("lang")
lang = detect_language(accept_language, query_lang)
t = get_translations(lang)
import json
plugin_fields = get_user_credential_fields()
plugin_names = get_user_plugin_names()
return templates.TemplateResponse(
"dashboard/sites/edit.html",
{
"request": request,
"lang": lang,
"t": t,
"session": user_session,
"site": site,
"plugin_fields": plugin_fields,
"plugin_fields_json": json.dumps(plugin_fields),
"plugin_names": plugin_names,
"current_page": "my_sites",
},
)
async def api_update_site(request: Request) -> Response:
"""PATCH /api/sites/{id} — Update site URL and credentials."""
user_session, redirect = _require_user_session(request)
if redirect:
return JSONResponse({"error": "Unauthorized"}, status_code=401)
site_id = request.path_params.get("id", "")
try:
body = await request.json()
except Exception:
return JSONResponse({"error": "Invalid JSON"}, status_code=400)
from core.site_api import update_user_site
try:
site = await update_user_site(
site_id=site_id,
user_id=user_session["user_id"],
url=body.get("url", "").strip().rstrip("/"),
credentials=body.get("credentials", {}),
)
return JSONResponse({"site": site, "message": "Site updated"})
except ValueError as e:
return JSONResponse({"error": str(e)}, status_code=400)
except RuntimeError as e:
logger.error("Site update failed (runtime): %s", e)
return JSONResponse(
{"error": "Site storage is not configured. Contact the administrator."},
status_code=503,
)
except Exception as e:
logger.error("Failed to update site: %s", e, exc_info=True)
return JSONResponse({"error": "Internal error"}, status_code=500)
async def api_create_key(request: Request) -> Response: async def api_create_key(request: Request) -> Response:
"""POST /api/keys — Create a new user API key.""" """POST /api/keys — Create a new user API key."""
user_session, redirect = _require_user_session(request) user_session, redirect = _require_user_session(request)
@@ -3090,6 +3174,7 @@ def register_dashboard_routes(mcp):
# Site Management routes (E.3) # Site Management routes (E.3)
mcp.custom_route("/dashboard/sites", methods=["GET"])(dashboard_sites_list) mcp.custom_route("/dashboard/sites", methods=["GET"])(dashboard_sites_list)
mcp.custom_route("/dashboard/sites/add", methods=["GET"])(dashboard_sites_add) mcp.custom_route("/dashboard/sites/add", methods=["GET"])(dashboard_sites_add)
mcp.custom_route("/dashboard/sites/{id}/edit", methods=["GET"])(dashboard_sites_edit)
mcp.custom_route("/dashboard/connect", methods=["GET"])(dashboard_connect_page) mcp.custom_route("/dashboard/connect", methods=["GET"])(dashboard_connect_page)
# Site Management API (E.3) # Site Management API (E.3)
@@ -3097,6 +3182,7 @@ def register_dashboard_routes(mcp):
mcp.custom_route("/api/sites", methods=["POST"])(api_create_site) mcp.custom_route("/api/sites", methods=["POST"])(api_create_site)
mcp.custom_route("/api/sites/{id}/test", methods=["POST"])(api_test_site) mcp.custom_route("/api/sites/{id}/test", methods=["POST"])(api_test_site)
mcp.custom_route("/api/sites/{id}", methods=["DELETE"])(api_delete_site) mcp.custom_route("/api/sites/{id}", methods=["DELETE"])(api_delete_site)
mcp.custom_route("/api/sites/{id}", methods=["PATCH"])(api_update_site)
# User API Key routes (E.3) # User API Key routes (E.3)
mcp.custom_route("/api/keys", methods=["GET"])(api_list_keys) mcp.custom_route("/api/keys", methods=["GET"])(api_list_keys)

View File

@@ -530,6 +530,30 @@ class Database:
(status, status_msg, site_id), (status, status_msg, site_id),
) )
async def update_site_credentials(
self,
site_id: str,
user_id: str,
url: str,
credentials: bytes,
) -> bool:
"""Update URL and credentials for an existing site.
Args:
site_id: Site UUID.
user_id: Owner's UUID (for tenant isolation).
url: New base URL for the site.
credentials: New AES-256-GCM encrypted credentials blob.
Returns:
True if a row was updated, False if site not found or not owned by user.
"""
cursor = await self.execute(
"UPDATE sites SET url = ?, credentials = ?, status = 'pending' WHERE id = ? AND user_id = ?",
(url, credentials, site_id, user_id),
)
return cursor.rowcount > 0
async def get_site_by_alias(self, user_id: str, alias: str) -> dict[str, Any] | None: async def get_site_by_alias(self, user_id: str, alias: str) -> dict[str, Any] | None:
"""Get a site by user ID and alias. """Get a site by user ID and alias.

View File

@@ -108,14 +108,34 @@ PLUGIN_CREDENTIAL_FIELDS: dict[str, list[dict[str, Any]]] = {
"label": "Service Role Key", "label": "Service Role Key",
"type": "password", "type": "password",
"required": True, "required": True,
"hint": "Supabase Dashboard → Settings → API → service_role key", "hint": (
"Supabase Dashboard → Settings → API → service_role key. "
"Note: On supabase.com cloud, postgres-meta tools "
"(list_tables, execute_sql, get_table_schema, etc.) are not available — "
"they only work on self-hosted Supabase."
),
}, },
{ {
"name": "anon_key", "name": "anon_key",
"label": "Anon Key", "label": "Anon Key (Optional)",
"type": "password", "type": "password",
"required": True, "required": False,
"hint": "Supabase Dashboard → Settings → API → anon key", "hint": (
"Supabase Dashboard → Settings → API → anon key. "
"Optional — if omitted, service_role_key is used for all calls."
),
},
{
"name": "meta_url",
"label": "postgres-meta URL (Optional)",
"type": "text",
"required": False,
"hint": (
"Only needed when /pg/ is not exposed through Kong (common on Coolify). "
"Enter the direct postgres-meta container URL, e.g.: "
"http://supabase-meta-<id>:8080 — "
"Leave blank to use the default Kong /pg/ route."
),
}, },
], ],
"openpanel": [ "openpanel": [
@@ -509,6 +529,99 @@ async def delete_user_site(site_id: str, user_id: str) -> bool:
return deleted return deleted
async def update_user_site(
site_id: str,
user_id: str,
url: str,
credentials: dict[str, str],
skip_validation: bool = False,
) -> dict[str, Any]:
"""Update URL and credentials for an existing site.
Password fields left blank are preserved from the existing encrypted credentials.
Re-validates the connection after update.
Args:
site_id: Site UUID.
user_id: Owner's UUID.
url: New base URL (required).
credentials: Credential dict — blank password fields keep their current value.
skip_validation: If True, skip connection test (for testing).
Returns:
The updated site dict (without decrypted credentials).
Raises:
ValueError: If site not found, validation fails, or connection test fails.
"""
from core.database import get_database
from core.encryption import get_credential_encryption
db = get_database()
# Verify site ownership
existing_site = await db.get_site(site_id, user_id)
if existing_site is None:
raise ValueError("Site not found")
plugin_type = existing_site["plugin_type"]
# Validate URL
url = url.strip().rstrip("/")
if not url or not (url.startswith("http://") or url.startswith("https://")):
raise ValueError("URL must start with http:// or https://")
# Merge new credentials with existing ones — blank fields keep existing values
encryptor = get_credential_encryption()
existing_credentials = encryptor.decrypt_credentials(existing_site["credentials"], site_id)
merged: dict[str, str] = dict(existing_credentials)
for key, value in credentials.items():
# Only override if the new value is non-empty
if value and value.strip():
merged[key] = value.strip()
# Blank value for a non-required field (e.g. meta_url) → explicitly clear it
else:
field_defs = {f["name"]: f for f in PLUGIN_CREDENTIAL_FIELDS.get(plugin_type, [])}
if key in field_defs and not field_defs[key].get("required", True):
merged[key] = ""
# Strip empty optional values before storing (keep storage clean)
merged = {k: v for k, v in merged.items() if v}
# Validate required fields are still present
valid, errors = validate_credentials(plugin_type, merged)
if not valid:
raise ValueError(f"Missing required credentials: {', '.join(errors)}")
# Test connection
if not skip_validation:
ok, msg = await validate_site_connection(plugin_type, url, merged)
if not ok:
raise ValueError(f"Connection test failed: {msg}")
# Encrypt merged credentials
encrypted = encryptor.encrypt_credentials(merged, site_id)
# Persist
updated = await db.update_site_credentials(site_id, user_id, url, encrypted)
if not updated:
raise RuntimeError(f"Failed to update site {site_id}")
# Mark active after successful connection test
status_msg = "Connection verified" if not skip_validation else "Updated (not tested)"
await db.update_site_status(site_id, "active", status_msg, user_id=user_id)
result = await db.get_site(site_id, user_id)
if result is None:
raise RuntimeError(f"Failed to read back updated site {site_id}")
site_dict = dict(result)
site_dict.pop("credentials", None)
logger.info("Updated site %s (%s) for user %s", site_id, plugin_type, user_id)
return site_dict
async def test_site_connection(site_id: str, user_id: str) -> tuple[bool, str]: async def test_site_connection(site_id: str, user_id: str) -> tuple[bool, str]:
"""Test connectivity to an existing site. """Test connectivity to an existing site.

View File

@@ -0,0 +1,134 @@
{% extends "dashboard/base.html" %}
{% block title %}{{ t.edit_site }} - MCP Hub{% endblock %}
{% block page_title %}{{ t.edit_site }}{% endblock %}
{% block content %}
<div class="max-w-2xl mx-auto space-y-6">
<div class="flex items-center gap-4 mb-6">
<a href="/dashboard/sites{% if lang and lang != 'en' %}?lang={{ lang }}{% endif %}"
class="text-gray-500 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white transition-colors">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 19l-7-7 7-7" />
</svg>
</a>
<h2 class="text-xl font-semibold text-gray-900 dark:text-white">{{ t.edit_site }}: {{ site.alias }}</h2>
</div>
<!-- Info notice about password fields -->
<div class="bg-blue-50 dark:bg-blue-500/10 border border-blue-200 dark:border-blue-500/30 text-blue-700 dark:text-blue-300 px-4 py-3 rounded-lg text-sm">
{{ t.keep_existing }}
</div>
<!-- Error display -->
<div id="error-msg" class="hidden bg-red-50 dark:bg-red-500/20 border border-red-200 dark:border-red-500/50 text-red-700 dark:text-red-300 px-4 py-3 rounded-lg"></div>
<form id="edit-site-form" class="bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700 p-6 space-y-6">
<!-- Plugin Type (read-only) -->
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">{{ t.plugin_type }}</label>
<div class="w-full bg-gray-100 dark:bg-gray-700/50 border border-gray-200 dark:border-gray-600 rounded-lg px-4 py-2.5 text-gray-600 dark:text-gray-400">
{{ plugin_names.get(site.plugin_type, site.plugin_type) }}
</div>
</div>
<!-- URL -->
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">{{ t.site_url }}</label>
<input type="url" id="url" name="url" required value="{{ site.url }}"
class="w-full bg-gray-50 dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-lg px-4 py-2.5 text-gray-900 dark:text-white placeholder-gray-400 dark:placeholder-gray-500 focus:ring-2 focus:ring-blue-500 focus:border-transparent">
</div>
<!-- Alias (read-only) -->
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">{{ t.site_alias }}</label>
<div class="w-full bg-gray-100 dark:bg-gray-700/50 border border-gray-200 dark:border-gray-600 rounded-lg px-4 py-2.5 text-gray-600 dark:text-gray-400">
{{ site.alias }}
</div>
</div>
<!-- Credential Fields -->
<div id="credential-fields" class="space-y-4">
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">{{ t.credentials }}</label>
{% set fields = plugin_fields.get(site.plugin_type, []) %}
{% for field in fields %}
<div class="mb-4">
<label class="block text-xs font-medium text-gray-500 dark:text-gray-400 mb-1">
{{ field.label }}{% if field.required %} *{% endif %}
</label>
<input type="{{ field.type }}" name="cred_{{ field.name }}"
{% if field.type == 'password' %}placeholder="{{ t.keep_existing }}"{% endif %}
class="w-full bg-gray-50 dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-lg px-4 py-2.5 text-gray-900 dark:text-white placeholder-gray-400 dark:placeholder-gray-500 focus:ring-2 focus:ring-blue-500 focus:border-transparent">
{% if field.hint %}
<p class="text-xs text-gray-500 dark:text-gray-400 mt-1">{{ field.hint }}</p>
{% endif %}
</div>
{% endfor %}
</div>
<!-- Submit -->
<div class="flex items-center gap-4 pt-4">
<button type="submit" id="submit-btn"
class="btn-primary px-6 py-2.5 rounded-lg text-white font-medium disabled:opacity-50">
{{ t.save }}
</button>
<a href="/dashboard/sites{% if lang and lang != 'en' %}?lang={{ lang }}{% endif %}"
class="text-gray-500 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white transition-colors">
{{ t.cancel }}
</a>
</div>
</form>
</div>
{% endblock %}
{% block scripts %}
<script>
const siteId = "{{ site.id }}";
const pluginType = "{{ site.plugin_type }}";
const pluginFields = {{ plugin_fields_json | safe }};
document.getElementById('edit-site-form').addEventListener('submit', async (e) => {
e.preventDefault();
const btn = document.getElementById('submit-btn');
const errorEl = document.getElementById('error-msg');
errorEl.classList.add('hidden');
btn.textContent = '{{ t.updating_site }}';
btn.disabled = true;
const url = document.getElementById('url').value;
// Collect credentials — only include non-empty values
const creds = {};
if (pluginFields[pluginType]) {
pluginFields[pluginType].forEach(field => {
const input = document.querySelector(`[name="cred_${field.name}"]`);
if (input) creds[field.name] = input.value;
});
}
try {
const resp = await fetch('/api/sites/' + siteId, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ url: url, credentials: creds }),
});
const data = await resp.json();
if (resp.ok) {
window.location.href = '/dashboard/sites?msg={{ t.site_updated }}{% if lang and lang != "en" %}&lang={{ lang }}{% endif %}';
} else {
errorEl.textContent = data.error || 'Failed to update site';
errorEl.classList.remove('hidden');
btn.textContent = '{{ t.save }}';
btn.disabled = false;
}
} catch (err) {
errorEl.textContent = 'Network error: ' + err.message;
errorEl.classList.remove('hidden');
btn.textContent = '{{ t.save }}';
btn.disabled = false;
}
});
</script>
{% endblock %}

View File

@@ -73,6 +73,10 @@
id="test-btn-{{ site.id }}"> id="test-btn-{{ site.id }}">
{{ t.test_connection }} {{ t.test_connection }}
</button> </button>
<a href="/dashboard/sites/{{ site.id }}/edit{% if lang and lang != 'en' %}?lang={{ lang }}{% endif %}"
class="text-sm text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white">
{{ t.edit }}
</a>
<button onclick="deleteSite('{{ site.id }}')" <button onclick="deleteSite('{{ site.id }}')"
class="text-sm text-red-600 dark:text-red-400 hover:text-red-500 dark:hover:text-red-300"> class="text-sm text-red-600 dark:text-red-400 hover:text-red-500 dark:hover:text-red-300">
{{ t.delete }} {{ t.delete }}

View File

@@ -98,11 +98,26 @@ WORDPRESS_SITE1_ALIAS=mysite
# N8N_INSTANCE1_API_KEY=your_n8n_api_key # N8N_INSTANCE1_API_KEY=your_n8n_api_key
# N8N_INSTANCE1_ALIAS=myn8n # N8N_INSTANCE1_ALIAS=myn8n
# --- Supabase --- # --- Supabase (Self-Hosted or Cloud) ---
# SUPABASE_PROJECT1_URL=https://xxxxx.supabase.co # Works with both self-hosted Supabase and supabase.com cloud projects.
# SUPABASE_PROJECT1_API_KEY=your_supabase_api_key # For cloud: URL is https://xxxx.supabase.co
# SUPABASE_PROJECT1_SERVICE_ROLE=your_service_role_key # For self-hosted: URL is your Kong gateway (e.g., http://your-server:8000)
#
# SERVICE_ROLE_KEY is required (bypasses RLS, full admin access).
# ANON_KEY is optional — if omitted, SERVICE_ROLE_KEY is used for all calls.
#
# Note: postgres-meta tools (list_tables, execute_sql, get_table_schema, etc.)
# require the /pg/ Kong route or a direct META_URL. These tools do NOT work
# on supabase.com cloud (postgres-meta is internal only on cloud).
# PostgREST/Auth/Storage/Functions tools work on both cloud and self-hosted.
#
# SUPABASE_PROJECT1_URL=https://xxxx.supabase.co
# SUPABASE_PROJECT1_SERVICE_ROLE_KEY=your_service_role_key
# SUPABASE_PROJECT1_ANON_KEY=your_anon_key
# SUPABASE_PROJECT1_ALIAS=mysupabase # SUPABASE_PROJECT1_ALIAS=mysupabase
#
# Optional: direct postgres-meta URL for self-hosted when /pg/ is not via Kong:
# SUPABASE_PROJECT1_META_URL=http://localhost:5555
# --- OpenPanel --- # --- OpenPanel ---
# OPENPANEL_INSTANCE1_URL=https://openpanel.example.com # OPENPANEL_INSTANCE1_URL=https://openpanel.example.com

View File

@@ -27,18 +27,30 @@ class SupabaseClient:
Uses JWT-based authentication with anon_key or service_role_key. Uses JWT-based authentication with anon_key or service_role_key.
""" """
def __init__(self, base_url: str, anon_key: str, service_role_key: str): def __init__(
self,
base_url: str,
anon_key: str,
service_role_key: str,
meta_url: str | None = None,
):
""" """
Initialize Supabase API client. Initialize Supabase API client.
Args: Args:
base_url: Supabase instance URL (Kong gateway) base_url: Supabase instance URL (Kong gateway or supabase.co)
anon_key: Public API key (RLS protected) anon_key: Public API key (RLS protected). Optional — if empty,
service_role_key: Admin API key (bypasses RLS) service_role_key is used for all calls.
service_role_key: Admin API key (bypasses RLS). Required.
meta_url: Optional direct postgres-meta URL (e.g. http://localhost:5555).
When provided, postgres-meta calls hit this URL directly instead of
the Kong /pg/ route. Useful when /pg/ is not exposed through Kong.
""" """
self.base_url = base_url.rstrip("/") self.base_url = base_url.rstrip("/")
self.anon_key = anon_key self.anon_key = anon_key
self.service_role_key = service_role_key self.service_role_key = service_role_key
# postgres-meta base: custom URL or Kong /pg/ prefix
self.meta_base_url = (meta_url or f"{self.base_url}/pg").rstrip("/")
# Initialize logger # Initialize logger
self.logger = logging.getLogger(f"SupabaseClient.{base_url}") self.logger = logging.getLogger(f"SupabaseClient.{base_url}")
@@ -56,7 +68,8 @@ class SupabaseClient:
Returns: Returns:
Dict: Headers with authentication Dict: Headers with authentication
""" """
key = self.service_role_key if use_service_role else self.anon_key # If anon_key is not configured, fall back to service_role_key for all calls
key = self.service_role_key if (use_service_role or not self.anon_key) else self.anon_key
headers = { headers = {
"apikey": key, "apikey": key,
@@ -70,15 +83,41 @@ class SupabaseClient:
return headers return headers
async def _head_request_headers(
self,
endpoint: str,
params: dict | None = None,
headers_override: dict | None = None,
use_service_role: bool = False,
base_url_override: str | None = None,
) -> dict[str, str]:
"""
Make HEAD request and return response headers.
Used for operations like count_rows where the result is in a response
header (Content-Range) rather than the body.
"""
url = f"{base_url_override or self.base_url}{endpoint}"
headers = self._get_headers(use_service_role, headers_override)
if params:
params = {k: v for k, v in params.items() if v is not None}
async with aiohttp.ClientSession() as session:
async with session.head(url, params=params or None, headers=headers) as response:
# Normalise to lowercase so callers can use consistent keys
# (aiohttp CIMultiDictProxy is case-insensitive but dict() is not)
return {k.lower(): v for k, v in response.headers.items()}
async def request( async def request(
self, self,
method: str, method: str,
endpoint: str, endpoint: str,
params: dict | None = None, params: dict | None = None,
json_data: dict | None = None, json_data: Any = None,
data: bytes | None = None, data: bytes | None = None,
headers_override: dict | None = None, headers_override: dict | None = None,
use_service_role: bool = False, use_service_role: bool = False,
base_url_override: str | None = None,
) -> Any: ) -> Any:
""" """
Make authenticated request to Supabase API. Make authenticated request to Supabase API.
@@ -87,10 +126,11 @@ class SupabaseClient:
method: HTTP method method: HTTP method
endpoint: API endpoint (with leading /) endpoint: API endpoint (with leading /)
params: Query parameters params: Query parameters
json_data: JSON body data json_data: JSON body data (dict or list)
data: Raw binary data (for file uploads) data: Raw binary data (for file uploads)
headers_override: Override/add headers headers_override: Override/add headers
use_service_role: Use service_role_key use_service_role: Use service_role_key
base_url_override: Override base URL (used for postgres-meta calls)
Returns: Returns:
API response API response
@@ -98,7 +138,7 @@ class SupabaseClient:
Raises: Raises:
Exception: On API errors Exception: On API errors
""" """
url = f"{self.base_url}{endpoint}" url = f"{base_url_override or self.base_url}{endpoint}"
headers = self._get_headers(use_service_role, headers_override) headers = self._get_headers(use_service_role, headers_override)
@@ -106,10 +146,10 @@ class SupabaseClient:
if data is not None: if data is not None:
headers.pop("Content-Type", None) headers.pop("Content-Type", None)
# Filter None values # Filter None values from params and dict json bodies
if params: if params:
params = {k: v for k, v in params.items() if v is not None} params = {k: v for k, v in params.items() if v is not None}
if json_data: if json_data and isinstance(json_data, dict):
json_data = {k: v for k, v in json_data.items() if v is not None} json_data = {k: v for k, v in json_data.items() if v is not None}
self.logger.debug(f"{method} {url}") self.logger.debug(f"{method} {url}")
@@ -167,18 +207,70 @@ class SupabaseClient:
def _extract_error_message(self, response_data: Any) -> str: def _extract_error_message(self, response_data: Any) -> str:
"""Extract error message from various response formats.""" """Extract error message from various response formats."""
if isinstance(response_data, dict): if isinstance(response_data, dict):
# PostgREST error format # PostgREST error format — includes code, details, hint
if "message" in response_data: if "message" in response_data:
return response_data["message"] parts = [response_data["message"]]
if response_data.get("code"):
parts.insert(0, f"[{response_data['code']}]")
if response_data.get("details"):
parts.append(f"Details: {response_data['details']}")
if response_data.get("detail"):
parts.append(f"Detail: {response_data['detail']}")
if response_data.get("hint"):
parts.append(f"Hint: {response_data['hint']}")
return " | ".join(parts)
# GoTrue error format # GoTrue error format
if "error_description" in response_data: if "error_description" in response_data:
return response_data["error_description"] return response_data["error_description"]
if "msg" in response_data: if "msg" in response_data:
return response_data["msg"] return response_data["msg"]
# postgres-meta error format — "error" key with optional PG fields
if "error" in response_data: if "error" in response_data:
return response_data["error"] parts = [str(response_data["error"])]
if response_data.get("code"):
parts.insert(0, f"[{response_data['code']}]")
if response_data.get("hint"):
parts.append(f"Hint: {response_data['hint']}")
if response_data.get("detail"):
parts.append(f"Detail: {response_data['detail']}")
if response_data.get("position"):
parts.append(f"Position: {response_data['position']}")
return " | ".join(parts)
return str(response_data) return str(response_data)
def _build_filter_params(self, filters: list[dict]) -> dict[str, str]:
"""
Convert a filter list to PostgREST query parameters.
Handles special cases:
- ``is`` operator with Python ``None`` → ``is.null``
- ``is`` operator with Python bool → ``is.true`` / ``is.false``
- ``in`` operator with a list → ``in.(a,b,c)``
- Any other operator with ``None`` value is skipped (prevents sending
a bare ``col=eq.None`` which PostgREST would reject).
"""
params: dict[str, str] = {}
for f in filters:
col = f.get("column")
op = f.get("operator", "eq")
val = f.get("value")
if not col:
continue
if val is None:
# Only ``is`` / ``not.is`` accept null
if op in ("is", "not.is"):
val = "null"
else:
continue
elif isinstance(val, bool):
# Convert Python bool to lowercase string for PostgREST
val = "true" if val else "false"
elif op == "in" and isinstance(val, list):
# PostgREST ``in`` format: status=in.(active,inactive)
val = f"({','.join(str(v) for v in val)})"
params[col] = f"{op}.{val}"
return params
# ===================== # =====================
# POSTGREST (Database) # POSTGREST (Database)
# ===================== # =====================
@@ -204,20 +296,15 @@ class SupabaseClient:
limit: Maximum rows limit: Maximum rows
offset: Offset for pagination offset: Offset for pagination
""" """
params = {"select": select, "limit": limit, "offset": offset} params: dict[str, Any] = {"select": select, "limit": limit, "offset": offset}
if order: if order:
params["order"] = order params["order"] = order
# Build filter query string
headers = {}
if filters: if filters:
for f in filters: params.update(self._build_filter_params(filters))
col = f.get("column")
op = f.get("operator", "eq") headers = {}
val = f.get("value")
if col and val is not None:
params[col] = f"{op}.{val}"
# Request single objects as array # Request single objects as array
headers["Accept"] = "application/json" headers["Accept"] = "application/json"
@@ -240,15 +327,18 @@ class SupabaseClient:
) -> list[dict]: ) -> list[dict]:
"""Insert rows into a table.""" """Insert rows into a table."""
headers = {"Prefer": "return=representation"} headers = {"Prefer": "return=representation"}
upsert_params: dict[str, str] = {}
if upsert: if upsert:
headers["Prefer"] = "return=representation,resolution=merge-duplicates" headers["Prefer"] = "return=representation,resolution=merge-duplicates"
if on_conflict: if on_conflict:
headers["on-conflict"] = on_conflict # PostgREST expects on_conflict as a query parameter, not a header
upsert_params["on_conflict"] = on_conflict
return await self.request( return await self.request(
"POST", "POST",
f"/rest/v1/{table}", f"/rest/v1/{table}",
params=upsert_params or None,
json_data=rows if isinstance(rows, list) else [rows], json_data=rows if isinstance(rows, list) else [rows],
headers_override=headers, headers_override=headers,
use_service_role=use_service_role, use_service_role=use_service_role,
@@ -258,14 +348,7 @@ class SupabaseClient:
self, table: str, data: dict, filters: list[dict], use_service_role: bool = False self, table: str, data: dict, filters: list[dict], use_service_role: bool = False
) -> list[dict]: ) -> list[dict]:
"""Update rows matching filters.""" """Update rows matching filters."""
params = {} params = self._build_filter_params(filters)
for f in filters:
col = f.get("column")
op = f.get("operator", "eq")
val = f.get("value")
if col and val is not None:
params[col] = f"{op}.{val}"
headers = {"Prefer": "return=representation"} headers = {"Prefer": "return=representation"}
return await self.request( return await self.request(
@@ -281,14 +364,7 @@ class SupabaseClient:
self, table: str, filters: list[dict], use_service_role: bool = False self, table: str, filters: list[dict], use_service_role: bool = False
) -> list[dict]: ) -> list[dict]:
"""Delete rows matching filters.""" """Delete rows matching filters."""
params = {} params = self._build_filter_params(filters)
for f in filters:
col = f.get("column")
op = f.get("operator", "eq")
val = f.get("value")
if col and val is not None:
params[col] = f"{op}.{val}"
headers = {"Prefer": "return=representation"} headers = {"Prefer": "return=representation"}
return await self.request( return await self.request(
@@ -313,94 +389,133 @@ class SupabaseClient:
async def count_rows( async def count_rows(
self, table: str, filters: list[dict] | None = None, use_service_role: bool = False self, table: str, filters: list[dict] | None = None, use_service_role: bool = False
) -> int: ) -> int:
"""Count rows in a table.""" """Count rows in a table using HEAD + Content-Range header."""
params = {"select": "count"} filter_params = self._build_filter_params(filters) if filters else {}
if filters: response_headers = await self._head_request_headers(
for f in filters:
col = f.get("column")
op = f.get("operator", "eq")
val = f.get("value")
if col and val is not None:
params[col] = f"{op}.{val}"
headers = {"Accept": "application/json", "Prefer": "count=exact"}
result = await self.request(
"HEAD",
f"/rest/v1/{table}", f"/rest/v1/{table}",
params=params, params=filter_params or None,
headers_override=headers,
use_service_role=use_service_role,
)
# Count is in Content-Range header for HEAD requests
# Fallback to query approach
result = await self.request(
"GET",
f"/rest/v1/{table}",
params={"select": "count", **{k: v for k, v in params.items() if k != "select"}},
headers_override={"Prefer": "count=exact"}, headers_override={"Prefer": "count=exact"},
use_service_role=use_service_role, use_service_role=use_service_role,
) )
if isinstance(result, list) and len(result) > 0: # PostgREST returns count in Content-Range: 0-N/TOTAL or */TOTAL
return result[0].get("count", 0) content_range = response_headers.get("content-range", "")
if "/" in content_range:
try:
total = content_range.split("/")[-1]
if total != "*":
return int(total)
except (ValueError, IndexError):
pass
return 0 return 0
# ===================== # =====================
# POSTGRES-META (Admin) # POSTGRES-META (Admin)
# ===================== # =====================
# All postgres-meta requests use self.meta_base_url (default: {base_url}/pg).
# Set META_URL env var for direct postgres-meta access (e.g., http://host:5555).
# Note: postgres-meta /columns, /policies, /triggers do NOT support filtering
# by table_name as a query param — we filter the results in Python instead.
async def list_tables(self, schema: str = "public") -> list[dict]: async def list_tables(self, schema: str = "public") -> list[dict]:
"""List all tables in a schema.""" """List all tables via postgres-meta."""
return await self.request( return await self.request(
"GET", "/pg/tables", params={"include_system_schemas": "false"}, use_service_role=True "GET",
"/tables",
params={"include_system_schemas": "false"},
use_service_role=True,
base_url_override=self.meta_base_url,
) )
async def get_table_schema(self, table: str, schema: str = "public") -> dict: async def get_table_schema(self, table: str, schema: str = "public") -> dict:
"""Get table schema/columns.""" """Get table schema/columns via postgres-meta, filtered by table+schema in Python."""
columns = await self.request( all_columns = await self.request(
"GET", "/pg/columns", params={"table_name": table}, use_service_role=True "GET",
"/columns",
params={"include_system_schemas": "false"},
use_service_role=True,
base_url_override=self.meta_base_url,
) )
# postgres-meta /columns does not support table_name filtering — filter here
columns = [
col
for col in (all_columns if isinstance(all_columns, list) else [])
if col.get("table") == table and col.get("schema") == schema
]
return {"table": table, "schema": schema, "columns": columns} return {"table": table, "schema": schema, "columns": columns}
async def list_schemas(self) -> list[dict]: async def list_schemas(self) -> list[dict]:
"""List all database schemas.""" """List all database schemas via postgres-meta."""
return await self.request("GET", "/pg/schemas", use_service_role=True) return await self.request(
"GET",
"/schemas",
use_service_role=True,
base_url_override=self.meta_base_url,
)
async def list_extensions(self) -> list[dict]: async def list_extensions(self) -> list[dict]:
"""List installed extensions.""" """List installed extensions via postgres-meta."""
return await self.request("GET", "/pg/extensions", use_service_role=True) return await self.request(
"GET",
"/extensions",
use_service_role=True,
base_url_override=self.meta_base_url,
)
async def list_policies(self, table: str | None = None) -> list[dict]: async def list_policies(self, table: str | None = None) -> list[dict]:
"""List RLS policies.""" """List RLS policies via postgres-meta, optionally filtered by table name."""
params = {} all_policies = await self.request(
if table: "GET",
params["table_name"] = table "/policies",
return await self.request("GET", "/pg/policies", params=params, use_service_role=True) use_service_role=True,
base_url_override=self.meta_base_url,
)
# postgres-meta /policies does not support table_name filtering — filter here
if table and isinstance(all_policies, list):
return [p for p in all_policies if p.get("table") == table]
return all_policies
async def list_roles(self) -> list[dict]: async def list_roles(self) -> list[dict]:
"""List database roles.""" """List database roles via postgres-meta."""
return await self.request("GET", "/pg/roles", use_service_role=True) return await self.request(
"GET",
"/roles",
use_service_role=True,
base_url_override=self.meta_base_url,
)
async def list_triggers(self, table: str | None = None) -> list[dict]: async def list_triggers(self, table: str | None = None) -> list[dict]:
"""List triggers.""" """List triggers via postgres-meta, optionally filtered by table name."""
params = {} all_triggers = await self.request(
if table: "GET",
params["table_name"] = table "/triggers",
return await self.request("GET", "/pg/triggers", params=params, use_service_role=True) use_service_role=True,
base_url_override=self.meta_base_url,
)
# postgres-meta /triggers does not support table_name filtering — filter here
if table and isinstance(all_triggers, list):
return [t for t in all_triggers if t.get("table") == table]
return all_triggers
async def list_functions(self, schema: str = "public") -> list[dict]: async def list_functions(self, schema: str = "public") -> list[dict]:
"""List database functions.""" """List database functions via postgres-meta."""
return await self.request( return await self.request(
"GET", "/pg/functions", params={"schema": schema}, use_service_role=True "GET",
"/functions",
params={"schema": schema},
use_service_role=True,
base_url_override=self.meta_base_url,
) )
async def execute_sql(self, query: str) -> Any: async def execute_sql(self, query: str) -> Any:
"""Execute raw SQL query.""" """Execute raw SQL query via postgres-meta."""
return await self.request( return await self.request(
"POST", "/pg/query", json_data={"query": query}, use_service_role=True "POST",
"/query",
json_data={"query": query},
use_service_role=True,
base_url_override=self.meta_base_url,
) )
# ===================== # =====================
@@ -655,8 +770,13 @@ class SupabaseClient:
await self.request("GET", "/rest/v1/", use_service_role=True) await self.request("GET", "/rest/v1/", use_service_role=True)
results["services"]["postgrest"] = "ok" results["services"]["postgrest"] = "ok"
except Exception as e: except Exception as e:
results["services"]["postgrest"] = f"error: {str(e)}" # A 404 on the PostgREST root still confirms connectivity — some versions
results["healthy"] = False # return 404 for the root endpoint while working normally for table queries.
if "status 404" in str(e).lower() or "404" in str(e):
results["services"]["postgrest"] = "ok"
else:
results["services"]["postgrest"] = f"error: {str(e)}"
results["healthy"] = False
# Check GoTrue # Check GoTrue
try: try:

View File

@@ -468,11 +468,15 @@ async def query_table(
use_service_role=use_service_role, use_service_role=use_service_role,
) )
row_count = len(result) if isinstance(result, list) else 1
return json.dumps( return json.dumps(
{ {
"success": True, "success": True,
"table": table, "table": table,
"count": len(result) if isinstance(result, list) else 1, # "returned" = rows in this page; use count_rows tool for total
"returned": row_count,
# True when returned == limit, meaning there are likely more rows
"has_more": row_count == limit,
"data": result, "data": result,
}, },
indent=2, indent=2,
@@ -500,16 +504,20 @@ async def insert_rows(
use_service_role=use_service_role, use_service_role=use_service_role,
) )
return json.dumps( affected = len(result) if isinstance(result, list) else 1
{ response: dict = {
"success": True, "success": True,
"table": table, "table": table,
"inserted": len(result) if isinstance(result, list) else 1, "inserted" if not upsert else "affected": affected,
"data": result, "data": result,
}, }
indent=2, if upsert:
ensure_ascii=False, response["operation"] = "upsert"
) response["note"] = (
"PostgREST does not distinguish inserted vs updated rows in upsert mode. "
"'affected' = total rows processed (inserted + updated)."
)
return json.dumps(response, indent=2, ensure_ascii=False)
except Exception as e: except Exception as e:
return json.dumps({"success": False, "error": str(e)}, indent=2, ensure_ascii=False) return json.dumps({"success": False, "error": str(e)}, indent=2, ensure_ascii=False)
@@ -827,7 +835,16 @@ async def execute_sql(client: SupabaseClient, query: str) -> str:
ensure_ascii=False, ensure_ascii=False,
) )
except Exception as e: except Exception as e:
return json.dumps({"success": False, "error": str(e)}, indent=2, ensure_ascii=False) error_str = str(e)
# Provide an actionable hint when postgres-meta is unreachable
if "status 404" in error_str or "status 502" in error_str or "Cannot connect" in error_str:
error_str = (
f"postgres-meta unreachable at {client.meta_base_url}. "
"If /pg/ is not exposed via Kong, set META_URL env var to the direct "
"postgres-meta container URL (e.g., http://supabase-meta:8080). "
f"Original: {error_str[:300]}"
)
return json.dumps({"success": False, "error": error_str}, indent=2, ensure_ascii=False)
async def get_table_indexes(client: SupabaseClient, table: str, schema: str = "public") -> str: async def get_table_indexes(client: SupabaseClient, table: str, schema: str = "public") -> str:

View File

@@ -41,8 +41,13 @@ class SupabasePlugin(BasePlugin):
@staticmethod @staticmethod
def get_required_config_keys() -> list[str]: def get_required_config_keys() -> list[str]:
"""Return required configuration keys""" """Return required configuration keys.
return ["url", "anon_key", "service_role_key"]
anon_key is optional — if not provided, service_role_key is used for all
calls (including those that would normally use anon_key). This simplifies
configuration for admin/backend use cases where RLS enforcement is not needed.
"""
return ["url", "service_role_key"]
def __init__(self, config: dict[str, Any], project_id: str | None = None): def __init__(self, config: dict[str, Any], project_id: str | None = None):
""" """
@@ -50,9 +55,10 @@ class SupabasePlugin(BasePlugin):
Args: Args:
config: Configuration dictionary containing: config: Configuration dictionary containing:
- url: Supabase instance URL (Kong gateway) - url: Supabase instance URL (Kong gateway or supabase.co)
- anon_key: Anonymous key (RLS protected) - service_role_key: Admin API key (bypasses RLS). Required.
- service_role_key: Service role key (bypasses RLS) - anon_key: Public API key (RLS protected). Optional.
- meta_url: Direct postgres-meta URL. Optional.
project_id: Optional project ID (auto-generated if not provided) project_id: Optional project ID (auto-generated if not provided)
""" """
super().__init__(config, project_id=project_id) super().__init__(config, project_id=project_id)
@@ -60,8 +66,9 @@ class SupabasePlugin(BasePlugin):
# Create Supabase API client # Create Supabase API client
self.client = SupabaseClient( self.client = SupabaseClient(
base_url=config["url"], base_url=config["url"],
anon_key=config["anon_key"], anon_key=config.get("anon_key", ""),
service_role_key=config["service_role_key"], service_role_key=config["service_role_key"],
meta_url=config.get("meta_url"),
) )
@staticmethod @staticmethod

View File

@@ -67,6 +67,7 @@ from core.dashboard.routes import (
api_list_keys, api_list_keys,
api_list_sites, api_list_sites,
api_test_site, api_test_site,
api_update_site,
# E.2: OAuth Social Login routes # E.2: OAuth Social Login routes
auth_callback, auth_callback,
auth_login_page, auth_login_page,
@@ -107,6 +108,7 @@ from core.dashboard.routes import (
dashboard_settings_page, dashboard_settings_page,
# E.3: Site Management pages # E.3: Site Management pages
dashboard_sites_add, dashboard_sites_add,
dashboard_sites_edit,
dashboard_sites_list, dashboard_sites_list,
# Bug C: User OAuth client routes # Bug C: User OAuth client routes
dashboard_user_oauth_clients_create, dashboard_user_oauth_clients_create,
@@ -4792,6 +4794,7 @@ def create_multi_endpoint_app(transport: str = "streamable-http"):
Route("/dashboard/logout", dashboard_logout, methods=["GET", "POST"]), Route("/dashboard/logout", dashboard_logout, methods=["GET", "POST"]),
Route("/dashboard/profile", dashboard_profile_page, methods=["GET"]), Route("/dashboard/profile", dashboard_profile_page, methods=["GET"]),
Route("/dashboard/sites/add", dashboard_sites_add, methods=["GET"]), Route("/dashboard/sites/add", dashboard_sites_add, methods=["GET"]),
Route("/dashboard/sites/{id}/edit", dashboard_sites_edit, methods=["GET"]),
Route("/dashboard/sites", dashboard_sites_list, methods=["GET"]), Route("/dashboard/sites", dashboard_sites_list, methods=["GET"]),
# Bug C: User OAuth client routes (must be before /dashboard/connect) # Bug C: User OAuth client routes (must be before /dashboard/connect)
Route( Route(
@@ -4860,6 +4863,7 @@ def create_multi_endpoint_app(transport: str = "streamable-http"):
Route("/api/sites", api_create_site, methods=["POST"]), Route("/api/sites", api_create_site, methods=["POST"]),
Route("/api/sites/{id}/test", api_test_site, methods=["POST"]), Route("/api/sites/{id}/test", api_test_site, methods=["POST"]),
Route("/api/sites/{id}", api_delete_site, methods=["DELETE"]), Route("/api/sites/{id}", api_delete_site, methods=["DELETE"]),
Route("/api/sites/{id}", api_update_site, methods=["PATCH"]),
# User API Key routes (E.3) # User API Key routes (E.3)
Route("/api/keys", api_list_keys, methods=["GET"]), Route("/api/keys", api_list_keys, methods=["GET"]),
Route("/api/keys", api_create_key, methods=["POST"]), Route("/api/keys", api_create_key, methods=["POST"]),

Binary file not shown.

View File

@@ -1,4 +1,4 @@
=== SEO API Bridge === === Airano MCP SEO Meta Bridge ===
Contributors: airano Contributors: airano
Tags: seo, rest-api, rank-math, yoast, mcp Tags: seo, rest-api, rank-math, yoast, mcp
Requires at least: 5.0 Requires at least: 5.0
@@ -12,7 +12,7 @@ Exposes Rank Math SEO and Yoast SEO meta fields via WordPress REST API for use w
== Description == == Description ==
SEO API Bridge is a WordPress plugin that exposes Rank Math SEO and Yoast SEO meta fields via dedicated REST API endpoints. This enables MCP servers, AI agents, and other applications to read and write SEO metadata programmatically for posts, pages, and WooCommerce products. Airano MCP SEO Bridge is a WordPress plugin that exposes Rank Math SEO and Yoast SEO meta fields via dedicated REST API endpoints. This enables MCP servers, AI agents, and other applications to read and write SEO metadata programmatically for posts, pages, and WooCommerce products.
**Features:** **Features:**
@@ -26,16 +26,16 @@ SEO API Bridge is a WordPress plugin that exposes Rank Math SEO and Yoast SEO me
**REST API Endpoints:** **REST API Endpoints:**
* `GET/POST /wp-json/seo-api-bridge/v1/posts/{id}/seo` — Post SEO data * `GET/POST /wp-json/airano-mcp-seo-bridge/v1/posts/{id}/seo` — Post SEO data
* `GET/POST /wp-json/seo-api-bridge/v1/pages/{id}/seo` — Page SEO data * `GET/POST /wp-json/airano-mcp-seo-bridge/v1/pages/{id}/seo` — Page SEO data
* `GET/POST /wp-json/seo-api-bridge/v1/products/{id}/seo` — Product SEO data (WooCommerce) * `GET/POST /wp-json/airano-mcp-seo-bridge/v1/products/{id}/seo` — Product SEO data (WooCommerce)
* `GET /wp-json/seo-api-bridge/v1/status` — Plugin status and SEO detection * `GET /wp-json/airano-mcp-seo-bridge/v1/status` — Plugin status and SEO detection
**Designed for [MCP Hub](https://github.com/airano-ir/mcphub)** — the AI-native management hub for WordPress and self-hosted services. **Designed for [MCP Hub](https://github.com/airano-ir/mcphub)** — the AI-native management hub for WordPress and self-hosted services.
== Installation == == Installation ==
1. Upload the `seo-api-bridge` folder to `/wp-content/plugins/` 1. Upload the `airano-mcp-seo-bridge` folder to `/wp-content/plugins/`
2. Activate the plugin through the 'Plugins' menu in WordPress 2. Activate the plugin through the 'Plugins' menu in WordPress
3. Ensure either Rank Math SEO or Yoast SEO is active 3. Ensure either Rank Math SEO or Yoast SEO is active
4. The REST API endpoints are now available 4. The REST API endpoints are now available

View File

@@ -1,6 +1,6 @@
<?php <?php
/** /**
* Plugin Name: SEO API Bridge * Plugin Name: Airano MCP SEO Meta Bridge
* Plugin URI: https://github.com/airano-ir/mcphub * Plugin URI: https://github.com/airano-ir/mcphub
* Description: Exposes Rank Math SEO and Yoast SEO meta fields via WordPress REST API for use with MCP servers and AI agents. Supports posts, pages, and WooCommerce products with full CRUD operations. * Description: Exposes Rank Math SEO and Yoast SEO meta fields via WordPress REST API for use with MCP servers and AI agents. Supports posts, pages, and WooCommerce products with full CRUD operations.
* Version: 1.3.0 * Version: 1.3.0
@@ -9,7 +9,7 @@
* License: GPL-2.0-or-later * License: GPL-2.0-or-later
* Requires at least: 5.0 * Requires at least: 5.0
* Requires PHP: 7.4 * Requires PHP: 7.4
* Text Domain: seo-api-bridge * Text Domain: airano-mcp-seo-bridge
* *
* Changelog: * Changelog:
* 1.3.0 - Added REST API endpoints for posts, pages, and products (GET/POST operations) * 1.3.0 - Added REST API endpoints for posts, pages, and products (GET/POST operations)
@@ -24,7 +24,7 @@ if (!defined('ABSPATH')) {
} }
/** /**
* SEO API Bridge Main Class * Airano MCP SEO Meta Bridge Main Class
*/ */
class SEO_API_Bridge { class SEO_API_Bridge {
@@ -57,7 +57,7 @@ class SEO_API_Bridge {
*/ */
public function register_rest_routes() { public function register_rest_routes() {
// Status endpoint // Status endpoint
register_rest_route('seo-api-bridge/v1', '/status', [ register_rest_route('airano-mcp-seo-bridge/v1', '/status', [
'methods' => 'GET', 'methods' => 'GET',
'callback' => [$this, 'get_status'], 'callback' => [$this, 'get_status'],
'permission_callback' => function() { 'permission_callback' => function() {
@@ -66,7 +66,7 @@ class SEO_API_Bridge {
]); ]);
// Post SEO endpoints // Post SEO endpoints
register_rest_route('seo-api-bridge/v1', '/posts/(?P<id>\d+)/seo', [ register_rest_route('airano-mcp-seo-bridge/v1', '/posts/(?P<id>\d+)/seo', [
[ [
'methods' => 'GET', 'methods' => 'GET',
'callback' => [$this, 'get_post_seo'], 'callback' => [$this, 'get_post_seo'],
@@ -98,7 +98,7 @@ class SEO_API_Bridge {
]); ]);
// Page SEO endpoints // Page SEO endpoints
register_rest_route('seo-api-bridge/v1', '/pages/(?P<id>\d+)/seo', [ register_rest_route('airano-mcp-seo-bridge/v1', '/pages/(?P<id>\d+)/seo', [
[ [
'methods' => 'GET', 'methods' => 'GET',
'callback' => [$this, 'get_page_seo'], 'callback' => [$this, 'get_page_seo'],
@@ -130,7 +130,7 @@ class SEO_API_Bridge {
]); ]);
// Product SEO endpoints (WooCommerce) // Product SEO endpoints (WooCommerce)
register_rest_route('seo-api-bridge/v1', '/products/(?P<id>\d+)/seo', [ register_rest_route('airano-mcp-seo-bridge/v1', '/products/(?P<id>\d+)/seo', [
[ [
'methods' => 'GET', 'methods' => 'GET',
'callback' => [$this, 'get_product_seo'], 'callback' => [$this, 'get_product_seo'],
@@ -170,7 +170,7 @@ class SEO_API_Bridge {
$yoast_active = $this->is_yoast_active(); $yoast_active = $this->is_yoast_active();
$response = [ $response = [
'plugin' => 'SEO API Bridge', 'plugin' => 'Airano MCP SEO Meta Bridge',
'version' => self::VERSION, 'version' => self::VERSION,
'active' => true, 'active' => true,
'seo_plugins' => [ 'seo_plugins' => [
@@ -202,7 +202,7 @@ class SEO_API_Bridge {
if ($rank_math_active) $active_plugins[] = 'Rank Math SEO'; if ($rank_math_active) $active_plugins[] = 'Rank Math SEO';
if ($yoast_active) $active_plugins[] = 'Yoast SEO'; if ($yoast_active) $active_plugins[] = 'Yoast SEO';
return 'SEO API Bridge is active and working with ' . implode(' and ', $active_plugins) . '.'; return 'Airano MCP SEO Meta Bridge is active and working with ' . implode(' and ', $active_plugins) . '.';
} }
/** /**
@@ -438,8 +438,12 @@ class SEO_API_Bridge {
private function register_fields($fields) { private function register_fields($fields) {
foreach ($this->supported_post_types as $post_type) { foreach ($this->supported_post_types as $post_type) {
foreach ($fields as $meta_key => $args) { foreach ($fields as $meta_key => $args) {
$show_in_rest = $args['type'] === 'array'
? [ 'schema' => [ 'type' => 'array', 'items' => [ 'type' => 'string' ] ] ]
: true;
register_post_meta($post_type, $meta_key, [ register_post_meta($post_type, $meta_key, [
'show_in_rest' => true, 'show_in_rest' => $show_in_rest,
'single' => $args['single'], 'single' => $args['single'],
'type' => $args['type'], 'type' => $args['type'],
'description' => $args['description'], 'description' => $args['description'],
@@ -686,7 +690,7 @@ class SEO_API_Bridge {
if (!$rank_math_active && !$yoast_active) { if (!$rank_math_active && !$yoast_active) {
echo '<div class="notice notice-warning is-dismissible">'; echo '<div class="notice notice-warning is-dismissible">';
echo '<p><strong>SEO API Bridge:</strong> ' . esc_html__( 'Neither Rank Math SEO nor Yoast SEO is detected. Please install and activate one of these plugins to enable SEO meta field access via REST API.', 'seo-api-bridge' ) . '</p>'; echo '<p><strong>Airano MCP SEO Meta Bridge:</strong> ' . esc_html__( 'Neither Rank Math SEO nor Yoast SEO is detected. Please install and activate one of these plugins to enable SEO meta field access via REST API.', 'airano-mcp-seo-bridge' ) . '</p>';
echo '</div>'; echo '</div>';
} else { } else {
$active_plugins = []; $active_plugins = [];
@@ -696,11 +700,11 @@ class SEO_API_Bridge {
$supported_types = implode(', ', $this->supported_post_types); $supported_types = implode(', ', $this->supported_post_types);
echo '<div class="notice notice-success is-dismissible">'; echo '<div class="notice notice-success is-dismissible">';
echo '<p><strong>SEO API Bridge v' . esc_html( self::VERSION ) . ':</strong> ' . esc_html( sprintf( 'Successfully registered meta fields for %s.', implode( ' and ', $active_plugins ) ) ) . '</p>'; echo '<p><strong>Airano MCP SEO Meta Bridge v' . esc_html( self::VERSION ) . ':</strong> ' . esc_html( sprintf( 'Successfully registered meta fields for %s.', implode( ' and ', $active_plugins ) ) ) . '</p>';
echo '<p><strong>' . esc_html__( 'Supported post types:', 'seo-api-bridge' ) . '</strong> ' . esc_html( $supported_types ) . '</p>'; echo '<p><strong>' . esc_html__( 'Supported post types:', 'airano-mcp-seo-bridge' ) . '</strong> ' . esc_html( $supported_types ) . '</p>';
if ($woocommerce_active) { if ($woocommerce_active) {
echo '<p><strong>WooCommerce:</strong> ' . esc_html__( 'Detected and supported. Product SEO fields are available via REST API.', 'seo-api-bridge' ) . '</p>'; echo '<p><strong>WooCommerce:</strong> ' . esc_html__( 'Detected and supported. Product SEO fields are available via REST API.', 'airano-mcp-seo-bridge' ) . '</p>';
} }
echo '</div>'; echo '</div>';
} }

Binary file not shown.