9 Commits

Author SHA1 Message Date
airano
f6dbbeaab0 sync: v3.2.0 pre-release audit — namespace fix, config types, docs update
Some checks failed
Release / Test before release (push) Has been cancelled
Release / Publish to PyPI (push) Has been cancelled
Release / Publish to Docker Hub (push) Has been cancelled
Release / Create GitHub Release (push) Has been cancelled
- fix(seo): REST API namespace seo-api-bridge/v1 → airano-mcp-seo-bridge/v1
- fix(config): add transport type field (streamableHttp/http) to config snippets
- feat(dashboard): support section for OAuth users, connect page guidance
- docs: hosted option, test count 481, WP plugin link, OAuth env vars, per-user endpoints

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-11 04:23:45 +03:30
airano
c0d55f9061 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>
2026-03-11 03:21:28 +03:30
airano
a806671e2d fix(oauth): fix invalid_scope and invalid_token errors for user OAuth clients
Three bugs fixed for social-login users connecting Claude.ai via OAuth:

1. User OAuth clients now allow 'admin' scope by default — previously
   defaulted to ['read', 'write'], causing invalid_scope when Claude.ai
   requests 'admin' scope during authorization.

2. Fix JWT tokens with 'aud' claim being rejected by middleware — when
   Claude.ai sends RFC 8707 resource parameter, the issued JWT gets an
   aud claim. validate_access_token() now sets verify_aud=False to
   prevent PyJWT InvalidAudienceError from silently marking valid tokens
   as invalid in _is_valid_token().

3. Fix NameError in user_mcp_handler tools/call scope check — key_info
   was only defined in the mhu_ auth path but referenced in tools/call
   for both paths. Replaced with key_scopes variable set by both mhu_
   and JWT auth paths.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-26 00:37:43 +03:30
airano
4a5381d765 fix(server): register user OAuth client routes in server.py Route list (404 fix) 2026-02-25 23:54:49 +03:30
airano
fdb050454e feat: OAuth user fixes + dashboard UI improvements (v3.2.0)
- Bug A: Fix OAuth consent redirect loop (return_url path-relative)
- Bug B: Accept OAuth JWT tokens on /u/{user_id}/{alias}/mcp
- Bug C: Add user OAuth client CRUD (list/create/delete) + new page
- D-1: Sidebar border via Tailwind class, version footer in sidebar
- D-2: Remove broken setInterval targeting #stats-container
- D-3: 404.html purple primary colors + system dark mode support
- D-4: Fix invisible buttons (bg-gray-200 + text-white in light mode)
- D-5: Pin Alpine.js to 3.14.8, remove duplicate CSRF meta tag
- D-6: Fix invisible lang toggle button in settings (dark mode)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-25 23:27:59 +03:30
airano
ea51c10e03 chore: release v3.2.0 — version bump, changelog, test badge
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-24 21:52:13 +03:30
airano
479d0c7252 feat(oauth): Phase C — social login on OAuth consent screen
Enable dashboard users (GitHub/Google social login) to approve OAuth
consent without needing to create and paste an API key. Adds mhu_ user
key support, session-based consent with __session__ marker, social login
links on the consent page, and return URL redirect after login.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-24 21:13:17 +03:30
airano
d3bcb31053 feat(oauth): Phase B — claude.ai protocol compatibility
Add 3 features for claude.ai Connectors compatibility:

- FEATURE-3: client_secret_basic auth on token endpoint (RFC 6749 §2.3.1)
- FEATURE-2: Token revocation endpoint /oauth/revoke (RFC 7009)
- FEATURE-4: resource parameter support with JWT aud claim (RFC 8707)

23 new tests (481 total), all passing.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-24 20:49:23 +03:30
airano
1736779d69 fix: SUG-6 API key modal, SUG-4 project activity filter, WooCommerce health auth
- SUG-6: Replace auto-closing inline banner with persistent modal on Connect
  page — key stays visible until user clicks "Done"
- SUG-4: Filter health_metric_recorded/health_check from project detail activity
- WooCommerce: Accept consumer_key/consumer_secret credentials (preferred) with
  username/app_password fallback; fix health check to use WC endpoint and creds

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-24 07:16:05 +03:30
49 changed files with 2577 additions and 296 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

@@ -5,6 +5,26 @@ All notable changes to MCP Hub will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [3.2.0] — 2026-02-25
### Fixed
- **Bug A**: OAuth consent redirect loop — after GitHub/Google login, page now returns to consent screen instead of `/dashboard`
- **Bug B**: `/u/{user_id}/{alias}/mcp` endpoint now accepts OAuth JWT tokens (issued after social login) in addition to `mhu_` API keys
- **Bug C**: OAuth users can now create, list, and delete their own OAuth clients via the new `/dashboard/connect/oauth-clients` page
- **D-1**: Dashboard sidebar border uses Tailwind class instead of inline style; version shown in footer
- **D-2**: Removed broken `setInterval` in dashboard that targeted non-existent `#stats-container`
- **D-3**: 404 page uses purple primary colors and respects system dark mode preference
- **D-4**: Fixed invisible buttons (`bg-gray-200` + `text-white` in light mode) across api-keys, health, projects pages
- **D-5**: Pinned Alpine.js to 3.14.8; removed duplicate CSRF meta tag from `head_assets.html`
- **D-6**: Fixed invisible language toggle button in settings (dark mode)
### Added
- `OAuthClient.owner_user_id` field for per-user OAuth client isolation
- `dashboard_connect_page` now includes Claude.ai connection guide with endpoint URL and link to OAuth Clients
- New route: `GET /dashboard/connect/oauth-clients` — user's OAuth clients list
- New API: `POST /api/dashboard/user-oauth-clients/create` — create OAuth client for logged-in user
- New API: `DELETE /api/dashboard/user-oauth-clients/{client_id}` — delete own OAuth client
--- ---
## [3.1.0] - 2026-02-23 ## [3.1.0] - 2026-02-23

View File

@@ -179,7 +179,7 @@ Types: `feat`, `fix`, `docs`, `style`, `refactor`, `test`, `chore`
- Test files exist in both `tests/` (proper) and root directory (legacy `test_*.py`). Run `pytest tests/` for organized tests only. - Test files exist in both `tests/` (proper) and root directory (legacy `test_*.py`). Run `pytest tests/` for organized tests only.
- `server_multi.py` is the alternative multi-endpoint entry point; `server.py` is the primary - `server_multi.py` is the alternative multi-endpoint entry point; `server.py` is the primary
- `wordpress-plugin/` contains companion WP plugins (openpanel, seo-api-bridge) — these are PHP, not Python - `wordpress-plugin/` contains companion WP plugins (openpanel, airano-mcp-seo-bridge) — these are PHP, not Python
- `env.example` has "FUTURE" labels for Supabase/Gitea but both are fully implemented - `env.example` has "FUTURE" labels for Supabase/Gitea but both are fully implemented
- Dashboard templates live in `core/templates/` (included in pip package as `package_data`) - Dashboard templates live in `core/templates/` (included in pip package as `package_data`)
- `ruff` config uses top-level `select` key in pyproject.toml (not `[tool.ruff.lint]` nested format) - `ruff` config uses top-level `select` key in pyproject.toml (not `[tool.ruff.lint]` nested format)

View File

@@ -120,7 +120,7 @@ Then register in `plugins/__init__.py` and add tests.
core/ # Core system (auth, site manager, tool registry, dashboard) core/ # Core system (auth, site manager, tool registry, dashboard)
plugins/ # Plugin system (9 plugins, each with handlers + schemas) plugins/ # Plugin system (9 plugins, each with handlers + schemas)
core/templates/ # Jinja2 templates (dashboard + OAuth) core/templates/ # Jinja2 templates (dashboard + OAuth)
tests/ # Test suite (290 tests) tests/ # Test suite (481 tests)
scripts/ # Setup & deployment scripts scripts/ # Setup & deployment scripts
docs/ # Documentation docs/ # Documentation
``` ```

View File

@@ -4,6 +4,8 @@
596 tools across 9 plugins. Connect your sites, stores, repos, and databases — manage them all through Claude, ChatGPT, Cursor, or any MCP client. 596 tools across 9 plugins. Connect your sites, stores, repos, and databases — manage them all through Claude, ChatGPT, Cursor, or any MCP client.
> **Don't want to self-host?** Try the hosted instance at **[mcp.palebluedot.live](https://mcp.palebluedot.live)** — log in with GitHub or Google, add your sites, and connect your AI client in minutes.
## Quick Start ## Quick Start
### 1. Create a `.env` file ### 1. Create a `.env` file
@@ -91,6 +93,7 @@ Use the most specific endpoint for your use case:
| Endpoint | Tools | `site` param? | Best for | | Endpoint | Tools | `site` param? | Best for |
|----------|------:|:-------------:|----------| |----------|------:|:-------------:|----------|
| `/u/{user_id}/{alias}/mcp` | 22-100 | No (pre-scoped) | Hosted/OAuth users |
| `/project/{alias}/mcp` | 22-100 | No (pre-scoped) | Single-site workflow | | `/project/{alias}/mcp` | 22-100 | No (pre-scoped) | Single-site workflow |
| `/{plugin}/mcp` | 23-101 | Yes | Multi-site management | | `/{plugin}/mcp` | 23-101 | Yes | Multi-site management |
| `/mcp` | 596 | Yes | Admin & discovery only | | `/mcp` | 596 | Yes | Admin & discovery only |
@@ -131,7 +134,7 @@ docker compose up -d
| URL | Description | | URL | Description |
|-----|-------------| |-----|-------------|
| `http://localhost:8000/health` | Health check & status | | `http://localhost:8000/health` | Health check & status |
| `http://localhost:8000/dashboard` | Web dashboard (manage API keys, view sites, health) | | `http://localhost:8000/dashboard` | Web dashboard (manage sites, API keys, OAuth clients, health monitoring). Login via MASTER_API_KEY or GitHub/Google OAuth |
| `http://localhost:8000/mcp` | MCP endpoint (connect AI clients here) | | `http://localhost:8000/mcp` | MCP endpoint (connect AI clients here) |
## Environment Variables ## Environment Variables
@@ -146,11 +149,19 @@ docker compose up -d
| `WORDPRESS_SITE1_CONTAINER` | For WP-CLI | Docker container name of your WordPress site (enables cache/db/system tools) | | `WORDPRESS_SITE1_CONTAINER` | For WP-CLI | Docker container name of your WordPress site (enables cache/db/system tools) |
| `OAUTH_JWT_SECRET_KEY` | For OAuth | JWT secret for ChatGPT auto-registration (not needed for Claude/Cursor) | | `OAUTH_JWT_SECRET_KEY` | For OAuth | JWT secret for ChatGPT auto-registration (not needed for Claude/Cursor) |
| `OAUTH_BASE_URL` | For OAuth | Public URL of your server (not needed for Claude/Cursor) | | `OAUTH_BASE_URL` | For OAuth | Public URL of your server (not needed for Claude/Cursor) |
| `GITHUB_CLIENT_ID` | For Social Login | GitHub OAuth App client ID |
| `GITHUB_CLIENT_SECRET` | For Social Login | GitHub OAuth App client secret |
| `GOOGLE_CLIENT_ID` | For Social Login | Google OAuth client ID |
| `GOOGLE_CLIENT_SECRET` | For Social Login | Google OAuth client secret |
| `PUBLIC_URL` | For Social Login | Public URL for OAuth callbacks (e.g., `https://mcp.example.com`) |
| `ENCRYPTION_KEY` | For Multi-user | AES-256 key for encrypting user site credentials. Generate: `python -c "import os, base64; print(base64.b64encode(os.urandom(32)).decode())"` |
> **CONTAINER**: Required for WP-CLI tools (cache flush, database export, system info) and **all** WordPress Advanced tools. Find your container name: `docker ps --filter name=wordpress`. Also requires Docker socket mount. > **CONTAINER**: Required for WP-CLI tools (cache flush, database export, system info) and **all** WordPress Advanced tools. Find your container name: `docker ps --filter name=wordpress`. Also requires Docker socket mount.
> **OAuth**: Only needed for ChatGPT Remote MCP auto-registration. For Claude Desktop, Claude Code, Cursor, and VS Code — just use `MASTER_API_KEY` with Bearer token auth. > **OAuth**: Only needed for ChatGPT Remote MCP auto-registration. For Claude Desktop, Claude Code, Cursor, and VS Code — just use `MASTER_API_KEY` with Bearer token auth.
> **Social Login**: Enable GitHub/Google login for multi-user mode. Users can add sites via the dashboard and get personal MCP endpoints (`/u/{user_id}/{alias}/mcp`).
Add more sites with `SITE2`, `SITE3`, etc. See [full configuration guide](https://github.com/airano-ir/mcphub/blob/main/docs/getting-started.md). Add more sites with `SITE2`, `SITE3`, etc. See [full configuration guide](https://github.com/airano-ir/mcphub/blob/main/docs/getting-started.md).
## Supported Plugins ## Supported Plugins

View File

@@ -13,7 +13,7 @@ Connect your sites, stores, repos, and databases — manage them all through Cla
[![Python 3.11+](https://img.shields.io/badge/python-3.11+-3776ab.svg)](https://www.python.org/) [![Python 3.11+](https://img.shields.io/badge/python-3.11+-3776ab.svg)](https://www.python.org/)
[![PyPI](https://img.shields.io/pypi/v/mcphub-server.svg)](https://pypi.org/project/mcphub-server/) [![PyPI](https://img.shields.io/pypi/v/mcphub-server.svg)](https://pypi.org/project/mcphub-server/)
[![Docker](https://img.shields.io/docker/v/airano/mcphub?label=docker)](https://hub.docker.com/r/airano/mcphub) [![Docker](https://img.shields.io/docker/v/airano/mcphub?label=docker)](https://hub.docker.com/r/airano/mcphub)
[![Tests: 290 passing](https://img.shields.io/badge/tests-290%20passing-brightgreen.svg)]() [![Tests: 481 passing](https://img.shields.io/badge/tests-481%20passing-brightgreen.svg)]()
[![Tools: 596](https://img.shields.io/badge/tools-596-orange.svg)]() [![Tools: 596](https://img.shields.io/badge/tools-596-orange.svg)]()
[![CI](https://github.com/airano-ir/mcphub/actions/workflows/ci.yml/badge.svg)](https://github.com/airano-ir/mcphub/actions/workflows/ci.yml) [![CI](https://github.com/airano-ir/mcphub/actions/workflows/ci.yml/badge.svg)](https://github.com/airano-ir/mcphub/actions/workflows/ci.yml)
@@ -108,7 +108,20 @@ curl http://localhost:8000/health
Open the **web dashboard** in your browser: **http://localhost:8000/dashboard** Open the **web dashboard** in your browser: **http://localhost:8000/dashboard**
You should see the login page. Use your `MASTER_API_KEY` to log in. You should see the login page. Log in with your `MASTER_API_KEY` or via **GitHub/Google OAuth** (if configured).
### Try It Now (No Setup Required)
**Don't want to self-host?** Use the hosted instance at **[mcp.example.com](https://mcp.example.com)**:
1. Log in with **GitHub** or **Google**
2. Add your sites via the dashboard (My Sites → Add Service)
3. Go to **Connect** page — generate config for your AI client
4. Copy-paste the config into Claude Desktop, VS Code, or Claude Code
Your personal MCP endpoint: `https://mcp.example.com/u/{your-user-id}/{alias}/mcp`
---
### Configure Your Sites ### Configure Your Sites
@@ -297,12 +310,14 @@ MCP Hub supports **Open Dynamic Client Registration** (RFC 7591). ChatGPT can au
/appwrite/mcp → Appwrite tools (100 tools) /appwrite/mcp → Appwrite tools (100 tools)
/directus/mcp → Directus tools (100 tools) /directus/mcp → Directus tools (100 tools)
/project/{alias}/mcp → Per-project endpoint (auto-injects site) /project/{alias}/mcp → Per-project endpoint (auto-injects site)
/u/{user_id}/{alias}/mcp → Per-user endpoint (hosted/OAuth users)
``` ```
**Recommendation**: Use plugin-specific endpoints instead of `/mcp` (596 tools) to minimize token usage. **Recommendation**: Use plugin-specific endpoints instead of `/mcp` (596 tools) to minimize token usage.
| Endpoint | Use Case | Tools | | Endpoint | Use Case | Tools |
|----------|----------|------:| |----------|----------|------:|
| `/u/{user_id}/{alias}/mcp` | Hosted users (OAuth login) | 22-100 |
| `/project/{alias}/mcp` | Single-site workflow (recommended) | 22-100 | | `/project/{alias}/mcp` | Single-site workflow (recommended) | 22-100 |
| `/{plugin}/mcp` | Multi-site management | 23-101 | | `/{plugin}/mcp` | Multi-site management | 23-101 |
| `/mcp` | Admin & discovery only | 596 | | `/mcp` | Admin & discovery only | 596 |
@@ -323,7 +338,7 @@ Some MCP Hub tools require companion WordPress plugins:
| Tools | Requirement | | Tools | Requirement |
|-------|-------------| |-------|-------------|
| SEO tools (`wordpress_get_post_seo`, etc.) | [SEO API Bridge](wordpress-plugin/seo-api-bridge/) ([Download ZIP](wordpress-plugin/seo-api-bridge.zip)) + Rank Math or Yoast SEO | | SEO tools (`wordpress_get_post_seo`, etc.) | [Airano MCP SEO Bridge](https://wordpress.org/plugins/airano-mcp-seo-bridge/) ([GitHub](wordpress-plugin/airano-mcp-seo-bridge/)) + Rank Math or Yoast SEO |
| WP-CLI tools (15 tools: `wp_cache_*`, `wp_db_*`, etc.) | Docker socket + `CONTAINER` env var | | WP-CLI tools (15 tools: `wp_cache_*`, `wp_db_*`, etc.) | Docker socket + `CONTAINER` env var |
| WordPress Advanced database/system tools | Docker socket + `CONTAINER` env var | | WordPress Advanced database/system tools | Docker socket + `CONTAINER` env var |
| OpenPanel analytics integration | [OpenPanel Self-Hosted](wordpress-plugin/openpanel-self-hosted/) ([Download ZIP](wordpress-plugin/openpanel-self-hosted.zip)) | | OpenPanel analytics integration | [OpenPanel Self-Hosted](wordpress-plugin/openpanel-self-hosted/) ([Download ZIP](wordpress-plugin/openpanel-self-hosted.zip)) |
@@ -363,7 +378,7 @@ Without Docker socket, WP-CLI tools return "not available" but all REST API tool
# Install with dev dependencies # Install with dev dependencies
pip install -e ".[dev]" pip install -e ".[dev]"
# Run tests (290 tests) # Run tests (481 tests)
pytest pytest
# Format and lint # Format and lint

View File

@@ -79,10 +79,14 @@ def generate_config(
endpoint_url = f"{base_url}/u/{user_id}/{alias}/mcp" endpoint_url = f"{base_url}/u/{user_id}/{alias}/mcp"
server_name = f"mcphub-{alias}" server_name = f"mcphub-{alias}"
# Claude Desktop uses streamableHttp; Claude Code, VS Code, Cursor use http
transport_type = "streamableHttp" if client_type == "claude_desktop" else "http"
if client_type in ("claude_desktop", "claude_code"): if client_type in ("claude_desktop", "claude_code"):
config = { config = {
"mcpServers": { "mcpServers": {
server_name: { server_name: {
"type": transport_type,
"url": endpoint_url, "url": endpoint_url,
"headers": { "headers": {
"Authorization": f"Bearer {api_key}", "Authorization": f"Bearer {api_key}",
@@ -97,6 +101,7 @@ def generate_config(
"mcp": { "mcp": {
"servers": { "servers": {
server_name: { server_name: {
"type": transport_type,
"url": endpoint_url, "url": endpoint_url,
"headers": { "headers": {
"Authorization": f"Bearer {api_key}", "Authorization": f"Bearer {api_key}",

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": "مشخصات دسترسی",
@@ -918,12 +926,15 @@ async def get_project_detail(project_id: str) -> dict | None:
# Get recent activity for this project # Get recent activity for this project
audit_logger = get_audit_logger() audit_logger = get_audit_logger()
recent_entries = audit_logger.get_recent_entries(limit=20) recent_entries = audit_logger.get_recent_entries(limit=60)
project_activity = [ project_activity = [
e e
for e in recent_entries for e in recent_entries
if e.get("metadata", {}).get("project_id") == project_id if (
e.get("metadata", {}).get("project_id") == project_id
or e.get("metadata", {}).get("site") == site_id or e.get("metadata", {}).get("site") == site_id
)
and e.get("event_type", "") not in ("health_metric_recorded", "health_check")
][:5] ][:5]
# Get cached health status # Get cached health status
@@ -2331,6 +2342,20 @@ async def auth_provider_redirect(request: Request) -> Response:
secure=os.environ.get("DASHBOARD_SECURE_COOKIE", "true").lower() == "true", secure=os.environ.get("DASHBOARD_SECURE_COOKIE", "true").lower() == "true",
samesite="lax", samesite="lax",
) )
# Save return URL if provided (for OAuth consent flow redirect-back)
next_url = request.query_params.get("next", "")
if next_url:
response.set_cookie(
key="mcp_auth_next",
value=next_url,
max_age=600,
httponly=True,
secure=os.environ.get("DASHBOARD_SECURE_COOKIE", "true").lower() == "true",
samesite="lax",
path="/",
)
return response return response
except (RuntimeError, ValueError) as e: except (RuntimeError, ValueError) as e:
logger.error("OAuth redirect failed for %s: %s", provider, e) logger.error("OAuth redirect failed for %s: %s", provider, e)
@@ -2442,15 +2467,25 @@ async def auth_callback(request: Request) -> Response:
role=user.get("role", "user"), role=user.get("role", "user"),
) )
response = RedirectResponse(url="/dashboard", status_code=303) # Check for return URL (OAuth consent flow redirect-back)
next_url = request.cookies.get("mcp_auth_next", "")
# Validate: must be relative URL or same-origin to prevent open redirect
if next_url and next_url.startswith("/"):
redirect_to = next_url
else:
redirect_to = "/dashboard"
response = RedirectResponse(url=redirect_to, status_code=303)
dashboard_auth = get_dashboard_auth() dashboard_auth = get_dashboard_auth()
dashboard_auth.set_session_cookie(response, token) dashboard_auth.set_session_cookie(response, token)
response.delete_cookie(key="oauth_state") response.delete_cookie(key="oauth_state")
response.delete_cookie(key="mcp_auth_next", path="/")
logger.info( logger.info(
"OAuth login successful: %s via %s", "OAuth login successful: %s via %s (redirect=%s)",
user["email"], user["email"],
provider, provider,
redirect_to,
) )
return response return response
@@ -2669,6 +2704,7 @@ async def dashboard_connect_page(request: Request) -> Response:
"clients": get_supported_clients(), "clients": get_supported_clients(),
"current_page": "connect", "current_page": "connect",
"new_key": new_key, "new_key": new_key,
"public_url": os.environ.get("PUBLIC_URL", "http://localhost:8000"),
}, },
) )
@@ -2783,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)
@@ -2875,6 +2987,110 @@ async def api_get_config(request: Request) -> Response:
return JSONResponse({"error": str(e)}, status_code=400) return JSONResponse({"error": str(e)}, status_code=400)
async def dashboard_user_oauth_clients_list(request: Request) -> Response:
"""GET /dashboard/connect/oauth-clients — OAuth user's own OAuth clients."""
user_session, redirect = _require_user_session(request)
if redirect:
return redirect
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)
from core.oauth import get_client_registry as _get_client_registry
registry = _get_client_registry()
user_id = user_session["user_id"]
user_clients = [c for c in registry.list_clients() if c.owner_user_id == user_id]
return templates.TemplateResponse(
"dashboard/user-oauth-clients.html",
{
"request": request,
"lang": lang,
"t": t,
"session": user_session,
"clients": user_clients,
"current_page": "connect",
},
)
async def dashboard_user_oauth_clients_create(request: Request) -> Response:
"""POST /api/dashboard/user-oauth-clients/create — Create OAuth client for OAuth user."""
user_session, redirect = _require_user_session(request)
if redirect:
return JSONResponse({"error": "Unauthorized"}, status_code=401)
try:
body = await request.json()
except Exception:
return JSONResponse({"error": "Invalid JSON"}, status_code=400)
client_name = body.get("client_name", "").strip()
redirect_uris_raw = body.get("redirect_uris", "")
scopes = body.get("scopes", ["read", "write", "admin"])
if not client_name:
return JSONResponse({"error": "Client name required"}, status_code=400)
if isinstance(redirect_uris_raw, str):
redirect_uris = [u.strip() for u in redirect_uris_raw.splitlines() if u.strip()]
else:
redirect_uris = [u.strip() for u in redirect_uris_raw if u.strip()]
if not redirect_uris:
return JSONResponse({"error": "At least one redirect URI required"}, status_code=400)
scope_str = " ".join(scopes) if isinstance(scopes, list) else scopes
from core.oauth import get_client_registry as _get_client_registry
registry = _get_client_registry()
client_id, client_secret = registry.create_client(
client_name=client_name,
redirect_uris=redirect_uris,
allowed_scopes=scope_str.split() if isinstance(scope_str, str) else scopes,
owner_user_id=user_session["user_id"],
)
client = registry.get_client(client_id)
return JSONResponse(
{
"client_id": client_id,
"client_secret": client_secret,
"client_name": client.client_name,
"redirect_uris": client.redirect_uris,
"scope": client.scope,
}
)
async def dashboard_user_oauth_clients_delete(request: Request) -> Response:
"""DELETE /api/dashboard/user-oauth-clients/{client_id} — Delete user's own OAuth client."""
user_session, redirect = _require_user_session(request)
if redirect:
return JSONResponse({"error": "Unauthorized"}, status_code=401)
client_id = request.path_params.get("client_id", "")
from core.oauth import get_client_registry as _get_client_registry
registry = _get_client_registry()
client = registry.get_client(client_id)
if not client:
return JSONResponse({"error": "Client not found"}, status_code=404)
# Only allow deleting own clients
if client.owner_user_id != user_session["user_id"]:
return JSONResponse({"error": "Access denied"}, status_code=403)
registry.delete_client(client_id)
return JSONResponse({"success": True})
def register_dashboard_routes(mcp): def register_dashboard_routes(mcp):
""" """
Register dashboard routes with the MCP server. Register dashboard routes with the MCP server.
@@ -2884,6 +3100,9 @@ def register_dashboard_routes(mcp):
""" """
logger.info("Registering dashboard routes...") logger.info("Registering dashboard routes...")
# Set template globals (available in all templates without passing explicitly)
templates.env.globals["project_version"] = _get_project_version()
# Auth routes (E.2: OAuth Social Login) # Auth routes (E.2: OAuth Social Login)
mcp.custom_route("/auth/login", methods=["GET"])(auth_login_page) mcp.custom_route("/auth/login", methods=["GET"])(auth_login_page)
mcp.custom_route("/auth/callback/{provider}", methods=["GET"])(auth_callback) mcp.custom_route("/auth/callback/{provider}", methods=["GET"])(auth_callback)
@@ -2955,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)
@@ -2962,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)
@@ -2971,4 +3192,15 @@ def register_dashboard_routes(mcp):
# Config snippet API (E.3) # Config snippet API (E.3)
mcp.custom_route("/api/config/{alias}", methods=["GET"])(api_get_config) mcp.custom_route("/api/config/{alias}", methods=["GET"])(api_get_config)
# User OAuth Client routes (Bug C fix)
mcp.custom_route("/dashboard/connect/oauth-clients", methods=["GET"])(
dashboard_user_oauth_clients_list
)
mcp.custom_route("/api/dashboard/user-oauth-clients/create", methods=["POST"])(
dashboard_user_oauth_clients_create
)
mcp.custom_route("/api/dashboard/user-oauth-clients/{client_id}", methods=["DELETE"])(
dashboard_user_oauth_clients_delete
)
logger.info("Dashboard routes registered successfully") logger.info("Dashboard routes registered successfully")

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

@@ -563,13 +563,26 @@ class HealthMonitor:
try: try:
import aiohttp import aiohttp
if plugin_type == "woocommerce":
# WooCommerce uses consumer_key/consumer_secret
ck = getattr(config, "consumer_key", None) or ""
cs = getattr(config, "consumer_secret", None) or ""
# consumer_key/consumer_secret may be in model_extra
if not ck and hasattr(config, "model_extra"):
ck = (config.model_extra or {}).get("consumer_key", "")
cs = (config.model_extra or {}).get("consumer_secret", "")
auth = aiohttp.BasicAuth(ck, cs)
auth_check_url = f"{config.url}/wp-json/wc/v3/system_status"
else:
auth = aiohttp.BasicAuth( auth = aiohttp.BasicAuth(
config.username or "", config.username or "",
config.app_password or "", config.app_password or "",
) )
auth_check_url = f"{config.url}/wp-json/wp/v2/users/me"
async with aiohttp.ClientSession(auth=auth) as session: async with aiohttp.ClientSession(auth=auth) as session:
async with session.get( async with session.get(
f"{config.url}/wp-json/wp/v2/users/me", auth_check_url,
timeout=aiohttp.ClientTimeout(total=10), timeout=aiohttp.ClientTimeout(total=10),
ssl=False, ssl=False,
) as resp: ) as resp:

View File

@@ -76,6 +76,7 @@ class ClientRegistry:
grant_types: list[str] | None = None, grant_types: list[str] | None = None,
allowed_scopes: list[str] | None = None, allowed_scopes: list[str] | None = None,
metadata: dict | None = None, metadata: dict | None = None,
owner_user_id: str | None = None,
) -> tuple[str, str]: ) -> tuple[str, str]:
""" """
Create new OAuth client. Create new OAuth client.
@@ -99,6 +100,7 @@ class ClientRegistry:
grant_types=grant_types or ["authorization_code", "refresh_token"], grant_types=grant_types or ["authorization_code", "refresh_token"],
allowed_scopes=allowed_scopes or ["read", "write"], allowed_scopes=allowed_scopes or ["read", "write"],
metadata=metadata or {}, metadata=metadata or {},
owner_user_id=owner_user_id,
) )
# Save # Save

View File

@@ -27,6 +27,9 @@ class OAuthClient(BaseModel):
) )
created_at: datetime = Field(default_factory=lambda: datetime.now(UTC)) created_at: datetime = Field(default_factory=lambda: datetime.now(UTC))
metadata: dict = Field(default_factory=dict) metadata: dict = Field(default_factory=dict)
owner_user_id: str | None = Field(
default=None, description="ID of the OAuth user who created this client (None = admin)"
)
@field_validator("redirect_uris") @field_validator("redirect_uris")
def validate_redirect_uris(cls, v): def validate_redirect_uris(cls, v):
@@ -63,6 +66,7 @@ class AuthorizationCode(BaseModel):
api_key_id: str | None = None # API Key ID for scope/project inheritance api_key_id: str | None = None # API Key ID for scope/project inheritance
api_key_project_id: str | None = None # Project ID from API Key api_key_project_id: str | None = None # Project ID from API Key
api_key_scope: str | None = None # Scope from API Key api_key_scope: str | None = None # Scope from API Key
resource: str | None = None # RFC 8707: Resource indicator
def is_expired(self) -> bool: def is_expired(self) -> bool:
"""Check if code is expired""" """Check if code is expired"""

View File

@@ -143,6 +143,7 @@ class OAuthServer:
api_key_id: str | None = None, api_key_id: str | None = None,
api_key_project_id: str | None = None, api_key_project_id: str | None = None,
api_key_scope: str | None = None, api_key_scope: str | None = None,
resource: str | None = None,
) -> str: ) -> str:
""" """
Create authorization code (Step 2 of Authorization Code flow) Create authorization code (Step 2 of Authorization Code flow)
@@ -157,6 +158,7 @@ class OAuthServer:
api_key_id: Optional API Key ID for scope/project inheritance api_key_id: Optional API Key ID for scope/project inheritance
api_key_project_id: Optional project ID from API Key api_key_project_id: Optional project ID from API Key
api_key_scope: Optional scope from API Key api_key_scope: Optional scope from API Key
resource: Optional resource indicator (RFC 8707)
Returns: Returns:
Authorization code (valid for 5 minutes) Authorization code (valid for 5 minutes)
@@ -178,6 +180,7 @@ class OAuthServer:
api_key_id=api_key_id, api_key_id=api_key_id,
api_key_project_id=api_key_project_id, api_key_project_id=api_key_project_id,
api_key_scope=api_key_scope, api_key_scope=api_key_scope,
resource=resource,
) )
# Save to storage # Save to storage
@@ -257,12 +260,14 @@ class OAuthServer:
# If authorization code has API Key metadata, use it for scoping # If authorization code has API Key metadata, use it for scoping
project_id = auth_code.api_key_project_id or "*" project_id = auth_code.api_key_project_id or "*"
token_scope = auth_code.api_key_scope or auth_code.scope token_scope = auth_code.api_key_scope or auth_code.scope
resource = auth_code.resource
access_token = self.token_manager.generate_access_token( access_token = self.token_manager.generate_access_token(
client_id=client_id, client_id=client_id,
scope=token_scope, scope=token_scope,
user_id=auth_code.user_id or auth_code.api_key_id, user_id=auth_code.user_id or auth_code.api_key_id,
project_id=project_id, project_id=project_id,
resource=resource,
) )
refresh_token = self.token_manager.generate_refresh_token( refresh_token = self.token_manager.generate_refresh_token(

View File

@@ -54,7 +54,12 @@ class TokenManager:
self.refresh_token_ttl = int(os.getenv("OAUTH_REFRESH_TOKEN_TTL", "604800")) # 7 days self.refresh_token_ttl = int(os.getenv("OAUTH_REFRESH_TOKEN_TTL", "604800")) # 7 days
def generate_access_token( def generate_access_token(
self, client_id: str, scope: str, user_id: str | None = None, project_id: str = "*" self,
client_id: str,
scope: str,
user_id: str | None = None,
project_id: str = "*",
resource: str | None = None,
) -> str: ) -> str:
""" """
Generate JWT access token. Generate JWT access token.
@@ -64,6 +69,7 @@ class TokenManager:
scope: Granted scopes (space-separated) scope: Granted scopes (space-separated)
user_id: User ID (optional, for user-based auth) user_id: User ID (optional, for user-based auth)
project_id: Project ID for scoping (default: "*" for global) project_id: Project ID for scoping (default: "*" for global)
resource: Resource indicator for aud claim (RFC 8707)
Returns: Returns:
JWT access token JWT access token
@@ -87,6 +93,9 @@ class TokenManager:
if user_id: if user_id:
payload["sub"] = user_id # Subject (user ID) payload["sub"] = user_id # Subject (user ID)
if resource:
payload["aud"] = resource
# Encode JWT # Encode JWT
token = jwt.encode(payload, self.jwt_secret, algorithm=self.jwt_algorithm) token = jwt.encode(payload, self.jwt_secret, algorithm=self.jwt_algorithm)
@@ -130,6 +139,7 @@ class TokenManager:
"verify_signature": True, "verify_signature": True,
"verify_exp": True, "verify_exp": True,
"verify_nbf": True, "verify_nbf": True,
"verify_aud": False, # Server is the resource server; no external aud check needed
}, },
) )

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

@@ -1,5 +1,5 @@
<!DOCTYPE html> <!DOCTYPE html>
<html lang="en" class="dark"> <html lang="en">
<head> <head>
<meta charset="UTF-8"> <meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
@@ -11,23 +11,35 @@
theme: { theme: {
extend: { extend: {
colors: { colors: {
primary: { 400: '#60a5fa', 500: '#3b82f6', 600: '#2563eb', 700: '#1d4ed8' } primary: {
50: '#f5f3ff',
100: '#ede9fe',
400: '#a78bfa',
500: '#8b5cf6',
600: '#7c3aed',
700: '#6d28d9',
}
} }
} }
} }
} }
</script> </script>
<script>
if (localStorage.theme === 'dark' || (!('theme' in localStorage) && window.matchMedia('(prefers-color-scheme: dark)').matches)) {
document.documentElement.classList.add('dark');
}
</script>
</head> </head>
<body class="bg-gray-900 text-gray-100 min-h-screen flex items-center justify-center"> <body class="bg-gray-50 dark:bg-gray-900 text-gray-900 dark:text-gray-100 min-h-screen flex items-center justify-center">
<div class="text-center px-6 py-16 max-w-lg"> <div class="text-center px-6 py-16 max-w-lg">
<div class="w-20 h-20 bg-primary-500/20 rounded-2xl flex items-center justify-center mx-auto mb-8"> <div class="w-20 h-20 bg-primary-500/20 rounded-2xl flex items-center justify-center mx-auto mb-8">
<svg class="w-10 h-10 text-primary-400" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <svg class="w-10 h-10 text-primary-500 dark:text-primary-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9.172 16.172a4 4 0 015.656 0M9 10h.01M15 10h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"/> <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9.172 16.172a4 4 0 015.656 0M9 10h.01M15 10h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"/>
</svg> </svg>
</div> </div>
<h1 class="text-6xl font-bold text-white mb-4">404</h1> <h1 class="text-6xl font-bold text-primary-500 dark:text-primary-400 mb-4">404</h1>
<p class="text-xl text-gray-400 mb-8">Page not found</p> <p class="text-xl text-gray-600 dark:text-gray-300 mb-3">Page Not Found</p>
<p class="text-sm text-gray-500 mb-8">The page you're looking for doesn't exist or has been moved.</p> <p class="text-sm text-gray-500 dark:text-gray-500 mb-8">The page you're looking for doesn't exist or has been moved.</p>
<div class="flex items-center justify-center gap-4"> <div class="flex items-center justify-center gap-4">
<a href="/dashboard" <a href="/dashboard"
class="inline-flex items-center px-5 py-2.5 text-sm font-medium text-white bg-primary-600 hover:bg-primary-500 rounded-lg transition-colors"> class="inline-flex items-center px-5 py-2.5 text-sm font-medium text-white bg-primary-600 hover:bg-primary-500 rounded-lg transition-colors">
@@ -37,11 +49,11 @@
Dashboard Dashboard
</a> </a>
<a href="/health" <a href="/health"
class="inline-flex items-center px-5 py-2.5 text-sm font-medium text-gray-300 bg-gray-800 hover:bg-gray-700 border border-gray-700 rounded-lg transition-colors"> class="inline-flex items-center px-5 py-2.5 text-sm font-medium text-gray-600 dark:text-gray-300 bg-gray-100 dark:bg-gray-800 hover:bg-gray-200 dark:hover:bg-gray-700 border border-gray-200 dark:border-gray-700 rounded-lg transition-colors">
Health Check Health Check
</a> </a>
</div> </div>
<p class="mt-12 text-xs text-gray-600">MCP Hub</p> <p class="mt-12 text-xs text-gray-400 dark:text-gray-600">MCP Hub</p>
</div> </div>
</body> </body>
</html> </html>

View File

@@ -76,7 +76,7 @@
</button> </button>
{% if search_query or selected_project or selected_status != 'active' %} {% if search_query or selected_project or selected_status != 'active' %}
<a href="/dashboard/api-keys{% if lang and lang != 'en' %}?lang={{ lang }}{% endif %}" class="px-4 py-2 bg-gray-200 dark:bg-gray-700 hover:bg-gray-300 dark:hover:bg-gray-600 rounded-lg text-white text-sm transition-colors"> <a href="/dashboard/api-keys{% if lang and lang != 'en' %}?lang={{ lang }}{% endif %}" class="px-4 py-2 bg-gray-200 dark:bg-gray-700 hover:bg-gray-300 dark:hover:bg-gray-600 rounded-lg text-gray-800 dark:text-white text-sm transition-colors">
{{ t['clear'] }} {{ t['clear'] }}
</a> </a>
{% endif %} {% endif %}
@@ -248,7 +248,7 @@
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
{% if page_number > 1 %} {% if page_number > 1 %}
<a href="?page={{ page_number - 1 }}{% if selected_project %}&project={{ selected_project }}{% endif %}{% if selected_status %}&status={{ selected_status }}{% endif %}{% if search_query %}&search={{ search_query }}{% endif %}{% if lang and lang != 'en' %}&lang={{ lang }}{% endif %}" <a href="?page={{ page_number - 1 }}{% if selected_project %}&project={{ selected_project }}{% endif %}{% if selected_status %}&status={{ selected_status }}{% endif %}{% if search_query %}&search={{ search_query }}{% endif %}{% if lang and lang != 'en' %}&lang={{ lang }}{% endif %}"
class="px-3 py-1.5 bg-gray-200 dark:bg-gray-700 hover:bg-gray-300 dark:hover:bg-gray-600 rounded-lg text-sm text-white transition-colors"> class="px-3 py-1.5 bg-gray-200 dark:bg-gray-700 hover:bg-gray-300 dark:hover:bg-gray-600 rounded-lg text-sm text-gray-800 dark:text-white transition-colors">
{% if lang == 'fa' %}قبلی{% else %}Previous{% endif %} {% if lang == 'fa' %}قبلی{% else %}Previous{% endif %}
</a> </a>
{% endif %} {% endif %}
@@ -258,7 +258,7 @@
<span class="px-3 py-1.5 bg-primary-600 rounded-lg text-sm text-white">{{ page_num }}</span> <span class="px-3 py-1.5 bg-primary-600 rounded-lg text-sm text-white">{{ page_num }}</span>
{% elif page_num == 1 or page_num == total_pages or (page_num >= page_number - 2 and page_num <= page_number + 2) %} {% elif page_num == 1 or page_num == total_pages or (page_num >= page_number - 2 and page_num <= page_number + 2) %}
<a href="?page={{ page_num }}{% if selected_project %}&project={{ selected_project }}{% endif %}{% if selected_status %}&status={{ selected_status }}{% endif %}{% if search_query %}&search={{ search_query }}{% endif %}{% if lang and lang != 'en' %}&lang={{ lang }}{% endif %}" <a href="?page={{ page_num }}{% if selected_project %}&project={{ selected_project }}{% endif %}{% if selected_status %}&status={{ selected_status }}{% endif %}{% if search_query %}&search={{ search_query }}{% endif %}{% if lang and lang != 'en' %}&lang={{ lang }}{% endif %}"
class="px-3 py-1.5 bg-gray-200 dark:bg-gray-700 hover:bg-gray-300 dark:hover:bg-gray-600 rounded-lg text-sm text-white transition-colors"> class="px-3 py-1.5 bg-gray-200 dark:bg-gray-700 hover:bg-gray-300 dark:hover:bg-gray-600 rounded-lg text-sm text-gray-800 dark:text-white transition-colors">
{{ page_num }} {{ page_num }}
</a> </a>
{% elif page_num == page_number - 3 or page_num == page_number + 3 %} {% elif page_num == page_number - 3 or page_num == page_number + 3 %}
@@ -268,7 +268,7 @@
{% if page_number < total_pages %} {% if page_number < total_pages %}
<a href="?page={{ page_number + 1 }}{% if selected_project %}&project={{ selected_project }}{% endif %}{% if selected_status %}&status={{ selected_status }}{% endif %}{% if search_query %}&search={{ search_query }}{% endif %}{% if lang and lang != 'en' %}&lang={{ lang }}{% endif %}" <a href="?page={{ page_number + 1 }}{% if selected_project %}&project={{ selected_project }}{% endif %}{% if selected_status %}&status={{ selected_status }}{% endif %}{% if search_query %}&search={{ search_query }}{% endif %}{% if lang and lang != 'en' %}&lang={{ lang }}{% endif %}"
class="px-3 py-1.5 bg-gray-200 dark:bg-gray-700 hover:bg-gray-300 dark:hover:bg-gray-600 rounded-lg text-sm text-white transition-colors"> class="px-3 py-1.5 bg-gray-200 dark:bg-gray-700 hover:bg-gray-300 dark:hover:bg-gray-600 rounded-lg text-sm text-gray-800 dark:text-white transition-colors">
{% if lang == 'fa' %}بعدی{% else %}Next{% endif %} {% if lang == 'fa' %}بعدی{% else %}Next{% endif %}
</a> </a>
{% endif %} {% endif %}
@@ -360,7 +360,7 @@
<div class="p-6 border-t border-gray-200 dark:border-gray-700 flex justify-end gap-3"> <div class="p-6 border-t border-gray-200 dark:border-gray-700 flex justify-end gap-3">
<button <button
onclick="closeCreateModal()" onclick="closeCreateModal()"
class="px-4 py-2 bg-gray-200 dark:bg-gray-700 hover:bg-gray-300 dark:hover:bg-gray-600 rounded-lg text-white text-sm transition-colors" class="px-4 py-2 bg-gray-200 dark:bg-gray-700 hover:bg-gray-300 dark:hover:bg-gray-600 rounded-lg text-gray-800 dark:text-white text-sm transition-colors"
> >
{% if lang == 'fa' %}انصراف{% else %}Cancel{% endif %} {% if lang == 'fa' %}انصراف{% else %}Cancel{% endif %}
</button> </button>
@@ -448,7 +448,7 @@
<div class="p-6 border-t border-gray-200 dark:border-gray-700 flex justify-end gap-3"> <div class="p-6 border-t border-gray-200 dark:border-gray-700 flex justify-end gap-3">
<button <button
onclick="closeRevokeModal()" onclick="closeRevokeModal()"
class="px-4 py-2 bg-gray-200 dark:bg-gray-700 hover:bg-gray-300 dark:hover:bg-gray-600 rounded-lg text-white text-sm transition-colors" class="px-4 py-2 bg-gray-200 dark:bg-gray-700 hover:bg-gray-300 dark:hover:bg-gray-600 rounded-lg text-gray-800 dark:text-white text-sm transition-colors"
> >
{% if lang == 'fa' %}انصراف{% else %}Cancel{% endif %} {% if lang == 'fa' %}انصراف{% else %}Cancel{% endif %}
</button> </button>
@@ -485,7 +485,7 @@
<div class="p-6 border-t border-gray-200 dark:border-gray-700 flex justify-end gap-3"> <div class="p-6 border-t border-gray-200 dark:border-gray-700 flex justify-end gap-3">
<button <button
onclick="closeDeleteModal()" onclick="closeDeleteModal()"
class="px-4 py-2 bg-gray-200 dark:bg-gray-700 hover:bg-gray-300 dark:hover:bg-gray-600 rounded-lg text-white text-sm transition-colors" class="px-4 py-2 bg-gray-200 dark:bg-gray-700 hover:bg-gray-300 dark:hover:bg-gray-600 rounded-lg text-gray-800 dark:text-white text-sm transition-colors"
> >
{% if lang == 'fa' %}انصراف{% else %}Cancel{% endif %} {% if lang == 'fa' %}انصراف{% else %}Cancel{% endif %}
</button> </button>

View File

@@ -102,9 +102,8 @@
})"> })">
<div class="flex h-screen overflow-hidden"> <div class="flex h-screen overflow-hidden">
<!-- Sidebar --> <!-- Sidebar -->
<aside class="sidebar-transition bg-white dark:bg-gray-800 border-gray-200 dark:border-gray-700 flex flex-col" <aside class="sidebar-transition bg-white dark:bg-gray-800 border-gray-200 dark:border-gray-700 flex flex-col {% if lang=='fa' %}border-l{% else %}border-r{% endif %} border-gray-200 dark:border-gray-700"
:class="sidebarOpen ? 'w-64' : 'w-20'" {% if lang=='fa' %}style="border-left: 1px solid;" {% else :class="sidebarOpen ? 'w-64' : 'w-20'">
%}style="border-right: 1px solid;" {% endif %}>
<!-- Logo --> <!-- Logo -->
<div class="flex items-center justify-between h-16 px-4 border-b border-gray-200 dark:border-gray-700"> <div class="flex items-center justify-between h-16 px-4 border-b border-gray-200 dark:border-gray-700">
<div class="flex items-center" x-show="sidebarOpen"> <div class="flex items-center" x-show="sidebarOpen">
@@ -207,6 +206,11 @@
{% endif %} {% endif %}
</nav> </nav>
<!-- Version footer -->
<div x-show="sidebarOpen" class="px-4 py-2 text-xs text-gray-400 dark:text-gray-600">
v{{ project_version | default('') }}
</div>
<!-- User Section --> <!-- User Section -->
<div class="p-4 border-t border-gray-200 dark:border-gray-700"> <div class="p-4 border-t border-gray-200 dark:border-gray-700">
{# Profile link for OAuth users #} {# Profile link for OAuth users #}

View File

@@ -28,14 +28,35 @@
</div> </div>
{% endif %} {% endif %}
<!-- New key display (JS-created) --> <!-- New Key Modal (stays open until user clicks Done) -->
<div id="new-key-display" class="hidden bg-yellow-50 dark:bg-yellow-500/20 border border-yellow-200 dark:border-yellow-500/50 text-yellow-800 dark:text-yellow-300 px-4 py-3 rounded-lg mb-4"> <div id="new-key-display" class="fixed inset-0 bg-black/50 hidden items-center justify-center z-50">
<p class="font-medium mb-1">{{ t.your_api_key }}</p> <div class="bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700 w-full max-w-lg mx-4">
<div class="flex items-center gap-2"> <div class="p-6 border-b border-gray-200 dark:border-gray-700">
<code id="new-key-value" class="bg-gray-100 dark:bg-gray-900 px-3 py-1 rounded text-sm flex-1 overflow-x-auto text-gray-800 dark:text-gray-200"></code> <h3 class="text-lg font-semibold text-gray-900 dark:text-white flex items-center gap-2">
<button onclick="copyNewKey()" class="text-sm text-yellow-700 dark:text-yellow-300 hover:text-yellow-600 dark:hover:text-yellow-200">{{ t['copy'] }}</button> <svg class="w-5 h-5 text-green-500" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7"/>
</svg>
{{ t.your_api_key }}
</h3>
</div>
<div class="p-6 space-y-4">
<div class="bg-yellow-50 dark:bg-yellow-500/10 border border-yellow-200 dark:border-yellow-500/30 rounded-lg p-4">
<p class="text-sm text-yellow-700 dark:text-yellow-400">{{ t.key_shown_once }}</p>
</div>
<div>
<label class="block text-sm font-medium text-gray-500 dark:text-gray-400 mb-2">API Key</label>
<div class="flex items-center gap-2">
<code id="new-key-value" class="flex-1 bg-gray-100 dark:bg-gray-900 border border-gray-200 dark:border-gray-700 rounded-lg px-4 py-2 text-sm font-mono text-gray-800 dark:text-gray-200 overflow-x-auto"></code>
<button onclick="copyNewKey()" class="px-4 py-2 bg-blue-600 hover:bg-blue-700 rounded-lg text-white text-sm transition-colors">{{ t['copy'] }}</button>
</div>
</div>
</div>
<div class="p-6 border-t border-gray-200 dark:border-gray-700 flex justify-end">
<button onclick="closeNewKeyModal()" class="px-4 py-2 bg-blue-600 hover:bg-blue-700 rounded-lg text-white text-sm transition-colors">
{% if lang == 'fa' %}تأیید{% else %}Done{% endif %}
</button>
</div>
</div> </div>
<p class="text-xs text-yellow-600 dark:text-yellow-400 mt-1">{{ t.key_shown_once }}</p>
</div> </div>
{% if api_keys %} {% if api_keys %}
@@ -106,10 +127,88 @@
<pre id="config-output" class="bg-gray-100 dark:bg-gray-900 rounded-lg p-4 text-sm text-gray-700 dark:text-gray-300 overflow-x-auto border border-gray-200 dark:border-gray-700 min-h-[120px]">Select a site and client to generate configuration...</pre> <pre id="config-output" class="bg-gray-100 dark:bg-gray-900 rounded-lg p-4 text-sm text-gray-700 dark:text-gray-300 overflow-x-auto border border-gray-200 dark:border-gray-700 min-h-[120px]">Select a site and client to generate configuration...</pre>
<button onclick="copyConfig()" class="absolute top-2 {{ 'left-2' if lang == 'fa' else 'right-2' }} text-xs bg-gray-200 dark:bg-gray-700 hover:bg-gray-300 dark:hover:bg-gray-600 text-gray-600 dark:text-gray-300 px-3 py-1 rounded transition-colors" id="copy-config-btn">{{ t['copy'] }}</button> <button onclick="copyConfig()" class="absolute top-2 {{ 'left-2' if lang == 'fa' else 'right-2' }} text-xs bg-gray-200 dark:bg-gray-700 hover:bg-gray-300 dark:hover:bg-gray-600 text-gray-600 dark:text-gray-300 px-3 py-1 rounded transition-colors" id="copy-config-btn">{{ t['copy'] }}</button>
</div> </div>
<!-- Transport type info -->
<div class="mt-4 bg-blue-50 dark:bg-blue-500/10 border border-blue-200 dark:border-blue-500/30 rounded-lg p-4">
<p class="text-sm text-blue-700 dark:text-blue-400">
{% if lang == 'fa' %}
<strong>نکته:</strong> تنظیمات بالا شامل نوع اتصال (<code>streamableHttp</code> برای Claude Desktop و <code>http</code> برای VS Code/Claude Code) می‌باشد. از <code>sse</code> استفاده نکنید — باعث خطای <code>400 Bad Request</code> می‌شود.
{% else %}
<strong>Note:</strong> Config above includes the correct transport type (<code>streamableHttp</code> for Claude Desktop, <code>http</code> for VS Code/Claude Code). Do not use <code>sse</code> — it will cause <code>400 Bad Request</code> errors.
{% endif %}
</p>
</div>
<!-- WordPress plugin requirement -->
<div class="mt-3 bg-amber-50 dark:bg-amber-500/10 border border-amber-200 dark:border-amber-500/30 rounded-lg p-4">
<p class="text-sm text-amber-700 dark:text-amber-400">
{% if lang == 'fa' %}
<strong>وردپرس:</strong> برای استفاده از ابزارهای SEO، افزونه
<a href="https://wordpress.org/plugins/airano-mcp-seo-bridge/" target="_blank" class="underline">Airano MCP SEO Bridge</a>
را روی سایت وردپرسی خود نصب کنید. همچنین یک <strong>Application Password</strong> در وردپرس (Users → Profile) ایجاد و هنگام افزودن سایت وارد کنید.
{% else %}
<strong>WordPress:</strong> For SEO tools, install the
<a href="https://wordpress.org/plugins/airano-mcp-seo-bridge/" target="_blank" class="underline">Airano MCP SEO Bridge</a>
plugin on your WordPress site. Also create an <strong>Application Password</strong> in WordPress (Users → Profile) and enter it when adding your site.
{% endif %}
</p>
</div>
{% else %} {% else %}
<p class="text-gray-500 dark:text-gray-400 text-sm">{{ t.no_sites }}. <a href="/dashboard/sites/add{% if lang and lang != 'en' %}?lang={{ lang }}{% endif %}" class="text-blue-600 dark:text-blue-400 hover:text-blue-500 dark:hover:text-blue-300">{{ t.add_site }}</a></p> <p class="text-gray-500 dark:text-gray-400 text-sm">{{ t.no_sites }}. <a href="/dashboard/sites/add{% if lang and lang != 'en' %}?lang={{ lang }}{% endif %}" class="text-blue-600 dark:text-blue-400 hover:text-blue-500 dark:hover:text-blue-300">{{ t.add_site }}</a></p>
{% endif %} {% endif %}
</div> </div>
<!-- Claude.ai Connection Guide -->
<div class="bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700 p-6">
<h3 class="text-lg font-semibold text-gray-900 dark:text-white mb-4">
{% if lang == 'fa' %}اتصال به Claude.ai{% else %}Connect to Claude.ai{% endif %}
</h3>
<ol class="space-y-3 text-sm text-gray-700 dark:text-gray-300 {% if lang == 'fa' %}list-decimal list-inside{% else %}list-decimal list-inside{% endif %}" {% if lang == 'fa' %}dir="rtl"{% endif %}>
<li>
{% if lang == 'fa' %}
در صفحه <strong>OAuth Clients</strong> یک کلاینت جدید بسازید
{% else %}
Create an OAuth Client on the <strong>OAuth Clients</strong> page
{% endif %}
</li>
<li>
{% if lang == 'fa' %}
در Claude.ai → Settings → Connectors → Add → Custom
{% else %}
Go to Claude.ai → Settings → Connectors → Add → Custom
{% endif %}
</li>
<li>
{% if lang == 'fa' %}
آدرس MCP Endpoint خود را وارد کنید:
{% else %}
Enter your MCP Endpoint URL:
{% endif %}
{% if sites %}
<code class="block mt-1 bg-gray-100 dark:bg-gray-900 px-3 py-1.5 rounded text-xs font-mono text-gray-800 dark:text-gray-200">
{{ public_url }}/u/{{ session.user_id }}/{{ sites[0].alias }}/mcp
</code>
{% else %}
<code class="block mt-1 bg-gray-100 dark:bg-gray-900 px-3 py-1.5 rounded text-xs font-mono text-gray-800 dark:text-gray-200">
{{ public_url }}/u/{{ session.user_id }}/YOUR-ALIAS/mcp
</code>
{% endif %}
</li>
<li>
{% if lang == 'fa' %}Client ID و Client Secret را از OAuth Clients وارد کنید{% else %}Enter Client ID and Client Secret from your OAuth Client{% endif %}
</li>
<li>
{% if lang == 'fa' %}با GitHub یا Google وارد شوید{% else %}Log in with GitHub or Google when prompted{% endif %}
</li>
</ol>
<a href="/dashboard/connect/oauth-clients{% if lang and lang != 'en' %}?lang={{ lang }}{% endif %}"
class="mt-4 inline-flex items-center gap-2 px-4 py-2 bg-primary-600 hover:bg-primary-700 text-white rounded-lg text-sm font-medium transition-colors">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m5.618-4.016A11.955 11.955 0 0112 2.944a11.955 11.955 0 01-8.618 3.04A12.02 12.02 0 003 9c0 5.591 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.042-.133-2.052-.382-3.016z"/>
</svg>
{% if lang == 'fa' %}مدیریت OAuth Clients{% else %}Manage OAuth Clients{% endif %}
</a>
</div>
</div> </div>
{% endblock %} {% endblock %}
@@ -132,8 +231,8 @@ async function createKey() {
if (resp.ok && data.key) { if (resp.ok && data.key) {
document.getElementById('new-key-value').textContent = data.key.key; document.getElementById('new-key-value').textContent = data.key.key;
document.getElementById('new-key-display').classList.remove('hidden'); document.getElementById('new-key-display').classList.remove('hidden');
// Reload to show in table document.getElementById('new-key-display').classList.add('flex');
setTimeout(() => location.reload(), 500); // Modal stays open — user clicks "Done" to dismiss
} else { } else {
alert(data.error || 'Failed to create key'); alert(data.error || 'Failed to create key');
} }
@@ -180,6 +279,12 @@ function copyNewKey() {
navigator.clipboard.writeText(key); navigator.clipboard.writeText(key);
} }
function closeNewKeyModal() {
document.getElementById('new-key-display').classList.add('hidden');
document.getElementById('new-key-display').classList.remove('flex');
location.reload();
}
function copyConfig() { function copyConfig() {
const text = document.getElementById('config-output').textContent; const text = document.getElementById('config-output').textContent;
navigator.clipboard.writeText(text); navigator.clipboard.writeText(text);

View File

@@ -316,7 +316,7 @@
<div class="flex gap-2"> <div class="flex gap-2">
<a href="/dashboard/health{% if lang and lang != 'en' %}?lang={{ lang }}{% endif %}" <a href="/dashboard/health{% if lang and lang != 'en' %}?lang={{ lang }}{% endif %}"
class="px-4 py-2 bg-gray-200 dark:bg-gray-700 hover:bg-gray-300 dark:hover:bg-gray-600 rounded-lg text-white text-sm transition-colors"> class="px-4 py-2 bg-gray-200 dark:bg-gray-700 hover:bg-gray-300 dark:hover:bg-gray-600 rounded-lg text-gray-800 dark:text-white text-sm transition-colors">
{{ t.refresh }} {{ t.refresh }}
</a> </a>
<a href="/dashboard/health?refresh=true{% if lang and lang != 'en' %}&lang={{ lang }}{% endif %}" <a href="/dashboard/health?refresh=true{% if lang and lang != 'en' %}&lang={{ lang }}{% endif %}"

View File

@@ -439,17 +439,37 @@
</div> </div>
</div> </div>
<!-- Support / Donate (User Dashboard) -->
<div class="bg-gradient-to-br from-primary-900/40 to-gray-800 rounded-xl border border-primary-700/30">
<div class="p-6 text-center">
<div class="w-10 h-10 bg-primary-500/20 rounded-full flex items-center justify-center mx-auto mb-3">
<svg class="w-5 h-5 text-primary-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4.318 6.318a4.5 4.5 0 000 6.364L12 20.364l7.682-7.682a4.5 4.5 0 00-6.364-6.364L12 7.636l-1.318-1.318a4.5 4.5 0 00-6.364 0z"/>
</svg>
</div>
<h3 class="text-sm font-semibold text-white mb-1">
{% if lang == 'fa' %}حمایت از پروژه{% else %}Support MCP Hub{% endif %}
</h3>
<p class="text-xs text-gray-400 mb-4">
{% if lang == 'fa' %}با حمایت مالی به توسعه این پروژه کمک کنید{% else %}Help us keep this project alive and growing{% endif %}
</p>
<a
href="https://nowpayments.io/donation/airano"
target="_blank"
rel="noopener noreferrer"
class="inline-flex items-center px-4 py-2 text-sm font-medium text-white bg-primary-600 hover:bg-primary-500 rounded-lg transition-colors"
>
<svg class="w-4 h-4 {% if lang == 'fa' %}ml-2{% else %}mr-2{% endif %}" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8c-1.657 0-3 .895-3 2s1.343 2 3 2 3 .895 3 2-1.343 2-3 2m0-8c1.11 0 2.08.402 2.599 1M12 8V7m0 1v8m0 0v1m0-1c-1.11 0-2.08-.402-2.599-1M21 12a9 9 0 11-18 0 9 9 0 0118 0z"/>
</svg>
{% if lang == 'fa' %}حمایت با رمزارز{% else %}Donate with Crypto{% endif %}
</a>
</div>
</div>
{% endif %} {% endif %}
</div> </div>
{% endblock %} {% endblock %}
{% block scripts %} {% block scripts %}
<script>
// Auto-refresh dashboard every 30 seconds
htmx.config.defaultSwapStyle = 'outerHTML';
setInterval(function() {
htmx.ajax('GET', '/api/dashboard/stats', {target: '#stats-container', swap: 'none'});
}, 30000);
</script>
{% endblock %} {% endblock %}

View File

@@ -11,7 +11,7 @@
<script src="https://unpkg.com/htmx.org@1.9.10"></script> <script src="https://unpkg.com/htmx.org@1.9.10"></script>
<!-- Alpine.js for simple interactions --> <!-- Alpine.js for simple interactions -->
<script defer src="https://unpkg.com/alpinejs@3.x.x/dist/cdn.min.js"></script> <script defer src="https://unpkg.com/alpinejs@3.14.8/dist/cdn.min.js"></script>
<!-- Custom Tailwind Config --> <!-- Custom Tailwind Config -->
<script> <script>
@@ -50,8 +50,6 @@
} }
</script> </script>
<meta name="csrf-token" content="{{ request.state.csrf_token }}">
<!-- Global CSRF Interceptor --> <!-- Global CSRF Interceptor -->
<script> <script>
document.addEventListener('DOMContentLoaded', () => { document.addEventListener('DOMContentLoaded', () => {

View File

@@ -52,7 +52,7 @@
{% if search_query or selected_plugin_type or selected_status %} {% if search_query or selected_plugin_type or selected_status %}
<a href="/dashboard/projects{% if lang and lang != 'en' %}?lang={{ lang }}{% endif %}" <a href="/dashboard/projects{% if lang and lang != 'en' %}?lang={{ lang }}{% endif %}"
class="px-4 py-2 bg-gray-200 dark:bg-gray-700 hover:bg-gray-300 dark:hover:bg-gray-600 rounded-lg text-white text-sm transition-colors"> class="px-4 py-2 bg-gray-200 dark:bg-gray-700 hover:bg-gray-300 dark:hover:bg-gray-600 rounded-lg text-gray-800 dark:text-white text-sm transition-colors">
{{ t['clear'] }} {{ t['clear'] }}
</a> </a>
{% endif %} {% endif %}
@@ -202,7 +202,7 @@
<!-- Actions --> <!-- Actions -->
<td class="px-6 py-4"> <td class="px-6 py-4">
<a href="/dashboard/projects/{{ project.full_id }}{% if lang and lang != 'en' %}?lang={{ lang }}{% endif %}" <a href="/dashboard/projects/{{ project.full_id }}{% if lang and lang != 'en' %}?lang={{ lang }}{% endif %}"
class="inline-flex items-center px-3 py-1.5 bg-gray-200 dark:bg-gray-700 hover:bg-gray-300 dark:hover:bg-gray-600 rounded-lg text-sm text-white transition-colors"> class="inline-flex items-center px-3 py-1.5 bg-gray-200 dark:bg-gray-700 hover:bg-gray-300 dark:hover:bg-gray-600 rounded-lg text-sm text-gray-800 dark:text-white transition-colors">
{{ t.view }} {{ t.view }}
<svg class="w-4 h-4 {% if lang == 'fa' %}mr-1 rotate-180{% else %}ml-1{% endif %}" <svg class="w-4 h-4 {% if lang == 'fa' %}mr-1 rotate-180{% else %}ml-1{% endif %}"
fill="none" stroke="currentColor" viewBox="0 0 24 24"> fill="none" stroke="currentColor" viewBox="0 0 24 24">
@@ -252,7 +252,7 @@
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
{% if page_number > 1 %} {% if page_number > 1 %}
<a href="?page={{ page_number - 1 }}{% if selected_plugin_type %}&plugin_type={{ selected_plugin_type }}{% endif %}{% if search_query %}&search={{ search_query }}{% endif %}{% if lang and lang != 'en' %}&lang={{ lang }}{% endif %}" <a href="?page={{ page_number - 1 }}{% if selected_plugin_type %}&plugin_type={{ selected_plugin_type }}{% endif %}{% if search_query %}&search={{ search_query }}{% endif %}{% if lang and lang != 'en' %}&lang={{ lang }}{% endif %}"
class="px-3 py-1.5 bg-gray-200 dark:bg-gray-700 hover:bg-gray-300 dark:hover:bg-gray-600 rounded-lg text-sm text-white transition-colors"> class="px-3 py-1.5 bg-gray-200 dark:bg-gray-700 hover:bg-gray-300 dark:hover:bg-gray-600 rounded-lg text-sm text-gray-800 dark:text-white transition-colors">
{% if lang == 'fa' %}قبلی{% else %}Previous{% endif %} {% if lang == 'fa' %}قبلی{% else %}Previous{% endif %}
</a> </a>
{% endif %} {% endif %}
@@ -263,7 +263,7 @@
{% elif page_num == 1 or page_num == total_pages or (page_num >= page_number - 2 and page_num <= {% elif page_num == 1 or page_num == total_pages or (page_num >= page_number - 2 and page_num <=
page_number + 2) %} <a page_number + 2) %} <a
href="?page={{ page_num }}{% if selected_plugin_type %}&plugin_type={{ selected_plugin_type }}{% endif %}{% if search_query %}&search={{ search_query }}{% endif %}{% if lang and lang != 'en' %}&lang={{ lang }}{% endif %}" href="?page={{ page_num }}{% if selected_plugin_type %}&plugin_type={{ selected_plugin_type }}{% endif %}{% if search_query %}&search={{ search_query }}{% endif %}{% if lang and lang != 'en' %}&lang={{ lang }}{% endif %}"
class="px-3 py-1.5 bg-gray-200 dark:bg-gray-700 hover:bg-gray-300 dark:hover:bg-gray-600 rounded-lg text-sm text-white transition-colors"> class="px-3 py-1.5 bg-gray-200 dark:bg-gray-700 hover:bg-gray-300 dark:hover:bg-gray-600 rounded-lg text-sm text-gray-800 dark:text-white transition-colors">
{{ page_num }} {{ page_num }}
</a> </a>
{% elif page_num == page_number - 3 or page_num == page_number + 3 %} {% elif page_num == page_number - 3 or page_num == page_number + 3 %}
@@ -273,7 +273,7 @@
{% if page_number < total_pages %} <a {% if page_number < total_pages %} <a
href="?page={{ page_number + 1 }}{% if selected_plugin_type %}&plugin_type={{ selected_plugin_type }}{% endif %}{% if search_query %}&search={{ search_query }}{% endif %}{% if lang and lang != 'en' %}&lang={{ lang }}{% endif %}" href="?page={{ page_number + 1 }}{% if selected_plugin_type %}&plugin_type={{ selected_plugin_type }}{% endif %}{% if search_query %}&search={{ search_query }}{% endif %}{% if lang and lang != 'en' %}&lang={{ lang }}{% endif %}"
class="px-3 py-1.5 bg-gray-200 dark:bg-gray-700 hover:bg-gray-300 dark:hover:bg-gray-600 rounded-lg text-sm text-white transition-colors"> class="px-3 py-1.5 bg-gray-200 dark:bg-gray-700 hover:bg-gray-300 dark:hover:bg-gray-600 rounded-lg text-sm text-gray-800 dark:text-white transition-colors">
{% if lang == 'fa' %}بعدی{% else %}Next{% endif %} {% if lang == 'fa' %}بعدی{% else %}Next{% endif %}
</a> </a>
{% endif %} {% endif %}

View File

@@ -13,11 +13,11 @@
<div class="space-y-4"> <div class="space-y-4">
<div class="flex items-center gap-4"> <div class="flex items-center gap-4">
<a href="/dashboard/settings?lang=en" <a href="/dashboard/settings?lang=en"
class="px-4 py-2 rounded-lg transition-colors {% if lang != 'fa' %}bg-primary-600 text-white{% else %}bg-gray-700 text-gray-700 dark:text-gray-300 hover:bg-gray-300 dark:hover:bg-gray-600{% endif %}"> class="px-4 py-2 rounded-lg transition-colors {% if lang != 'fa' %}bg-primary-600 text-white{% else %}bg-gray-700 text-white dark:text-gray-300 hover:bg-gray-300 dark:hover:bg-gray-600{% endif %}">
English English
</a> </a>
<a href="/dashboard/settings?lang=fa" <a href="/dashboard/settings?lang=fa"
class="px-4 py-2 rounded-lg transition-colors {% if lang == 'fa' %}bg-primary-600 text-white{% else %}bg-gray-700 text-gray-700 dark:text-gray-300 hover:bg-gray-300 dark:hover:bg-gray-600{% endif %}"> class="px-4 py-2 rounded-lg transition-colors {% if lang == 'fa' %}bg-primary-600 text-white{% else %}bg-gray-700 text-white dark:text-gray-300 hover:bg-gray-300 dark:hover:bg-gray-600{% endif %}">
فارسی فارسی
</a> </a>
</div> </div>

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

@@ -0,0 +1,241 @@
{% extends "dashboard/base.html" %}
{% block title %}{% if lang == 'fa' %}کلاینت‌های OAuth{% else %}My OAuth Clients{% endif %} - MCP Hub{% endblock %}
{% block page_title %}{% if lang == 'fa' %}کلاینت‌های OAuth من{% else %}My OAuth Clients{% endif %}{% endblock %}
{% block content %}
<div class="space-y-6">
<!-- Back link -->
<div>
<a href="/dashboard/connect{% if lang and lang != 'en' %}?lang={{ lang }}{% endif %}"
class="inline-flex items-center text-sm text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-200 transition-colors">
<svg class="w-4 h-4 {% if lang == 'fa' %}ml-1 rotate-180{% else %}mr-1{% endif %}" 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>
{% if lang == 'fa' %}بازگشت به اتصال{% else %}Back to Connect{% endif %}
</a>
</div>
<!-- Info Banner -->
<div class="bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800 rounded-xl p-4">
<p class="text-sm text-blue-800 dark:text-blue-300">
{% if lang == 'fa' %}
برای اتصال Claude.ai Connectors به MCP Hub، یک OAuth Client بسازید. Client ID و Client Secret را در Claude.ai وارد کنید.
{% else %}
Create an OAuth Client to connect Claude.ai Connectors to your MCP Hub sites. Enter the Client ID and Client Secret in Claude.ai → Settings → Connectors.
{% endif %}
</p>
</div>
<!-- Clients List -->
<div class="bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700 p-6">
<div class="flex items-center justify-between mb-4">
<h3 class="text-lg font-semibold text-gray-900 dark:text-white">
{% if lang == 'fa' %}کلاینت‌های OAuth{% else %}OAuth Clients{% endif %}
</h3>
<button onclick="openCreateModal()"
class="btn-primary px-4 py-2 rounded-lg text-white text-sm font-medium">
+ {% if lang == 'fa' %}کلاینت جدید{% else %}New Client{% endif %}
</button>
</div>
{% if clients %}
<div class="overflow-x-auto">
<table class="w-full">
<thead class="bg-gray-50 dark:bg-gray-700/50">
<tr>
<th class="px-4 py-3 text-left text-sm font-medium text-gray-500 dark:text-gray-300">
{% if lang == 'fa' %}نام{% else %}Name{% endif %}
</th>
<th class="px-4 py-3 text-left text-sm font-medium text-gray-500 dark:text-gray-300">
Client ID
</th>
<th class="px-4 py-3 text-left text-sm font-medium text-gray-500 dark:text-gray-300">
{% if lang == 'fa' %}Redirect URIs{% else %}Redirect URIs{% endif %}
</th>
<th class="px-4 py-3 text-left text-sm font-medium text-gray-500 dark:text-gray-300">
{% if lang == 'fa' %}عملیات{% else %}Actions{% endif %}
</th>
</tr>
</thead>
<tbody class="divide-y divide-gray-100 dark:divide-gray-700">
{% for client in clients %}
<tr id="client-{{ client.client_id }}">
<td class="px-4 py-3 text-gray-900 dark:text-white font-medium">{{ client.client_name }}</td>
<td class="px-4 py-3 text-gray-500 dark:text-gray-400 font-mono text-xs">{{ client.client_id }}</td>
<td class="px-4 py-3 text-gray-500 dark:text-gray-400 text-xs">
{% for uri in client.redirect_uris %}
<div class="truncate max-w-xs" title="{{ uri }}">{{ uri }}</div>
{% endfor %}
</td>
<td class="px-4 py-3">
<button onclick="deleteClient('{{ client.client_id }}')"
class="text-sm text-red-600 dark:text-red-400 hover:text-red-500 dark:hover:text-red-300">
{% if lang == 'fa' %}حذف{% else %}Delete{% endif %}
</button>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% else %}
<p class="text-gray-500 dark:text-gray-400 text-sm">
{% if lang == 'fa' %}هنوز کلاینت OAuth ندارید. یکی بسازید.{% else %}No OAuth clients yet. Create one to connect Claude.ai.{% endif %}
</p>
{% endif %}
</div>
</div>
<!-- Create Client Modal -->
<div id="create-modal" class="fixed inset-0 bg-black/50 hidden items-center justify-center z-50">
<div class="bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700 w-full max-w-lg mx-4">
<div class="p-6 border-b border-gray-200 dark:border-gray-700">
<h3 class="text-lg font-semibold text-gray-900 dark:text-white">
{% if lang == 'fa' %}کلاینت OAuth جدید{% else %}New OAuth Client{% endif %}
</h3>
</div>
<div class="p-6 space-y-4">
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
{% if lang == 'fa' %}نام کلاینت{% else %}Client Name{% endif %}
</label>
<input type="text" id="client-name"
placeholder="{% if lang == 'fa' %}مثال: Claude.ai{% else %}e.g. Claude.ai Desktop{% 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 text-gray-900 dark:text-white focus:ring-2 focus:ring-primary-500">
</div>
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
{% if lang == 'fa' %}Redirect URIs (هر URI در یک خط){% else %}Redirect URIs (one per line){% endif %}
</label>
<textarea id="redirect-uris" rows="3"
placeholder="https://claude.ai/oauth/callback"
class="w-full bg-gray-50 dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-lg px-4 py-2 text-gray-900 dark:text-white focus:ring-2 focus:ring-primary-500 font-mono text-sm"></textarea>
</div>
</div>
<div class="p-6 border-t border-gray-200 dark:border-gray-700 flex justify-end gap-3">
<button onclick="closeCreateModal()"
class="px-4 py-2 bg-gray-100 dark:bg-gray-700 hover:bg-gray-200 dark:hover:bg-gray-600 text-gray-800 dark:text-gray-200 rounded-lg text-sm transition-colors">
{% if lang == 'fa' %}لغو{% else %}Cancel{% endif %}
</button>
<button onclick="createClient()"
class="px-4 py-2 bg-primary-600 hover:bg-primary-700 text-white rounded-lg text-sm font-medium transition-colors">
{% if lang == 'fa' %}ایجاد{% else %}Create{% endif %}
</button>
</div>
</div>
</div>
<!-- Success Modal (shown once after create) -->
<div id="success-modal" class="fixed inset-0 bg-black/50 hidden items-center justify-center z-50">
<div class="bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700 w-full max-w-lg mx-4">
<div class="p-6 border-b border-gray-200 dark:border-gray-700">
<h3 class="text-lg font-semibold text-gray-900 dark:text-white flex items-center gap-2">
<svg class="w-5 h-5 text-green-500" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7"/>
</svg>
{% if lang == 'fa' %}کلاینت ساخته شد{% else %}Client Created{% endif %}
</h3>
</div>
<div class="p-6 space-y-4">
<div class="bg-yellow-50 dark:bg-yellow-500/10 border border-yellow-200 dark:border-yellow-500/30 rounded-lg p-3">
<p class="text-sm text-yellow-700 dark:text-yellow-400">
{% if lang == 'fa' %}Client Secret فقط یک‌بار نمایش داده می‌شود. الان کپی کنید.{% else %}Client Secret is shown only once. Copy it now.{% endif %}
</p>
</div>
<div>
<label class="block text-xs font-medium text-gray-500 dark:text-gray-400 mb-1">Client ID</label>
<div class="flex items-center gap-2">
<code id="new-client-id" class="flex-1 bg-gray-100 dark:bg-gray-900 border border-gray-200 dark:border-gray-700 rounded px-3 py-1.5 text-sm font-mono text-gray-800 dark:text-gray-200 overflow-x-auto"></code>
<button onclick="copyText('new-client-id')" class="px-3 py-1.5 bg-blue-600 hover:bg-blue-700 rounded text-white text-xs transition-colors">{% if lang == 'fa' %}کپی{% else %}Copy{% endif %}</button>
</div>
</div>
<div>
<label class="block text-xs font-medium text-gray-500 dark:text-gray-400 mb-1">Client Secret</label>
<div class="flex items-center gap-2">
<code id="new-client-secret" class="flex-1 bg-gray-100 dark:bg-gray-900 border border-gray-200 dark:border-gray-700 rounded px-3 py-1.5 text-sm font-mono text-gray-800 dark:text-gray-200 overflow-x-auto"></code>
<button onclick="copyText('new-client-secret')" class="px-3 py-1.5 bg-blue-600 hover:bg-blue-700 rounded text-white text-xs transition-colors">{% if lang == 'fa' %}کپی{% else %}Copy{% endif %}</button>
</div>
</div>
</div>
<div class="p-6 border-t border-gray-200 dark:border-gray-700 flex justify-end">
<button onclick="closeSuccessModal()"
class="px-4 py-2 bg-primary-600 hover:bg-primary-700 text-white rounded-lg text-sm font-medium transition-colors">
{% if lang == 'fa' %}متوجه شدم{% else %}Done{% endif %}
</button>
</div>
</div>
</div>
{% endblock %}
{% block scripts %}
<script>
function openCreateModal() {
document.getElementById('create-modal').classList.remove('hidden');
document.getElementById('create-modal').classList.add('flex');
document.getElementById('client-name').focus();
}
function closeCreateModal() {
document.getElementById('create-modal').classList.add('hidden');
document.getElementById('create-modal').classList.remove('flex');
document.getElementById('client-name').value = '';
document.getElementById('redirect-uris').value = '';
}
function closeSuccessModal() {
document.getElementById('success-modal').classList.add('hidden');
document.getElementById('success-modal').classList.remove('flex');
location.reload();
}
function copyText(elementId) {
const text = document.getElementById(elementId).textContent;
navigator.clipboard.writeText(text);
}
async function createClient() {
const name = document.getElementById('client-name').value.trim();
const uris = document.getElementById('redirect-uris').value.trim();
if (!name || !uris) {
alert('{% if lang == "fa" %}نام و Redirect URI الزامی است{% else %}Name and Redirect URI are required{% endif %}');
return;
}
try {
const resp = await fetch('/api/dashboard/user-oauth-clients/create', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ client_name: name, redirect_uris: uris }),
});
const data = await resp.json();
if (resp.ok) {
closeCreateModal();
document.getElementById('new-client-id').textContent = data.client_id;
document.getElementById('new-client-secret').textContent = data.client_secret;
document.getElementById('success-modal').classList.remove('hidden');
document.getElementById('success-modal').classList.add('flex');
} else {
alert(data.error || '{% if lang == "fa" %}خطا در ایجاد کلاینت{% else %}Failed to create client{% endif %}');
}
} catch (e) {
alert('Error: ' + e.message);
}
}
async function deleteClient(clientId) {
if (!confirm('{% if lang == "fa" %}این کلاینت حذف شود؟{% else %}Delete this OAuth client?{% endif %}')) return;
try {
const resp = await fetch('/api/dashboard/user-oauth-clients/' + clientId, { method: 'DELETE' });
if (resp.ok) {
document.getElementById('client-' + clientId).remove();
} else {
const data = await resp.json();
alert(data.error || '{% if lang == "fa" %}خطا در حذف{% else %}Delete failed{% endif %}');
}
} catch (e) {
alert('Error: ' + e.message);
}
}
</script>
{% endblock %}

View File

@@ -68,9 +68,57 @@
{% if state %} {% if state %}
<input type="hidden" name="state" value="{{ state }}"> <input type="hidden" name="state" value="{{ state }}">
{% endif %} {% endif %}
{% if resource %}
<input type="hidden" name="resource" value="{{ resource }}">
{% endif %}
<input type="hidden" name="csrf_token" value="{{ csrf_token }}"> <input type="hidden" name="csrf_token" value="{{ csrf_token }}">
<!-- API Key Input --> <!-- Authentication -->
{% if session_user %}
<!-- Session-based consent (user already logged in) -->
<div {% if lang == 'fa' %}dir="rtl"{% endif %}>
<div class="bg-green-50 dark:bg-green-500/10 border border-green-200 dark:border-green-500/30 rounded-lg p-4 mb-4">
<div class="flex items-center gap-3">
<svg class="w-5 h-5 text-green-600 dark:text-green-400 flex-shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7"/>
</svg>
<div>
<p class="font-medium text-green-800 dark:text-green-300">
{% if lang == 'fa' %}وارد شده به عنوان{% else %}Logged in as{% endif %}
</p>
<p class="text-sm text-green-700 dark:text-green-400">
{{ session_user.name or session_user.email or 'User' }}
{% if session_user.email %}
<span class="text-green-600 dark:text-green-500">({{ session_user.email }})</span>
{% endif %}
</p>
</div>
</div>
</div>
<input type="hidden" name="api_key" value="__session__" id="session_marker">
<p class="text-xs text-gray-500 dark:text-gray-400">
{% if lang == 'fa' %}
یا <a href="#" onclick="showApiKeyInput()" class="text-blue-600 dark:text-blue-400 hover:underline">با کلید API وارد شوید</a>
{% else %}
Or <a href="#" onclick="showApiKeyInput()" class="text-blue-600 dark:text-blue-400 hover:underline">enter an API key instead</a>
{% endif %}
</p>
</div>
<!-- Hidden API key input (shown when user clicks "enter an API key instead") -->
<div id="api-key-section" class="hidden" {% if lang == 'fa' %}dir="rtl"{% endif %}>
<label for="api_key_input" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
{{ t.enter_api_key }}
</label>
<input type="password" id="api_key_input" name="api_key_manual"
class="w-full px-4 py-3 border border-gray-300 dark:border-gray-600 rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-transparent dark:bg-gray-700 dark:text-white transition"
placeholder="{{ t.api_key_placeholder }}" autocomplete="off"
{% if lang == 'fa' %}dir="ltr" style="text-align: right;"{% endif %}>
<p class="text-xs text-gray-500 dark:text-gray-400 mt-2">{{ t.api_key_note }}</p>
</div>
{% else %}
<!-- API key mode (no active session) -->
<div {% if lang == 'fa' %}dir="rtl"{% endif %}> <div {% if lang == 'fa' %}dir="rtl"{% endif %}>
<label for="api_key" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2"> <label for="api_key" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
{{ t.enter_api_key }} {{ t.enter_api_key }}
@@ -88,7 +136,33 @@
<p class="text-xs text-gray-500 dark:text-gray-400 mt-2" {% if lang == 'fa' %}dir="rtl"{% endif %}> <p class="text-xs text-gray-500 dark:text-gray-400 mt-2" {% if lang == 'fa' %}dir="rtl"{% endif %}>
{{ t.api_key_note }} {{ t.api_key_note }}
</p> </p>
{% if return_url %}
<!-- Social login link -->
<div class="mt-3 pt-3 border-t border-gray-200 dark:border-gray-700">
<p class="text-sm text-gray-600 dark:text-gray-400">
{% if lang == 'fa' %}
یا با حساب کاربری وارد شوید:
{% else %}
Or log in with your account:
{% endif %}
</p>
<div class="flex gap-2 mt-2">
<a href="/auth/github?next={{ return_url | urlencode }}"
class="inline-flex items-center gap-2 px-3 py-1.5 bg-gray-800 hover:bg-gray-700 text-white text-sm rounded-lg transition-colors">
<svg class="w-4 h-4" fill="currentColor" viewBox="0 0 24 24"><path d="M12 0C5.374 0 0 5.373 0 12c0 5.302 3.438 9.8 8.207 11.387.599.111.793-.261.793-.577v-2.234c-3.338.726-4.033-1.416-4.033-1.416-.546-1.387-1.333-1.756-1.333-1.756-1.089-.745.083-.729.083-.729 1.205.084 1.839 1.237 1.839 1.237 1.07 1.834 2.807 1.304 3.492.997.107-.775.418-1.305.762-1.604-2.665-.305-5.467-1.334-5.467-5.931 0-1.311.469-2.381 1.236-3.221-.124-.303-.535-1.524.117-3.176 0 0 1.008-.322 3.301 1.23A11.509 11.509 0 0112 5.803c1.02.005 2.047.138 3.006.404 2.291-1.552 3.297-1.23 3.297-1.23.653 1.653.242 2.874.118 3.176.77.84 1.235 1.911 1.235 3.221 0 4.609-2.807 5.624-5.479 5.921.43.372.823 1.102.823 2.222v3.293c0 .319.192.694.801.576C20.566 21.797 24 17.3 24 12c0-6.627-5.373-12-12-12z"/></svg>
GitHub
</a>
<a href="/auth/google?next={{ return_url | urlencode }}"
class="inline-flex items-center gap-2 px-3 py-1.5 bg-white hover:bg-gray-50 text-gray-700 text-sm rounded-lg border border-gray-300 transition-colors">
<svg class="w-4 h-4" viewBox="0 0 24 24"><path fill="#4285F4" d="M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92a5.06 5.06 0 01-2.2 3.32v2.77h3.57c2.08-1.92 3.28-4.74 3.28-8.1z"/><path fill="#34A853" d="M12 23c2.97 0 5.46-.98 7.28-2.66l-3.57-2.77c-.98.66-2.23 1.06-3.71 1.06-2.86 0-5.29-1.93-6.16-4.53H2.18v2.84C3.99 20.53 7.7 23 12 23z"/><path fill="#FBBC05" d="M5.84 14.09c-.22-.66-.35-1.36-.35-2.09s.13-1.43.35-2.09V7.07H2.18C1.43 8.55 1 10.22 1 12s.43 3.45 1.18 4.93l2.85-2.22.81-.62z"/><path fill="#EA4335" d="M12 5.38c1.62 0 3.06.56 4.21 1.64l3.15-3.15C17.45 2.09 14.97 1 12 1 7.7 1 3.99 3.47 2.18 7.07l3.66 2.84c.87-2.6 3.3-4.53 6.16-4.53z"/></svg>
Google
</a>
</div> </div>
</div>
{% endif %}
</div>
{% endif %}
<!-- Action Buttons --> <!-- Action Buttons -->
<div class="flex gap-3 pt-4" {% if lang == 'fa' %}dir="rtl"{% endif %}> <div class="flex gap-3 pt-4" {% if lang == 'fa' %}dir="rtl"{% endif %}>
@@ -128,29 +202,51 @@
{% block scripts %} {% block scripts %}
<script> <script>
function showApiKeyInput() {
// Switch from session mode to API key input mode
document.getElementById('api-key-section').classList.remove('hidden');
// Remove the __session__ hidden input
var hiddenInput = document.getElementById('session_marker');
if (hiddenInput) hiddenInput.remove();
// Make the manual input the actual api_key field
var manualInput = document.getElementById('api_key_input');
if (manualInput) {
manualInput.name = 'api_key';
manualInput.required = true;
manualInput.focus();
}
}
// Client-side validation // Client-side validation
document.getElementById('authForm').addEventListener('submit', function(e) { document.getElementById('authForm').addEventListener('submit', function(e) {
const apiKey = document.getElementById('api_key').value; var action = e.submitter.value;
const action = e.submitter.value;
if (action === 'approve') { if (action === 'approve') {
// Session-based consent — no API key validation needed
var sessionInput = document.getElementById('session_marker');
if (sessionInput) {
return true;
}
var apiKeyEl = document.getElementById('api_key') || document.getElementById('api_key_input');
var apiKey = apiKeyEl ? apiKeyEl.value : '';
if (!apiKey || apiKey.length < 10) { if (!apiKey || apiKey.length < 10) {
e.preventDefault(); e.preventDefault();
const lang = '{{ lang }}'; var lang = '{{ lang }}';
const message = lang === 'fa' var message = lang === 'fa'
? 'لطفاً یک API Key معتبر وارد کنید' ? 'لطفاً یک API Key معتبر وارد کنید'
: 'Please enter a valid API key'; : 'Please enter a valid API key';
alert(message); alert(message);
return false; return false;
} }
if (!apiKey.startsWith('cmp_') && !apiKey.startsWith('ckm_')) { if (!apiKey.startsWith('cmp_') && !apiKey.startsWith('ckm_') && !apiKey.startsWith('mhu_')) {
const lang = '{{ lang }}'; var lang2 = '{{ lang }}';
const message = lang === 'fa' var msg = lang2 === 'fa'
? 'فرمت API Key غیرمعمول به نظر می‌رسد. ادامه می‌دهید؟' ? 'فرمت API Key غیرمعمول به نظر می‌رسد. ادامه می‌دهید؟'
: 'The API key format looks unusual. Continue anyway?'; : 'The API key format looks unusual. Continue anyway?';
const confirm = window.confirm(message); if (!window.confirm(msg)) {
if (!confirm) {
e.preventDefault(); e.preventDefault();
return false; return false;
} }
@@ -158,13 +254,15 @@
} }
}); });
// Focus effect // Focus effect for API key input (if present)
const apiKeyInput = document.getElementById('api_key'); var apiKeyInput = document.getElementById('api_key');
if (apiKeyInput) {
apiKeyInput.addEventListener('focus', function() { apiKeyInput.addEventListener('focus', function() {
this.parentElement.classList.add('ring-2', 'ring-purple-200'); this.parentElement.classList.add('ring-2', 'ring-purple-200');
}); });
apiKeyInput.addEventListener('blur', function() { apiKeyInput.addEventListener('blur', function() {
this.parentElement.classList.remove('ring-2', 'ring-purple-200'); this.parentElement.classList.remove('ring-2', 'ring-purple-200');
}); });
}
</script> </script>
{% endblock %} {% endblock %}

View File

@@ -210,6 +210,11 @@ async def user_mcp_handler(request: Request) -> Response:
api_key = auth_header[7:] # Strip "Bearer " api_key = auth_header[7:] # Strip "Bearer "
# Shared scope tracking — set by whichever auth path succeeds
key_scopes: list[str] = []
# Try mhu_ API key first, then fall back to OAuth JWT token
if api_key.startswith("mhu_"):
try: try:
from core.user_keys import get_user_key_manager from core.user_keys import get_user_key_manager
@@ -227,12 +232,47 @@ async def user_mcp_handler(request: Request) -> Response:
status_code=401, status_code=401,
) )
# Ensure the API key belongs to the user in the URL
if key_info["user_id"] != user_id: if key_info["user_id"] != user_id:
return JSONResponse( return JSONResponse(
_jsonrpc_error(None, -32600, "API key does not match user"), _jsonrpc_error(None, -32600, "API key does not match user"),
status_code=403, status_code=403,
) )
key_scopes = key_info.get("scopes", "read").split()
else:
# Try OAuth JWT token (issued after consent flow via GitHub/Google login)
try:
import jwt as pyjwt
from core.oauth import get_token_manager
token_manager = get_token_manager()
jwt_payload = token_manager.validate_access_token(api_key)
# sub = "user:{uuid}" — extract actual user_id
sub = jwt_payload.get("sub", "")
if not sub.startswith("user:"):
return JSONResponse(
_jsonrpc_error(None, -32600, "Token not authorized for user endpoint"),
status_code=403,
)
jwt_user_id = sub[len("user:") :]
if jwt_user_id != user_id:
return JSONResponse(
_jsonrpc_error(None, -32600, "Token user mismatch"),
status_code=403,
)
key_scopes = jwt_payload.get("scope", "read").split()
except pyjwt.ExpiredSignatureError:
return JSONResponse(
_jsonrpc_error(None, -32600, "Token expired"),
status_code=401,
)
except Exception:
return JSONResponse(
_jsonrpc_error(None, -32600, "Invalid token"),
status_code=401,
)
# --- Rate Limiting --- # --- Rate Limiting ---
allowed, rate_msg = _check_user_rate_limit(user_id) allowed, rate_msg = _check_user_rate_limit(user_id)
@@ -325,7 +365,7 @@ async def user_mcp_handler(request: Request) -> Response:
return JSONResponse(_jsonrpc_error(req_id, -32601, f"Tool '{tool_name}' not found")) return JSONResponse(_jsonrpc_error(req_id, -32601, f"Tool '{tool_name}' not found"))
required_scope = tool_def.required_scope required_scope = tool_def.required_scope
key_scopes = key_info.get("scopes", "").split() # key_scopes is set during authentication (both mhu_ and JWT paths)
scope_hierarchy = {"read": 1, "write": 2, "admin": 3} scope_hierarchy = {"read": 1, "write": 2, "admin": 3}
required_level = scope_hierarchy.get(required_scope, 0) required_level = scope_hierarchy.get(required_scope, 0)

View File

@@ -42,6 +42,27 @@ For each WordPress site you want to manage:
## Installation ## Installation
### Option 0: Use Hosted Version (No Setup Required)
The fastest way to try MCP Hub — no installation needed:
1. Visit **[mcp.example.com](https://mcp.example.com)**
2. Log in with **GitHub** or **Google**
3. Add your sites via the dashboard (**My Sites → Add Service**)
4. Go to **Connect** page to generate your AI client config
5. Copy-paste into Claude Desktop, VS Code, or Claude Code
Your personal MCP endpoint:
```
https://mcp.example.com/u/{your-user-id}/{alias}/mcp
```
**WordPress users:** Install the [Airano MCP SEO Bridge](https://wordpress.org/plugins/airano-mcp-seo-bridge/) plugin for SEO tools, and create an [Application Password](https://make.wordpress.org/core/2020/11/05/application-passwords-integration-guide/) (Users → Profile) for authentication.
> If you prefer self-hosting, continue with the options below.
---
### Option 1: Docker (Recommended) ### Option 1: Docker (Recommended)
```bash ```bash
@@ -162,6 +183,21 @@ GITEA_REPO1_ALIAS=mygitea
OAUTH_JWT_SECRET_KEY=your-jwt-secret OAUTH_JWT_SECRET_KEY=your-jwt-secret
OAUTH_BASE_URL=https://your-server:8000 OAUTH_BASE_URL=https://your-server:8000
# ============================================
# Social Login (GitHub/Google OAuth — for hosted/multi-user)
# ============================================
GITHUB_CLIENT_ID=your-github-oauth-app-id
GITHUB_CLIENT_SECRET=your-github-oauth-app-secret
GOOGLE_CLIENT_ID=your-google-oauth-client-id
GOOGLE_CLIENT_SECRET=your-google-oauth-client-secret
PUBLIC_URL=https://your-server:8000
# ============================================
# Encryption (required for storing user site credentials)
# ============================================
# Generate: python -c "import os, base64; print(base64.b64encode(os.urandom(32)).decode())"
ENCRYPTION_KEY=your-base64-encoded-32-byte-key
# ============================================ # ============================================
# Optional # Optional
# ============================================ # ============================================
@@ -327,13 +363,15 @@ curl http://localhost:8000/health
**2. Open the web dashboard:** **2. Open the web dashboard:**
Open **http://localhost:8000/dashboard** in your browser. Log in with your `MASTER_API_KEY`. Open **http://localhost:8000/dashboard** in your browser. Log in with your `MASTER_API_KEY` or via **GitHub/Google OAuth** (if configured).
The dashboard lets you: The dashboard lets you:
- View all connected sites and their health status - View all connected sites and their health status
- **Add, edit, delete, and test sites** via the My Sites page (OAuth users)
- Create and manage per-project API keys - Create and manage per-project API keys
- View audit logs - **Generate AI client configs** on the Connect page (with correct transport type per client)
- Monitor rate limits - View audit logs and monitor rate limits
- **Per-user MCP endpoints**: OAuth users get personal endpoints at `/u/{user_id}/{alias}/mcp` with `mhu_` API keys
**3. Check container status (Docker only):** **3. Check container status (Docker only):**

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,6 +770,11 @@ 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:
# A 404 on the PostgREST root still confirms connectivity — some versions
# 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["services"]["postgrest"] = f"error: {str(e)}"
results["healthy"] = False results["healthy"] = False

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

@@ -47,7 +47,7 @@ class WooCommercePlugin(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", "username", "app_password"] return ["url"]
def __init__(self, config: dict[str, Any], project_id: str | None = None): def __init__(self, config: dict[str, Any], project_id: str | None = None):
""" """
@@ -55,16 +55,30 @@ class WooCommercePlugin(BasePlugin):
Args: Args:
config: Configuration dictionary containing: config: Configuration dictionary containing:
- url: WordPress site URL - url: WordPress/WooCommerce site URL
- username: WordPress username - consumer_key/consumer_secret: WooCommerce REST API keys (preferred)
- app_password: WordPress application password - username/app_password: WordPress application password (fallback)
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)
# Create WordPress API client (WooCommerce uses WordPress REST API) # WooCommerce supports two credential formats:
# 1. consumer_key/consumer_secret (WooCommerce REST API keys — preferred)
# 2. username/app_password (WordPress Application Passwords — legacy fallback)
username = config.get("consumer_key") or config.get("username")
password = config.get("consumer_secret") or config.get("app_password")
if not username or not password:
from plugins.wordpress.client import ConfigurationError
raise ConfigurationError(
"WooCommerce credentials not configured. "
"Please set either CONSUMER_KEY/CONSUMER_SECRET or USERNAME/APP_PASSWORD."
)
# Create WordPress API client
self.client = WordPressClient( self.client = WordPressClient(
site_url=config["url"], username=config["username"], app_password=config["app_password"] site_url=config["url"], username=username, app_password=password
) )
# Initialize WooCommerce handlers # Initialize WooCommerce handlers

View File

@@ -127,7 +127,7 @@ class WordPressClient:
""" """
# Build URL based on endpoint type # Build URL based on endpoint type
if use_custom_namespace: if use_custom_namespace:
# For custom namespaces like seo-api-bridge/v1 # For custom namespaces like airano-mcp-seo-bridge/v1
url = f"{self.site_url}/wp-json/{endpoint}" url = f"{self.site_url}/wp-json/{endpoint}"
elif use_woocommerce: elif use_woocommerce:
# For WooCommerce endpoints # For WooCommerce endpoints

View File

@@ -188,7 +188,7 @@ class SEOHandler:
# First, try to use the new health check endpoint (v1.1.0+) # First, try to use the new health check endpoint (v1.1.0+)
try: try:
status_result = await self.client.get( status_result = await self.client.get(
"seo-api-bridge/v1/status", use_custom_namespace=True "airano-mcp-seo-bridge/v1/status", use_custom_namespace=True
) )
if status_result and isinstance(status_result, dict): if status_result and isinstance(status_result, dict):
@@ -383,7 +383,7 @@ class SEOHandler:
try: try:
# Use SEO API Bridge endpoint for products (same as update_product_seo) # Use SEO API Bridge endpoint for products (same as update_product_seo)
result = await self.client.get( result = await self.client.get(
f"seo-api-bridge/v1/products/{product_id}/seo", use_custom_namespace=True f"airano-mcp-seo-bridge/v1/products/{product_id}/seo", use_custom_namespace=True
) )
return json.dumps(result, indent=2) return json.dumps(result, indent=2)
@@ -592,7 +592,7 @@ class SEOHandler:
# Use SEO API Bridge endpoint for products # Use SEO API Bridge endpoint for products
await self.client.post( await self.client.post(
f"seo-api-bridge/v1/products/{product_id}/seo", f"airano-mcp-seo-bridge/v1/products/{product_id}/seo",
json_data=data, json_data=data,
use_custom_namespace=True, use_custom_namespace=True,
) )

View File

@@ -1,6 +1,6 @@
[project] [project]
name = "mcphub-server" name = "mcphub-server"
version = "3.1.0" version = "3.2.0"
description = "AI-native management hub for WordPress, WooCommerce, and self-hosted services via Model Context Protocol (MCP)" description = "AI-native management hub for WordPress, WooCommerce, and self-hosted services via Model Context Protocol (MCP)"
authors = [ authors = [
{name = "MCP Hub", email = "contact@mcphub.dev"} {name = "MCP Hub", email = "contact@mcphub.dev"}

246
server.py
View File

@@ -18,6 +18,7 @@ Environment Variables:
LOG_LEVEL: Logging level (DEBUG, INFO, WARNING, ERROR) LOG_LEVEL: Logging level (DEBUG, INFO, WARNING, ERROR)
""" """
import base64
import copy import copy
import logging import logging
import os import os
@@ -66,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,
@@ -106,7 +108,12 @@ 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
dashboard_user_oauth_clients_create,
dashboard_user_oauth_clients_delete,
dashboard_user_oauth_clients_list,
) )
from core.database import get_database, initialize_database from core.database import get_database, initialize_database
from core.i18n import detect_language, get_all_translations from core.i18n import detect_language, get_all_translations
@@ -2018,6 +2025,7 @@ async def oauth_metadata(request: Request) -> JSONResponse:
"authorization_endpoint": f"{base_url}/oauth/authorize", "authorization_endpoint": f"{base_url}/oauth/authorize",
"token_endpoint": f"{base_url}/oauth/token", "token_endpoint": f"{base_url}/oauth/token",
"registration_endpoint": f"{base_url}/oauth/register", # RFC 7591 (requires auth) "registration_endpoint": f"{base_url}/oauth/register", # RFC 7591 (requires auth)
"revocation_endpoint": f"{base_url}/oauth/revoke",
# Supported Features # Supported Features
"response_types_supported": ["code"], "response_types_supported": ["code"],
"grant_types_supported": ["authorization_code", "refresh_token", "client_credentials"], "grant_types_supported": ["authorization_code", "refresh_token", "client_credentials"],
@@ -2025,7 +2033,7 @@ async def oauth_metadata(request: Request) -> JSONResponse:
"scopes_supported": ["read", "write", "admin"], "scopes_supported": ["read", "write", "admin"],
"token_endpoint_auth_methods_supported": ["client_secret_post", "client_secret_basic"], "token_endpoint_auth_methods_supported": ["client_secret_post", "client_secret_basic"],
# Token Configuration # Token Configuration
"revocation_endpoint_auth_methods_supported": ["client_secret_post"], "revocation_endpoint_auth_methods_supported": ["client_secret_post", "client_secret_basic"],
"introspection_endpoint_auth_methods_supported": ["client_secret_post"], "introspection_endpoint_auth_methods_supported": ["client_secret_post"],
# Additional OAuth 2.1 Features # Additional OAuth 2.1 Features
"response_modes_supported": ["query"], "response_modes_supported": ["query"],
@@ -2380,9 +2388,49 @@ async def oauth_authorize(request: Request):
state=params.get("state"), state=params.get("state"),
) )
# RFC 8707: Accept resource parameter
resource = params.get("resource")
if resource:
expected_resource = get_oauth_base_url(request)
if resource.rstrip("/") != expected_resource.rstrip("/"):
logger.warning(
f"OAuth resource mismatch: got {resource}, expected {expected_resource}"
)
# Generate CSRF token # Generate CSRF token
csrf_token = csrf_manager.generate_token() csrf_token = csrf_manager.generate_token()
# Detect existing dashboard session for session-based consent
session_user = None
try:
from core.dashboard.auth import get_dashboard_auth
dashboard_auth = get_dashboard_auth()
# Try OAuth user session first (GitHub/Google login)
user_session = dashboard_auth.get_user_session_from_request(request)
if user_session and user_session.get("user_id"):
session_user = {
"user_id": user_session["user_id"],
"email": user_session.get("email", ""),
"name": user_session.get("name", ""),
"role": user_session.get("role", "user"),
"type": "oauth_user",
}
else:
# Try admin session
admin_session = dashboard_auth.get_session_from_request(request)
if admin_session:
session_user = {
"user_id": "admin",
"email": "",
"name": "Admin",
"role": "admin",
"type": admin_session.user_type,
}
except Exception as e:
logger.debug(f"No dashboard session detected for OAuth consent: {e}")
# Get client info # Get client info
from core.oauth import get_client_registry from core.oauth import get_client_registry
@@ -2417,9 +2465,13 @@ async def oauth_authorize(request: Request):
"scope": validated["scope"], "scope": validated["scope"],
"scopes": scopes, "scopes": scopes,
"state": validated.get("state", ""), "state": validated.get("state", ""),
"resource": resource or "",
"csrf_token": csrf_token, "csrf_token": csrf_token,
"lang": lang, # Language code (en/fa) "lang": lang, # Language code (en/fa)
"t": translations, # All translations for this language "t": translations, # All translations for this language
"session_user": session_user,
"return_url": str(request.url.path)
+ (("?" + str(request.url.query)) if request.url.query else ""),
}, },
) )
@@ -2493,6 +2545,7 @@ async def oauth_authorize_confirm(request: Request):
# Parse form data # Parse form data
form = await request.form() form = await request.form()
params = dict(form) params = dict(form)
resource = params.get("resource", "")
# Validate action # Validate action
action = params.get("action") action = params.get("action")
@@ -2543,10 +2596,12 @@ async def oauth_authorize_confirm(request: Request):
state=params.get("state"), state=params.get("state"),
) )
# Validate API Key # Validate authentication (API Key or dashboard session)
api_key = params.get("api_key") api_key = params.get("api_key", "")
if not api_key: if not api_key:
raise OAuthError(error="invalid_request", error_description="API Key is required") raise OAuthError(
error="invalid_request", error_description="Authentication is required"
)
api_key_id = None api_key_id = None
api_key_project_id = None api_key_project_id = None
@@ -2554,13 +2609,12 @@ async def oauth_authorize_confirm(request: Request):
granted_scope = validated["scope"] granted_scope = validated["scope"]
try: try:
# Try as regular API key first # Path 1: Project API key (cmp_)
if api_key.startswith("cmp_"): if api_key.startswith("cmp_"):
# Validate without project/scope check (we'll use its values)
key_id = api_key_manager.validate_key( key_id = api_key_manager.validate_key(
api_key, api_key,
project_id="*", # Skip project check project_id="*",
required_scope="read", # Skip scope check required_scope="read",
skip_project_check=True, skip_project_check=True,
) )
api_key_info = api_key_manager.keys.get(key_id) api_key_info = api_key_manager.keys.get(key_id)
@@ -2568,9 +2622,67 @@ async def oauth_authorize_confirm(request: Request):
api_key_project_id = api_key_info.project_id api_key_project_id = api_key_info.project_id
api_key_scope = api_key_info.scope api_key_scope = api_key_info.scope
# Try as Master API Key # Path 2: User API key (mhu_)
elif api_key.startswith("mhu_"):
from core.user_keys import get_user_key_manager
user_key_mgr = get_user_key_manager()
key_info = await user_key_mgr.validate_key(api_key)
if key_info:
api_key_id = f"user:{key_info['user_id']}"
api_key_project_id = "*"
api_key_scope = key_info.get("scopes", "read write")
logger.info(
f"OAuth authorization: User API key validated - "
f"user_id={key_info['user_id']}, scope={api_key_scope}"
)
else:
raise OAuthError(
error="access_denied",
error_description="Invalid or expired User API Key.",
)
# Path 3: Dashboard session (social login)
elif api_key == "__session__":
from core.dashboard.auth import get_dashboard_auth
dashboard_auth = get_dashboard_auth()
# Try OAuth user session (GitHub/Google)
user_session = dashboard_auth.get_user_session_from_request(request)
if user_session and user_session.get("user_id"):
user_role = user_session.get("role", "user")
api_key_id = f"user:{user_session['user_id']}"
api_key_project_id = "*"
if user_role == "admin":
api_key_scope = "read write admin"
else:
api_key_scope = "read write"
logger.info(
f"OAuth authorization: Session-based consent - "
f"user_id={user_session['user_id']}, "
f"email={user_session.get('email', '')}, "
f"role={user_role}, scope={api_key_scope}"
)
else:
# Try admin session
admin_session = dashboard_auth.get_session_from_request(request)
if admin_session:
api_key_id = f"admin:{admin_session.session_id}"
api_key_project_id = "*"
api_key_scope = "read write admin"
logger.info(
f"OAuth authorization: Admin session consent - "
f"type={admin_session.user_type}"
)
else:
raise OAuthError(
error="access_denied",
error_description="Session expired. Please log in again.",
)
# Path 4: Master API Key
elif auth_manager.validate_master_key(api_key): elif auth_manager.validate_master_key(api_key):
# Master key → Full access
api_key_id = "master" api_key_id = "master"
api_key_project_id = "*" api_key_project_id = "*"
api_key_scope = "read write admin" api_key_scope = "read write admin"
@@ -2616,6 +2728,7 @@ async def oauth_authorize_confirm(request: Request):
api_key_id=api_key_id, api_key_id=api_key_id,
api_key_project_id=api_key_project_id, api_key_project_id=api_key_project_id,
api_key_scope=api_key_scope, api_key_scope=api_key_scope,
resource=resource,
) )
# Redirect back with authorization code # Redirect back with authorization code
@@ -2708,9 +2821,27 @@ async def oauth_token(request: Request) -> JSONResponse:
error="invalid_request", error_description="Missing grant_type parameter" error="invalid_request", error_description="Missing grant_type parameter"
) )
# Support client_secret_basic: extract client credentials from Authorization header
# RFC 6749 Section 2.3.1 — client_id:client_secret as HTTP Basic Auth
if "client_id" not in body or "client_secret" not in body:
auth_header = request.headers.get("authorization", "")
if auth_header.startswith("Basic "):
try:
decoded = base64.b64decode(auth_header[6:]).decode("utf-8")
basic_client_id, basic_client_secret = decoded.split(":", 1)
body.setdefault("client_id", basic_client_id)
body.setdefault("client_secret", basic_client_secret)
except Exception:
raise OAuthError(
error="invalid_client",
error_description="Invalid Basic authentication header",
status_code=401,
)
if "client_id" not in body or "client_secret" not in body: if "client_id" not in body or "client_secret" not in body:
raise OAuthError( raise OAuthError(
error="invalid_request", error_description="Missing client_id or client_secret" error="invalid_request",
error_description="Missing client_id or client_secret",
) )
grant_type = body["grant_type"] grant_type = body["grant_type"]
@@ -2773,6 +2904,79 @@ async def oauth_token(request: Request) -> JSONResponse:
return JSONResponse({"error": "server_error", "error_description": str(e)}, status_code=500) return JSONResponse({"error": "server_error", "error_description": str(e)}, status_code=500)
@mcp.custom_route("/oauth/revoke", methods=["POST"])
async def oauth_revoke(request: Request) -> JSONResponse:
"""OAuth 2.0 Token Revocation (RFC 7009)."""
try:
content_type = request.headers.get("content-type", "")
if "application/json" in content_type:
body = await request.json()
else:
form = await request.form()
body = dict(form)
# Support client_secret_basic
if "client_id" not in body or "client_secret" not in body:
auth_header = request.headers.get("authorization", "")
if auth_header.startswith("Basic "):
try:
decoded = base64.b64decode(auth_header[6:]).decode("utf-8")
basic_client_id, basic_client_secret = decoded.split(":", 1)
body.setdefault("client_id", basic_client_id)
body.setdefault("client_secret", basic_client_secret)
except Exception:
pass # Per RFC 7009, don't error on this
# Validate client credentials
client_id = body.get("client_id")
client_secret = body.get("client_secret")
if not client_id or not client_secret:
raise OAuthError(
error="invalid_client",
error_description="Client authentication required",
status_code=401,
)
from core.oauth import get_client_registry
client_registry = get_client_registry()
if not client_registry.validate_client_secret(client_id, client_secret):
raise OAuthError(
error="invalid_client",
error_description="Invalid client credentials",
status_code=401,
)
# Get token to revoke
token = body.get("token")
if not token:
return JSONResponse({}, status_code=200)
token_type_hint = body.get("token_type_hint", "")
# Revoke the token
from core.oauth import get_token_manager
token_manager = get_token_manager()
# Try revoking as refresh token (access tokens are JWT/stateless, can't be revoked)
token_manager.revoke_token(token, token_type="refresh")
logger.info(f"OAuth token revoked: client_id={client_id}, hint={token_type_hint}")
return JSONResponse({}, status_code=200)
except OAuthError as e:
return JSONResponse(
{"error": e.error, "error_description": e.error_description},
status_code=e.status_code,
)
except Exception as e:
logger.error(f"Token revocation error: {e}")
return JSONResponse({}, status_code=200)
# === OAUTH CLIENT MANAGEMENT TOOLS === # === OAUTH CLIENT MANAGEMENT TOOLS ===
@@ -4590,7 +4794,14 @@ 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)
Route(
"/dashboard/connect/oauth-clients",
dashboard_user_oauth_clients_list,
methods=["GET"],
),
Route("/dashboard/connect", dashboard_connect_page, methods=["GET"]), Route("/dashboard/connect", dashboard_connect_page, methods=["GET"]),
Route("/dashboard", dashboard_home, methods=["GET"]), Route("/dashboard", dashboard_home, methods=["GET"]),
Route("/dashboard/", dashboard_home, methods=["GET"]), Route("/dashboard/", dashboard_home, methods=["GET"]),
@@ -4627,6 +4838,17 @@ def create_multi_endpoint_app(transport: str = "streamable-http"):
dashboard_oauth_clients_delete, dashboard_oauth_clients_delete,
methods=["DELETE"], methods=["DELETE"],
), ),
# Bug C: User OAuth client API routes
Route(
"/api/dashboard/user-oauth-clients/create",
dashboard_user_oauth_clients_create,
methods=["POST"],
),
Route(
"/api/dashboard/user-oauth-clients/{client_id}",
dashboard_user_oauth_clients_delete,
methods=["DELETE"],
),
# Dashboard Audit Logs routes (Phase K.4) # Dashboard Audit Logs routes (Phase K.4)
Route("/dashboard/audit-logs", dashboard_audit_logs_list, methods=["GET"]), Route("/dashboard/audit-logs", dashboard_audit_logs_list, methods=["GET"]),
Route("/api/dashboard/audit-logs", dashboard_api_audit_logs, methods=["GET"]), Route("/api/dashboard/audit-logs", dashboard_api_audit_logs, methods=["GET"]),
@@ -4641,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"]),
@@ -4661,6 +4884,7 @@ def create_multi_endpoint_app(transport: str = "streamable-http"):
Route("/oauth/authorize", oauth_authorize, methods=["GET"]), Route("/oauth/authorize", oauth_authorize, methods=["GET"]),
Route("/oauth/authorize/confirm", oauth_authorize_confirm, methods=["POST"]), Route("/oauth/authorize/confirm", oauth_authorize_confirm, methods=["POST"]),
Route("/oauth/token", oauth_token, methods=["POST"]), Route("/oauth/token", oauth_token, methods=["POST"]),
Route("/oauth/revoke", oauth_revoke, methods=["POST"]),
# Multi-Endpoint MCP (Phase X + D.1 + X.3 + F + G + H + I + J) # Multi-Endpoint MCP (Phase X + D.1 + X.3 + F + G + H + I + J)
# Mount each FastMCP app - they handle their own /mcp path internally # Mount each FastMCP app - they handle their own /mcp path internally
Mount("/system", app=system_app, name="mcp_system"), # Phase X.3 Mount("/system", app=system_app, name="mcp_system"), # Phase X.3

View File

@@ -60,16 +60,18 @@ class TestClaudeFormat:
assert server_name in config["mcpServers"] assert server_name in config["mcpServers"]
server = config["mcpServers"][server_name] server = config["mcpServers"][server_name]
assert server["url"] == EXPECTED_ENDPOINT assert server["url"] == EXPECTED_ENDPOINT
assert server["type"] == "streamableHttp"
assert f"Bearer {API_KEY}" in server["headers"]["Authorization"] assert f"Bearer {API_KEY}" in server["headers"]["Authorization"]
@pytest.mark.unit @pytest.mark.unit
def test_claude_code_format(self): def test_claude_code_format(self):
"""Claude Code config should use the same mcpServers format.""" """Claude Code config should use the same mcpServers format with http type."""
snippet = generate_config(BASE_URL, USER_ID, ALIAS, API_KEY, "claude_code") snippet = generate_config(BASE_URL, USER_ID, ALIAS, API_KEY, "claude_code")
config = json.loads(snippet) config = json.loads(snippet)
assert "mcpServers" in config assert "mcpServers" in config
server_name = f"mcphub-{ALIAS}" server_name = f"mcphub-{ALIAS}"
assert server_name in config["mcpServers"] assert server_name in config["mcpServers"]
assert config["mcpServers"][server_name]["type"] == "http"
# ── Cursor / VS Code Format ───────────────────────────────── # ── Cursor / VS Code Format ─────────────────────────────────
@@ -80,7 +82,7 @@ class TestCursorVSCodeFormat:
@pytest.mark.unit @pytest.mark.unit
def test_cursor_format(self): def test_cursor_format(self):
"""Cursor config should be valid JSON with mcp.servers.""" """Cursor config should be valid JSON with mcp.servers and http type."""
snippet = generate_config(BASE_URL, USER_ID, ALIAS, API_KEY, "cursor") snippet = generate_config(BASE_URL, USER_ID, ALIAS, API_KEY, "cursor")
config = json.loads(snippet) config = json.loads(snippet)
assert "mcp" in config assert "mcp" in config
@@ -89,15 +91,18 @@ class TestCursorVSCodeFormat:
assert server_name in config["mcp"]["servers"] assert server_name in config["mcp"]["servers"]
server = config["mcp"]["servers"][server_name] server = config["mcp"]["servers"][server_name]
assert server["url"] == EXPECTED_ENDPOINT assert server["url"] == EXPECTED_ENDPOINT
assert server["type"] == "http"
assert f"Bearer {API_KEY}" in server["headers"]["Authorization"] assert f"Bearer {API_KEY}" in server["headers"]["Authorization"]
@pytest.mark.unit @pytest.mark.unit
def test_vscode_format(self): def test_vscode_format(self):
"""VS Code config should use the same mcp.servers format as Cursor.""" """VS Code config should use mcp.servers format with http type."""
snippet = generate_config(BASE_URL, USER_ID, ALIAS, API_KEY, "vscode") snippet = generate_config(BASE_URL, USER_ID, ALIAS, API_KEY, "vscode")
config = json.loads(snippet) config = json.loads(snippet)
assert "mcp" in config assert "mcp" in config
assert "servers" in config["mcp"] assert "servers" in config["mcp"]
server_name = f"mcphub-{ALIAS}"
assert config["mcp"]["servers"][server_name]["type"] == "http"
# ── ChatGPT Format ─────────────────────────────────────────── # ── ChatGPT Format ───────────────────────────────────────────

View File

@@ -0,0 +1,165 @@
"""Tests for client_secret_basic (HTTP Basic Auth) on the OAuth token endpoint."""
import base64
import os
import tempfile
from unittest.mock import AsyncMock, MagicMock
import pytest
def _make_request(headers=None, body=None, content_type="application/x-www-form-urlencoded"):
"""Create a mock Starlette Request for oauth_token."""
request = AsyncMock()
request.headers = MagicMock()
_headers = {"content-type": content_type}
if headers:
_headers.update(headers)
request.headers.get = lambda key, default="": _headers.get(key.lower(), default)
form_data = body or {}
form = AsyncMock(return_value=form_data)
request.form = form
return request
def _encode_basic(client_id: str, client_secret: str) -> str:
"""Encode client_id:client_secret as Basic Auth header value."""
raw = f"{client_id}:{client_secret}"
encoded = base64.b64encode(raw.encode("utf-8")).decode("utf-8")
return f"Basic {encoded}"
@pytest.fixture
def temp_storage():
"""Create temporary storage for OAuth tests."""
with tempfile.TemporaryDirectory() as tmpdir:
os.environ["OAUTH_STORAGE_PATH"] = tmpdir
os.environ["OAUTH_JWT_SECRET_KEY"] = "test_secret_key_for_basic_auth_tests"
from core.oauth import client_registry, server, storage, token_manager
client_registry._client_registry = None
token_manager._token_manager = None
storage._storage = None
server._oauth_server = None
yield tmpdir
os.environ.pop("OAUTH_STORAGE_PATH", None)
os.environ.pop("OAUTH_JWT_SECRET_KEY", None)
client_registry._client_registry = None
token_manager._token_manager = None
storage._storage = None
server._oauth_server = None
async def _call_oauth_token(request):
"""Import and call the oauth_token endpoint from server.py."""
from server import oauth_token
return await oauth_token(request)
@pytest.mark.unit
class TestClientSecretBasicAuth:
"""Tests for client_secret_basic authentication on the token endpoint."""
async def test_basic_auth_header_parsed(self, temp_storage):
"""Basic Auth header is correctly parsed (base64 encoded client_id:client_secret)."""
request = _make_request(
headers={"authorization": _encode_basic("my_client_id", "my_client_secret")},
body={"grant_type": "client_credentials"},
)
response = await _call_oauth_token(request)
# The request will likely fail on actual client validation,
# but we're testing that it gets past the credential parsing stage.
# If it returned invalid_request with "Missing client_id", Basic Auth parsing failed.
import json
data = json.loads(response.body)
assert data.get("error") != "invalid_request" or "Missing client_id" not in data.get(
"error_description", ""
)
async def test_body_params_take_priority_over_basic_auth(self, temp_storage):
"""Body params take priority over Basic Auth (setdefault behavior)."""
request = _make_request(
headers={"authorization": _encode_basic("basic_id", "basic_secret")},
body={
"grant_type": "client_credentials",
"client_id": "body_id",
"client_secret": "body_secret",
},
)
response = await _call_oauth_token(request)
import json
data = json.loads(response.body)
# The body params should be used, not the Basic Auth ones.
# If the error mentions "body_id", body params were used.
# If it mentions "basic_id", Basic Auth overrode body params (wrong).
# Since the client won't exist, we expect invalid_client error.
# The key check: it should NOT have replaced body_id with basic_id.
if data.get("error") == "invalid_client":
# The error came from actual client validation, meaning
# credentials were parsed. The body params were used.
pass
else:
# Should not get "Missing client_id" error
assert "Missing client_id" not in data.get("error_description", "")
async def test_malformed_basic_auth_returns_invalid_client(self, temp_storage):
"""Malformed Basic Auth header returns invalid_client with 401 status."""
request = _make_request(
headers={"authorization": "Basic !!!not-valid-base64!!!"},
body={"grant_type": "client_credentials"},
)
response = await _call_oauth_token(request)
import json
data = json.loads(response.body)
assert data["error"] == "invalid_client"
assert "Invalid Basic authentication header" in data["error_description"]
assert response.status_code == 401
async def test_missing_credentials_returns_invalid_request(self, temp_storage):
"""Missing credentials (no body, no Basic header) returns invalid_request."""
request = _make_request(
body={"grant_type": "client_credentials"},
)
response = await _call_oauth_token(request)
import json
data = json.loads(response.body)
assert data["error"] == "invalid_request"
assert "Missing client_id or client_secret" in data["error_description"]
async def test_client_secret_with_colon(self, temp_storage):
"""client_secret containing ':' is handled correctly (split on first ':' only)."""
secret_with_colon = "my:secret:with:colons"
request = _make_request(
headers={"authorization": _encode_basic("my_client_id", secret_with_colon)},
body={"grant_type": "client_credentials"},
)
response = await _call_oauth_token(request)
import json
data = json.loads(response.body)
# Should not get invalid_request about missing credentials
assert data.get("error") != "invalid_request" or "Missing client_id" not in data.get(
"error_description", ""
)
# Should not get invalid_client from Basic Auth parsing
assert data.get("error_description") != "Invalid Basic authentication header"

View File

@@ -0,0 +1,333 @@
"""
Tests for RFC 8707 resource parameter support in OAuth 2.1 flow.
Validates that the resource parameter is:
1. Accepted and stored in authorization codes
2. Passed through to JWT access tokens as aud claim
3. Optional -- existing flows without resource continue to work
"""
import os
import tempfile
from datetime import UTC, datetime, timedelta
import jwt
import pytest
from core.oauth.schemas import AuthorizationCode
from core.oauth.token_manager import TokenManager
@pytest.fixture
def token_manager():
"""Create a fresh TokenManager for tests."""
os.environ["OAUTH_JWT_SECRET_KEY"] = "test_secret_key_resource"
with tempfile.TemporaryDirectory() as tmpdir:
os.environ["OAUTH_STORAGE_PATH"] = tmpdir
yield TokenManager()
os.environ.pop("OAUTH_JWT_SECRET_KEY", None)
os.environ.pop("OAUTH_STORAGE_PATH", None)
@pytest.fixture
def temp_storage():
"""Create temporary storage for OAuth server tests."""
with tempfile.TemporaryDirectory() as tmpdir:
os.environ["OAUTH_STORAGE_PATH"] = tmpdir
os.environ["OAUTH_JWT_SECRET_KEY"] = "test_secret_key_resource"
from core.oauth import client_registry, server, storage, token_manager
client_registry._client_registry = None
token_manager._token_manager = None
storage._storage = None
server._oauth_server = None
yield tmpdir
# Teardown
os.environ.pop("OAUTH_STORAGE_PATH", None)
os.environ.pop("OAUTH_JWT_SECRET_KEY", None)
client_registry._client_registry = None
token_manager._token_manager = None
storage._storage = None
server._oauth_server = None
@pytest.fixture
def oauth_components(temp_storage):
"""Get fresh OAuth components."""
from core.oauth import get_client_registry, get_oauth_server, get_storage, get_token_manager
return {
"server": get_oauth_server(),
"client_registry": get_client_registry(),
"token_manager": get_token_manager(),
"storage": get_storage(),
}
@pytest.fixture
def test_client(oauth_components):
"""Create a test OAuth client."""
client_registry = oauth_components["client_registry"]
client_id, client_secret = client_registry.create_client(
client_name="Resource Test Client",
redirect_uris=["http://localhost:3000/callback"],
grant_types=["authorization_code", "refresh_token"],
allowed_scopes=["read", "write"],
)
return {
"client_id": client_id,
"client_secret": client_secret,
"redirect_uri": "http://localhost:3000/callback",
}
# --- AuthorizationCode schema tests ---
def test_authorization_code_accepts_resource():
"""Test that AuthorizationCode model accepts and stores the resource field."""
auth_code = AuthorizationCode(
code="auth_test123",
client_id="test_client",
redirect_uri="http://localhost:3000/callback",
scope="read write",
code_challenge="abc123",
code_challenge_method="S256",
expires_at=datetime.now(UTC) + timedelta(minutes=5),
resource="https://mcp.example.com",
)
assert auth_code.resource == "https://mcp.example.com"
def test_authorization_code_resource_defaults_to_none():
"""Test that AuthorizationCode resource defaults to None when not provided."""
auth_code = AuthorizationCode(
code="auth_test456",
client_id="test_client",
redirect_uri="http://localhost:3000/callback",
scope="read",
code_challenge="xyz789",
code_challenge_method="S256",
expires_at=datetime.now(UTC) + timedelta(minutes=5),
)
assert auth_code.resource is None
# --- create_authorization_code tests ---
def test_create_authorization_code_with_resource(oauth_components, test_client):
"""Test that create_authorization_code accepts and stores resource parameter."""
from core.oauth import generate_code_challenge, generate_code_verifier
code_verifier = generate_code_verifier()
code_challenge = generate_code_challenge(code_verifier)
oauth_server = oauth_components["server"]
resource_url = "https://mcp.example.com"
code = oauth_server.create_authorization_code(
client_id=test_client["client_id"],
redirect_uri=test_client["redirect_uri"],
scope="read write",
code_challenge=code_challenge,
code_challenge_method="S256",
resource=resource_url,
)
assert code.startswith("auth_")
# Verify resource is stored in the authorization code
stored_code = oauth_components["storage"].get_authorization_code(code)
assert stored_code is not None
assert stored_code.resource == resource_url
def test_create_authorization_code_without_resource(oauth_components, test_client):
"""Test that create_authorization_code works without resource (backward compat)."""
from core.oauth import generate_code_challenge, generate_code_verifier
code_verifier = generate_code_verifier()
code_challenge = generate_code_challenge(code_verifier)
oauth_server = oauth_components["server"]
code = oauth_server.create_authorization_code(
client_id=test_client["client_id"],
redirect_uri=test_client["redirect_uri"],
scope="read write",
code_challenge=code_challenge,
code_challenge_method="S256",
)
assert code.startswith("auth_")
stored_code = oauth_components["storage"].get_authorization_code(code)
assert stored_code is not None
assert stored_code.resource is None
# --- generate_access_token / JWT aud claim tests ---
def test_generate_access_token_with_resource_sets_aud(token_manager):
"""Test that resource parameter flows through to JWT aud claim."""
resource_url = "https://mcp.example.com"
token = token_manager.generate_access_token(
client_id="test_client",
scope="read write",
user_id="user_123",
resource=resource_url,
)
# Decode JWT and verify aud claim
payload = jwt.decode(
token,
"test_secret_key_resource",
algorithms=["HS256"],
audience=resource_url,
)
assert payload["aud"] == resource_url
assert payload["client_id"] == "test_client"
assert payload["sub"] == "user_123"
def test_generate_access_token_without_resource_no_aud(token_manager):
"""Test that JWT has no aud claim when resource is not provided."""
token = token_manager.generate_access_token(
client_id="test_client",
scope="read write",
user_id="user_123",
)
payload = jwt.decode(
token,
"test_secret_key_resource",
algorithms=["HS256"],
)
assert "aud" not in payload
assert payload["client_id"] == "test_client"
def test_generate_access_token_resource_none_no_aud(token_manager):
"""Test that resource=None does not add aud claim."""
token = token_manager.generate_access_token(
client_id="test_client",
scope="read",
resource=None,
)
payload = jwt.decode(
token,
"test_secret_key_resource",
algorithms=["HS256"],
)
assert "aud" not in payload
def test_generate_access_token_resource_empty_string_no_aud(token_manager):
"""Test that empty string resource does not add aud claim."""
token = token_manager.generate_access_token(
client_id="test_client",
scope="read",
resource="",
)
payload = jwt.decode(
token,
"test_secret_key_resource",
algorithms=["HS256"],
)
assert "aud" not in payload
# --- Full flow: resource through code exchange ---
def test_full_flow_resource_to_jwt_aud(oauth_components, test_client):
"""Test that resource flows from auth code creation through to JWT aud claim."""
from core.oauth import generate_code_challenge, generate_code_verifier
code_verifier = generate_code_verifier()
code_challenge = generate_code_challenge(code_verifier)
oauth_server = oauth_components["server"]
resource_url = "https://mcp.example.com"
# Step 1: Create authorization code with resource
code = oauth_server.create_authorization_code(
client_id=test_client["client_id"],
redirect_uri=test_client["redirect_uri"],
scope="read write",
code_challenge=code_challenge,
code_challenge_method="S256",
api_key_id="master",
api_key_project_id="*",
api_key_scope="read write",
resource=resource_url,
)
# Step 2: Exchange code for tokens
token_response = oauth_server.exchange_code_for_tokens(
client_id=test_client["client_id"],
client_secret=test_client["client_secret"],
code=code,
redirect_uri=test_client["redirect_uri"],
code_verifier=code_verifier,
)
# Step 3: Verify JWT contains aud claim
payload = jwt.decode(
token_response.access_token,
os.environ["OAUTH_JWT_SECRET_KEY"],
algorithms=["HS256"],
audience=resource_url,
)
assert payload["aud"] == resource_url
def test_full_flow_without_resource_no_aud(oauth_components, test_client):
"""Test that full flow without resource produces JWT without aud claim."""
from core.oauth import generate_code_challenge, generate_code_verifier
code_verifier = generate_code_verifier()
code_challenge = generate_code_challenge(code_verifier)
oauth_server = oauth_components["server"]
# Step 1: Create authorization code WITHOUT resource
code = oauth_server.create_authorization_code(
client_id=test_client["client_id"],
redirect_uri=test_client["redirect_uri"],
scope="read write",
code_challenge=code_challenge,
code_challenge_method="S256",
api_key_id="master",
api_key_project_id="*",
api_key_scope="read write",
)
# Step 2: Exchange code for tokens
token_response = oauth_server.exchange_code_for_tokens(
client_id=test_client["client_id"],
client_secret=test_client["client_secret"],
code=code,
redirect_uri=test_client["redirect_uri"],
code_verifier=code_verifier,
)
# Step 3: Verify JWT does NOT contain aud claim
payload = jwt.decode(
token_response.access_token,
os.environ["OAUTH_JWT_SECRET_KEY"],
algorithms=["HS256"],
)
assert "aud" not in payload

227
tests/test_oauth_revoke.py Normal file
View File

@@ -0,0 +1,227 @@
"""Tests for OAuth 2.0 Token Revocation endpoint (RFC 7009)."""
import base64
import json
import os
import tempfile
from unittest.mock import AsyncMock, MagicMock
import pytest
def _make_request(headers=None, body=None, content_type="application/x-www-form-urlencoded"):
"""Create a mock Starlette Request for oauth_revoke."""
request = AsyncMock()
request.headers = MagicMock()
_headers = {"content-type": content_type}
if headers:
_headers.update(headers)
request.headers.get = lambda key, default="": _headers.get(key.lower(), default)
form_data = body or {}
form = AsyncMock(return_value=form_data)
request.form = form
if "application/json" in content_type:
request.json = AsyncMock(return_value=form_data)
return request
def _encode_basic(client_id: str, client_secret: str) -> str:
"""Encode client_id:client_secret as Basic Auth header value."""
raw = f"{client_id}:{client_secret}"
encoded = base64.b64encode(raw.encode("utf-8")).decode("utf-8")
return f"Basic {encoded}"
@pytest.fixture
def temp_storage():
"""Create temporary storage for OAuth tests."""
with tempfile.TemporaryDirectory() as tmpdir:
os.environ["OAUTH_STORAGE_PATH"] = tmpdir
os.environ["OAUTH_JWT_SECRET_KEY"] = "test_secret_key_for_revoke_tests"
from core.oauth import client_registry, server, storage, token_manager
client_registry._client_registry = None
token_manager._token_manager = None
storage._storage = None
server._oauth_server = None
yield tmpdir
os.environ.pop("OAUTH_STORAGE_PATH", None)
os.environ.pop("OAUTH_JWT_SECRET_KEY", None)
client_registry._client_registry = None
token_manager._token_manager = None
storage._storage = None
server._oauth_server = None
@pytest.fixture
def registered_client(temp_storage):
"""Create a registered OAuth client and return (client_id, client_secret)."""
from core.oauth import get_client_registry
registry = get_client_registry()
client_id, client_secret = registry.create_client(
client_name="Test Revoke Client",
redirect_uris=["http://localhost:3000/callback"],
)
return client_id, client_secret
async def _call_oauth_revoke(request):
"""Import and call the oauth_revoke endpoint from server.py."""
from server import oauth_revoke
return await oauth_revoke(request)
@pytest.mark.unit
class TestOAuthRevoke:
"""Tests for the /oauth/revoke endpoint (RFC 7009)."""
async def test_valid_token_revocation_returns_200(self, registered_client):
"""Valid token revocation returns 200 with empty body."""
client_id, client_secret = registered_client
request = _make_request(
body={
"client_id": client_id,
"client_secret": client_secret,
"token": "rt_some_refresh_token",
},
)
response = await _call_oauth_revoke(request)
assert response.status_code == 200
data = json.loads(response.body)
assert data == {}
async def test_missing_token_returns_200(self, registered_client):
"""Per RFC 7009, missing token returns 200 (no-op)."""
client_id, client_secret = registered_client
request = _make_request(
body={
"client_id": client_id,
"client_secret": client_secret,
},
)
response = await _call_oauth_revoke(request)
assert response.status_code == 200
data = json.loads(response.body)
assert data == {}
async def test_invalid_client_credentials_returns_401(self, registered_client):
"""Invalid client credentials return 401 with invalid_client error."""
client_id, _ = registered_client
request = _make_request(
body={
"client_id": client_id,
"client_secret": "wrong_secret",
"token": "rt_some_token",
},
)
response = await _call_oauth_revoke(request)
assert response.status_code == 401
data = json.loads(response.body)
assert data["error"] == "invalid_client"
assert "Invalid client credentials" in data["error_description"]
async def test_missing_client_credentials_returns_401(self, temp_storage):
"""Missing client credentials return 401 with invalid_client error."""
request = _make_request(
body={
"token": "rt_some_token",
},
)
response = await _call_oauth_revoke(request)
assert response.status_code == 401
data = json.loads(response.body)
assert data["error"] == "invalid_client"
assert "Client authentication required" in data["error_description"]
async def test_revocation_with_refresh_token_hint(self, registered_client):
"""Revocation with token_type_hint=refresh_token works correctly."""
client_id, client_secret = registered_client
request = _make_request(
body={
"client_id": client_id,
"client_secret": client_secret,
"token": "some_token_value",
"token_type_hint": "refresh_token",
},
)
response = await _call_oauth_revoke(request)
assert response.status_code == 200
data = json.loads(response.body)
assert data == {}
async def test_unknown_token_returns_200(self, registered_client):
"""Per RFC 7009, revoking an unknown/invalid token still returns 200."""
client_id, client_secret = registered_client
request = _make_request(
body={
"client_id": client_id,
"client_secret": client_secret,
"token": "completely_nonexistent_token_xyz",
},
)
response = await _call_oauth_revoke(request)
assert response.status_code == 200
data = json.loads(response.body)
assert data == {}
async def test_basic_auth_works_for_revocation(self, registered_client):
"""Client authentication via Basic Auth header works on the revoke endpoint."""
client_id, client_secret = registered_client
request = _make_request(
headers={"authorization": _encode_basic(client_id, client_secret)},
body={
"token": "rt_some_refresh_token",
},
)
response = await _call_oauth_revoke(request)
assert response.status_code == 200
data = json.loads(response.body)
assert data == {}
async def test_metadata_includes_revocation_endpoint(self, temp_storage):
"""OAuth metadata includes the revocation_endpoint field."""
from server import oauth_metadata
# Create a mock request with a host header
request = AsyncMock()
request.headers = MagicMock()
_headers = {"host": "localhost:8000"}
request.headers.get = lambda key, default="": _headers.get(key.lower(), default)
request.url = MagicMock()
request.url.scheme = "http"
response = await oauth_metadata(request)
data = json.loads(response.body)
assert "revocation_endpoint" in data
assert data["revocation_endpoint"].endswith("/oauth/revoke")
assert "client_secret_basic" in data.get("revocation_endpoint_auth_methods_supported", [])

View File

@@ -44,11 +44,10 @@ class TestWooCommercePluginInit:
assert WooCommercePlugin.get_plugin_name() == "woocommerce" assert WooCommercePlugin.get_plugin_name() == "woocommerce"
def test_required_config_keys(self): def test_required_config_keys(self):
"""Should require url, username, app_password.""" """Should require url only (credentials checked at init)."""
keys = WooCommercePlugin.get_required_config_keys() keys = WooCommercePlugin.get_required_config_keys()
assert "url" in keys assert "url" in keys
assert "username" in keys assert len(keys) == 1
assert "app_password" in keys
def test_missing_url_raises(self): def test_missing_url_raises(self):
"""Should raise ValueError for missing URL.""" """Should raise ValueError for missing URL."""
@@ -57,9 +56,11 @@ class TestWooCommercePluginInit:
WooCommercePlugin(config) WooCommercePlugin(config)
def test_missing_credentials_raises(self): def test_missing_credentials_raises(self):
"""Should raise ValueError for missing credentials.""" """Should raise ConfigurationError for missing credentials."""
from plugins.wordpress.client import ConfigurationError
config = {"url": "https://shop.example.com"} config = {"url": "https://shop.example.com"}
with pytest.raises(ValueError, match="Missing required configuration"): with pytest.raises(ConfigurationError, match="credentials not configured"):
WooCommercePlugin(config) WooCommercePlugin(config)
def test_custom_project_id(self): def test_custom_project_id(self):

Binary file not shown.

View File

@@ -1,4 +1,4 @@
# SEO API Bridge - WordPress Plugin # Airano MCP SEO Bridge - WordPress Plugin
**Version:** 1.3.0 **Version:** 1.3.0
**Requires:** WordPress 5.0+, PHP 7.4+ **Requires:** WordPress 5.0+, PHP 7.4+
@@ -72,23 +72,23 @@ SEO API Bridge is a comprehensive WordPress plugin that exposes Rank Math SEO an
### Method 1: Upload via WordPress Admin ### Method 1: Upload via WordPress Admin
1. Download `seo-api-bridge.zip` from [Releases](https://github.com/airano-ir/mcphub) 1. Download `airano-mcp-seo-bridge.zip` from [Releases](https://github.com/airano-ir/mcphub)
2. Go to WordPress Admin > Plugins > Add New > Upload Plugin 2. Go to WordPress Admin > Plugins > Add New > Upload Plugin
3. Upload the ZIP file and click "Install Now" 3. Upload the ZIP file and click "Install Now"
4. Click "Activate Plugin" 4. Click "Activate Plugin"
### Method 2: FTP/SSH Upload ### Method 2: FTP/SSH Upload
1. Upload the `seo-api-bridge` folder to `/wp-content/plugins/` 1. Upload the `airano-mcp-seo-bridge` folder to `/wp-content/plugins/`
2. Go to WordPress Admin > Plugins 2. Go to WordPress Admin > Plugins
3. Find "SEO API Bridge" and click "Activate" 3. Find "Airano MCP SEO Bridge" and click "Activate"
### Method 3: WP-CLI ### Method 3: WP-CLI
```bash ```bash
cd /var/www/html/wp-content/plugins/ cd /var/www/html/wp-content/plugins/
# Copy the plugin folder here # Copy the plugin folder here
wp plugin activate seo-api-bridge wp plugin activate airano-mcp-seo-bridge
``` ```
## REST API Endpoints ## REST API Endpoints
@@ -97,10 +97,10 @@ All endpoints require **WordPress Application Password** authentication.
### Status Endpoint ### Status Endpoint
**GET** `/wp-json/seo-api-bridge/v1/status` **GET** `/wp-json/airano-mcp-seo-bridge/v1/status`
```bash ```bash
curl -X GET "https://yoursite.com/wp-json/seo-api-bridge/v1/status" \ curl -X GET "https://yoursite.com/wp-json/airano-mcp-seo-bridge/v1/status" \
-u "username:application_password" -u "username:application_password"
``` ```
@@ -121,17 +121,17 @@ curl -X GET "https://yoursite.com/wp-json/seo-api-bridge/v1/status" \
### Post SEO Endpoints ### Post SEO Endpoints
**GET** `/wp-json/seo-api-bridge/v1/posts/{id}/seo` — Get SEO metadata for a post **GET** `/wp-json/airano-mcp-seo-bridge/v1/posts/{id}/seo` — Get SEO metadata for a post
**POST** `/wp-json/seo-api-bridge/v1/posts/{id}/seo` — Update SEO metadata for a post **POST** `/wp-json/airano-mcp-seo-bridge/v1/posts/{id}/seo` — Update SEO metadata for a post
```bash ```bash
# Get post SEO # Get post SEO
curl -X GET "https://yoursite.com/wp-json/seo-api-bridge/v1/posts/123/seo" \ curl -X GET "https://yoursite.com/wp-json/airano-mcp-seo-bridge/v1/posts/123/seo" \
-u "username:application_password" -u "username:application_password"
# Update post SEO # Update post SEO
curl -X POST "https://yoursite.com/wp-json/seo-api-bridge/v1/posts/123/seo" \ curl -X POST "https://yoursite.com/wp-json/airano-mcp-seo-bridge/v1/posts/123/seo" \
-u "username:application_password" \ -u "username:application_password" \
-H "Content-Type: application/json" \ -H "Content-Type: application/json" \
-d '{"focus_keyword": "wordpress", "seo_title": "My Title"}' -d '{"focus_keyword": "wordpress", "seo_title": "My Title"}'
@@ -139,18 +139,18 @@ curl -X POST "https://yoursite.com/wp-json/seo-api-bridge/v1/posts/123/seo" \
### Page SEO Endpoints ### Page SEO Endpoints
**GET** `/wp-json/seo-api-bridge/v1/pages/{id}/seo` **GET** `/wp-json/airano-mcp-seo-bridge/v1/pages/{id}/seo`
**POST** `/wp-json/seo-api-bridge/v1/pages/{id}/seo` **POST** `/wp-json/airano-mcp-seo-bridge/v1/pages/{id}/seo`
### Product SEO Endpoints (WooCommerce) ### Product SEO Endpoints (WooCommerce)
**GET** `/wp-json/seo-api-bridge/v1/products/{id}/seo` **GET** `/wp-json/airano-mcp-seo-bridge/v1/products/{id}/seo`
**POST** `/wp-json/seo-api-bridge/v1/products/{id}/seo` **POST** `/wp-json/airano-mcp-seo-bridge/v1/products/{id}/seo`
```bash ```bash
curl -X POST "https://yoursite.com/wp-json/seo-api-bridge/v1/products/1217/seo" \ curl -X POST "https://yoursite.com/wp-json/airano-mcp-seo-bridge/v1/products/1217/seo" \
-u "username:application_password" \ -u "username:application_password" \
-H "Content-Type: application/json" \ -H "Content-Type: application/json" \
-d '{"focus_keyword": "product keyword", "seo_title": "Product Title"}' -d '{"focus_keyword": "product keyword", "seo_title": "Product Title"}'
@@ -223,7 +223,7 @@ result = await mcp.call_tool(
2. **Status Endpoint:** 2. **Status Endpoint:**
```bash ```bash
curl -X GET "https://your-site.com/wp-json/seo-api-bridge/v1/status" \ curl -X GET "https://your-site.com/wp-json/airano-mcp-seo-bridge/v1/status" \
-u "username:application_password" -u "username:application_password"
``` ```
@@ -248,7 +248,7 @@ curl -X GET "https://your-site.com/wp-json/wp/v2/posts/1" \
1. Upgrade to v1.1.0+ (has dedicated health check endpoint) 1. Upgrade to v1.1.0+ (has dedicated health check endpoint)
2. Restart MCP server 2. Restart MCP server
3. Test endpoint: `curl "https://your-site.com/wp-json/seo-api-bridge/v1/status" -u "user:pass"` 3. Test endpoint: `curl "https://your-site.com/wp-json/airano-mcp-seo-bridge/v1/status" -u "user:pass"`
### SEO Plugin Not Detected ### SEO Plugin Not Detected
@@ -285,7 +285,7 @@ curl -X GET "https://your-site.com/wp-json/wp/v2/posts/1" \
- Improved MariaDB compatibility for meta field queries - Improved MariaDB compatibility for meta field queries
### 1.1.0 (2025-01-10) ### 1.1.0 (2025-01-10)
- Added health check REST API endpoint `/seo-api-bridge/v1/status` - Added health check REST API endpoint `/airano-mcp-seo-bridge/v1/status`
- Direct plugin detection without requiring posts/products - Direct plugin detection without requiring posts/products
- Better compatibility with sites that have no content - Better compatibility with sites that have no content
- Returns SEO plugin versions in status endpoint - Returns SEO plugin versions in status endpoint

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.