Compare commits
9 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f6dbbeaab0 | ||
|
|
c0d55f9061 | ||
|
|
a806671e2d | ||
|
|
4a5381d765 | ||
|
|
fdb050454e | ||
|
|
ea51c10e03 | ||
|
|
479d0c7252 | ||
|
|
d3bcb31053 | ||
|
|
1736779d69 |
1
.gitignore
vendored
1
.gitignore
vendored
@@ -29,6 +29,7 @@ ENV/
|
||||
htmlcov/
|
||||
.tox/
|
||||
pytest-out.txt
|
||||
scripts/community-build/BUILD_REPORT.md
|
||||
.nox/
|
||||
coverage.xml
|
||||
*.cover
|
||||
|
||||
20
CHANGELOG.md
20
CHANGELOG.md
@@ -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/),
|
||||
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
|
||||
|
||||
@@ -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.
|
||||
- `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
|
||||
- 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)
|
||||
|
||||
@@ -120,7 +120,7 @@ Then register in `plugins/__init__.py` and add tests.
|
||||
core/ # Core system (auth, site manager, tool registry, dashboard)
|
||||
plugins/ # Plugin system (9 plugins, each with handlers + schemas)
|
||||
core/templates/ # Jinja2 templates (dashboard + OAuth)
|
||||
tests/ # Test suite (290 tests)
|
||||
tests/ # Test suite (481 tests)
|
||||
scripts/ # Setup & deployment scripts
|
||||
docs/ # Documentation
|
||||
```
|
||||
|
||||
@@ -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.
|
||||
|
||||
> **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
|
||||
|
||||
### 1. Create a `.env` file
|
||||
@@ -91,6 +93,7 @@ Use the most specific endpoint for your use case:
|
||||
|
||||
| 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 |
|
||||
| `/{plugin}/mcp` | 23-101 | Yes | Multi-site management |
|
||||
| `/mcp` | 596 | Yes | Admin & discovery only |
|
||||
@@ -131,7 +134,7 @@ docker compose up -d
|
||||
| URL | Description |
|
||||
|-----|-------------|
|
||||
| `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) |
|
||||
|
||||
## 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) |
|
||||
| `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) |
|
||||
| `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.
|
||||
|
||||
> **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).
|
||||
|
||||
## Supported Plugins
|
||||
|
||||
23
README.md
23
README.md
@@ -13,7 +13,7 @@ Connect your sites, stores, repos, and databases — manage them all through Cla
|
||||
[](https://www.python.org/)
|
||||
[](https://pypi.org/project/mcphub-server/)
|
||||
[](https://hub.docker.com/r/airano/mcphub)
|
||||
[]()
|
||||
[]()
|
||||
[]()
|
||||
[](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**
|
||||
|
||||
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
|
||||
|
||||
@@ -297,12 +310,14 @@ MCP Hub supports **Open Dynamic Client Registration** (RFC 7591). ChatGPT can au
|
||||
/appwrite/mcp → Appwrite tools (100 tools)
|
||||
/directus/mcp → Directus tools (100 tools)
|
||||
/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.
|
||||
|
||||
| Endpoint | Use Case | Tools |
|
||||
|----------|----------|------:|
|
||||
| `/u/{user_id}/{alias}/mcp` | Hosted users (OAuth login) | 22-100 |
|
||||
| `/project/{alias}/mcp` | Single-site workflow (recommended) | 22-100 |
|
||||
| `/{plugin}/mcp` | Multi-site management | 23-101 |
|
||||
| `/mcp` | Admin & discovery only | 596 |
|
||||
@@ -323,7 +338,7 @@ Some MCP Hub tools require companion WordPress plugins:
|
||||
|
||||
| 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 |
|
||||
| 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)) |
|
||||
@@ -363,7 +378,7 @@ Without Docker socket, WP-CLI tools return "not available" but all REST API tool
|
||||
# Install with dev dependencies
|
||||
pip install -e ".[dev]"
|
||||
|
||||
# Run tests (290 tests)
|
||||
# Run tests (481 tests)
|
||||
pytest
|
||||
|
||||
# Format and lint
|
||||
|
||||
@@ -79,10 +79,14 @@ def generate_config(
|
||||
endpoint_url = f"{base_url}/u/{user_id}/{alias}/mcp"
|
||||
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"):
|
||||
config = {
|
||||
"mcpServers": {
|
||||
server_name: {
|
||||
"type": transport_type,
|
||||
"url": endpoint_url,
|
||||
"headers": {
|
||||
"Authorization": f"Bearer {api_key}",
|
||||
@@ -97,6 +101,7 @@ def generate_config(
|
||||
"mcp": {
|
||||
"servers": {
|
||||
server_name: {
|
||||
"type": transport_type,
|
||||
"url": endpoint_url,
|
||||
"headers": {
|
||||
"Authorization": f"Bearer {api_key}",
|
||||
|
||||
@@ -122,7 +122,11 @@ DASHBOARD_TRANSLATIONS = {
|
||||
"no_sites": "No sites added yet",
|
||||
"add_first_site": "Add your first site to get started",
|
||||
"site_added": "Site added successfully",
|
||||
"site_updated": "Site updated successfully",
|
||||
"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_failed": "Connection failed",
|
||||
"credentials": "Credentials",
|
||||
@@ -218,7 +222,11 @@ DASHBOARD_TRANSLATIONS = {
|
||||
"no_sites": "هنوز سایتی اضافه نشده",
|
||||
"add_first_site": "اولین سایت خود را اضافه کنید",
|
||||
"site_added": "سایت با موفقیت اضافه شد",
|
||||
"site_updated": "سایت با موفقیت بروزرسانی شد",
|
||||
"site_deleted": "سایت حذف شد",
|
||||
"edit_site": "ویرایش سایت",
|
||||
"updating_site": "در حال بروزرسانی...",
|
||||
"keep_existing": "خالی بگذارید تا مقدار فعلی حفظ شود",
|
||||
"connection_ok": "اتصال برقرار",
|
||||
"connection_failed": "اتصال ناموفق",
|
||||
"credentials": "مشخصات دسترسی",
|
||||
@@ -918,12 +926,15 @@ async def get_project_detail(project_id: str) -> dict | None:
|
||||
|
||||
# Get recent activity for this project
|
||||
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 = [
|
||||
e
|
||||
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
|
||||
)
|
||||
and e.get("event_type", "") not in ("health_metric_recorded", "health_check")
|
||||
][:5]
|
||||
|
||||
# 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",
|
||||
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
|
||||
except (RuntimeError, ValueError) as 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"),
|
||||
)
|
||||
|
||||
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.set_session_cookie(response, token)
|
||||
response.delete_cookie(key="oauth_state")
|
||||
response.delete_cookie(key="mcp_auth_next", path="/")
|
||||
|
||||
logger.info(
|
||||
"OAuth login successful: %s via %s",
|
||||
"OAuth login successful: %s via %s (redirect=%s)",
|
||||
user["email"],
|
||||
provider,
|
||||
redirect_to,
|
||||
)
|
||||
return response
|
||||
|
||||
@@ -2669,6 +2704,7 @@ async def dashboard_connect_page(request: Request) -> Response:
|
||||
"clients": get_supported_clients(),
|
||||
"current_page": "connect",
|
||||
"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)
|
||||
|
||||
|
||||
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:
|
||||
"""POST /api/keys — Create a new user API key."""
|
||||
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)
|
||||
|
||||
|
||||
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):
|
||||
"""
|
||||
Register dashboard routes with the MCP server.
|
||||
@@ -2884,6 +3100,9 @@ def register_dashboard_routes(mcp):
|
||||
"""
|
||||
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)
|
||||
mcp.custom_route("/auth/login", methods=["GET"])(auth_login_page)
|
||||
mcp.custom_route("/auth/callback/{provider}", methods=["GET"])(auth_callback)
|
||||
@@ -2955,6 +3174,7 @@ def register_dashboard_routes(mcp):
|
||||
# Site Management routes (E.3)
|
||||
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/{id}/edit", methods=["GET"])(dashboard_sites_edit)
|
||||
mcp.custom_route("/dashboard/connect", methods=["GET"])(dashboard_connect_page)
|
||||
|
||||
# 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/{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=["PATCH"])(api_update_site)
|
||||
|
||||
# User API Key routes (E.3)
|
||||
mcp.custom_route("/api/keys", methods=["GET"])(api_list_keys)
|
||||
@@ -2971,4 +3192,15 @@ def register_dashboard_routes(mcp):
|
||||
# Config snippet API (E.3)
|
||||
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")
|
||||
|
||||
@@ -530,6 +530,30 @@ class Database:
|
||||
(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:
|
||||
"""Get a site by user ID and alias.
|
||||
|
||||
|
||||
@@ -563,13 +563,26 @@ class HealthMonitor:
|
||||
try:
|
||||
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(
|
||||
config.username 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 session.get(
|
||||
f"{config.url}/wp-json/wp/v2/users/me",
|
||||
auth_check_url,
|
||||
timeout=aiohttp.ClientTimeout(total=10),
|
||||
ssl=False,
|
||||
) as resp:
|
||||
|
||||
@@ -76,6 +76,7 @@ class ClientRegistry:
|
||||
grant_types: list[str] | None = None,
|
||||
allowed_scopes: list[str] | None = None,
|
||||
metadata: dict | None = None,
|
||||
owner_user_id: str | None = None,
|
||||
) -> tuple[str, str]:
|
||||
"""
|
||||
Create new OAuth client.
|
||||
@@ -99,6 +100,7 @@ class ClientRegistry:
|
||||
grant_types=grant_types or ["authorization_code", "refresh_token"],
|
||||
allowed_scopes=allowed_scopes or ["read", "write"],
|
||||
metadata=metadata or {},
|
||||
owner_user_id=owner_user_id,
|
||||
)
|
||||
|
||||
# Save
|
||||
|
||||
@@ -27,6 +27,9 @@ class OAuthClient(BaseModel):
|
||||
)
|
||||
created_at: datetime = Field(default_factory=lambda: datetime.now(UTC))
|
||||
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")
|
||||
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_project_id: str | None = None # Project ID 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:
|
||||
"""Check if code is expired"""
|
||||
|
||||
@@ -143,6 +143,7 @@ class OAuthServer:
|
||||
api_key_id: str | None = None,
|
||||
api_key_project_id: str | None = None,
|
||||
api_key_scope: str | None = None,
|
||||
resource: str | None = None,
|
||||
) -> str:
|
||||
"""
|
||||
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_project_id: Optional project ID from API Key
|
||||
api_key_scope: Optional scope from API Key
|
||||
resource: Optional resource indicator (RFC 8707)
|
||||
|
||||
Returns:
|
||||
Authorization code (valid for 5 minutes)
|
||||
@@ -178,6 +180,7 @@ class OAuthServer:
|
||||
api_key_id=api_key_id,
|
||||
api_key_project_id=api_key_project_id,
|
||||
api_key_scope=api_key_scope,
|
||||
resource=resource,
|
||||
)
|
||||
|
||||
# Save to storage
|
||||
@@ -257,12 +260,14 @@ class OAuthServer:
|
||||
# If authorization code has API Key metadata, use it for scoping
|
||||
project_id = auth_code.api_key_project_id or "*"
|
||||
token_scope = auth_code.api_key_scope or auth_code.scope
|
||||
resource = auth_code.resource
|
||||
|
||||
access_token = self.token_manager.generate_access_token(
|
||||
client_id=client_id,
|
||||
scope=token_scope,
|
||||
user_id=auth_code.user_id or auth_code.api_key_id,
|
||||
project_id=project_id,
|
||||
resource=resource,
|
||||
)
|
||||
|
||||
refresh_token = self.token_manager.generate_refresh_token(
|
||||
|
||||
@@ -54,7 +54,12 @@ class TokenManager:
|
||||
self.refresh_token_ttl = int(os.getenv("OAUTH_REFRESH_TOKEN_TTL", "604800")) # 7 days
|
||||
|
||||
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:
|
||||
"""
|
||||
Generate JWT access token.
|
||||
@@ -64,6 +69,7 @@ class TokenManager:
|
||||
scope: Granted scopes (space-separated)
|
||||
user_id: User ID (optional, for user-based auth)
|
||||
project_id: Project ID for scoping (default: "*" for global)
|
||||
resource: Resource indicator for aud claim (RFC 8707)
|
||||
|
||||
Returns:
|
||||
JWT access token
|
||||
@@ -87,6 +93,9 @@ class TokenManager:
|
||||
if user_id:
|
||||
payload["sub"] = user_id # Subject (user ID)
|
||||
|
||||
if resource:
|
||||
payload["aud"] = resource
|
||||
|
||||
# Encode JWT
|
||||
token = jwt.encode(payload, self.jwt_secret, algorithm=self.jwt_algorithm)
|
||||
|
||||
@@ -130,6 +139,7 @@ class TokenManager:
|
||||
"verify_signature": True,
|
||||
"verify_exp": True,
|
||||
"verify_nbf": True,
|
||||
"verify_aud": False, # Server is the resource server; no external aud check needed
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
121
core/site_api.py
121
core/site_api.py
@@ -108,14 +108,34 @@ PLUGIN_CREDENTIAL_FIELDS: dict[str, list[dict[str, Any]]] = {
|
||||
"label": "Service Role Key",
|
||||
"type": "password",
|
||||
"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",
|
||||
"label": "Anon Key",
|
||||
"label": "Anon Key (Optional)",
|
||||
"type": "password",
|
||||
"required": True,
|
||||
"hint": "Supabase Dashboard → Settings → API → anon key",
|
||||
"required": False,
|
||||
"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": [
|
||||
@@ -509,6 +529,99 @@ async def delete_user_site(site_id: str, user_id: str) -> bool:
|
||||
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]:
|
||||
"""Test connectivity to an existing site.
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en" class="dark">
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
@@ -11,23 +11,35 @@
|
||||
theme: {
|
||||
extend: {
|
||||
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>
|
||||
if (localStorage.theme === 'dark' || (!('theme' in localStorage) && window.matchMedia('(prefers-color-scheme: dark)').matches)) {
|
||||
document.documentElement.classList.add('dark');
|
||||
}
|
||||
</script>
|
||||
</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="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"/>
|
||||
</svg>
|
||||
</div>
|
||||
<h1 class="text-6xl font-bold text-white mb-4">404</h1>
|
||||
<p class="text-xl text-gray-400 mb-8">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>
|
||||
<h1 class="text-6xl font-bold text-primary-500 dark:text-primary-400 mb-4">404</h1>
|
||||
<p class="text-xl text-gray-600 dark:text-gray-300 mb-3">Page Not Found</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">
|
||||
<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">
|
||||
@@ -37,11 +49,11 @@
|
||||
Dashboard
|
||||
</a>
|
||||
<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
|
||||
</a>
|
||||
</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>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -76,7 +76,7 @@
|
||||
</button>
|
||||
|
||||
{% 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'] }}
|
||||
</a>
|
||||
{% endif %}
|
||||
@@ -248,7 +248,7 @@
|
||||
<div class="flex items-center gap-2">
|
||||
{% 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 %}"
|
||||
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 %}
|
||||
</a>
|
||||
{% endif %}
|
||||
@@ -258,7 +258,7 @@
|
||||
<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) %}
|
||||
<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 }}
|
||||
</a>
|
||||
{% elif page_num == page_number - 3 or page_num == page_number + 3 %}
|
||||
@@ -268,7 +268,7 @@
|
||||
|
||||
{% 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 %}"
|
||||
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 %}
|
||||
</a>
|
||||
{% endif %}
|
||||
@@ -360,7 +360,7 @@
|
||||
<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-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 %}
|
||||
</button>
|
||||
@@ -448,7 +448,7 @@
|
||||
<div class="p-6 border-t border-gray-200 dark:border-gray-700 flex justify-end gap-3">
|
||||
<button
|
||||
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 %}
|
||||
</button>
|
||||
@@ -485,7 +485,7 @@
|
||||
<div class="p-6 border-t border-gray-200 dark:border-gray-700 flex justify-end gap-3">
|
||||
<button
|
||||
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 %}
|
||||
</button>
|
||||
|
||||
@@ -102,9 +102,8 @@
|
||||
})">
|
||||
<div class="flex h-screen overflow-hidden">
|
||||
<!-- Sidebar -->
|
||||
<aside class="sidebar-transition bg-white dark:bg-gray-800 border-gray-200 dark:border-gray-700 flex flex-col"
|
||||
:class="sidebarOpen ? 'w-64' : 'w-20'" {% if lang=='fa' %}style="border-left: 1px solid;" {% else
|
||||
%}style="border-right: 1px solid;" {% endif %}>
|
||||
<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'">
|
||||
<!-- 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" x-show="sidebarOpen">
|
||||
@@ -207,6 +206,11 @@
|
||||
{% endif %}
|
||||
</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 -->
|
||||
<div class="p-4 border-t border-gray-200 dark:border-gray-700">
|
||||
{# Profile link for OAuth users #}
|
||||
|
||||
@@ -28,14 +28,35 @@
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<!-- New key display (JS-created) -->
|
||||
<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">
|
||||
<p class="font-medium mb-1">{{ t.your_api_key }}</p>
|
||||
<div class="flex items-center gap-2">
|
||||
<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>
|
||||
<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>
|
||||
<!-- New Key Modal (stays open until user clicks Done) -->
|
||||
<div id="new-key-display" 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>
|
||||
{{ 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>
|
||||
<p class="text-xs text-yellow-600 dark:text-yellow-400 mt-1">{{ t.key_shown_once }}</p>
|
||||
</div>
|
||||
|
||||
{% 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>
|
||||
<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>
|
||||
|
||||
<!-- 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 %}
|
||||
<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 %}
|
||||
</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>
|
||||
{% endblock %}
|
||||
|
||||
@@ -132,8 +231,8 @@ async function createKey() {
|
||||
if (resp.ok && data.key) {
|
||||
document.getElementById('new-key-value').textContent = data.key.key;
|
||||
document.getElementById('new-key-display').classList.remove('hidden');
|
||||
// Reload to show in table
|
||||
setTimeout(() => location.reload(), 500);
|
||||
document.getElementById('new-key-display').classList.add('flex');
|
||||
// Modal stays open — user clicks "Done" to dismiss
|
||||
} else {
|
||||
alert(data.error || 'Failed to create key');
|
||||
}
|
||||
@@ -180,6 +279,12 @@ function copyNewKey() {
|
||||
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() {
|
||||
const text = document.getElementById('config-output').textContent;
|
||||
navigator.clipboard.writeText(text);
|
||||
|
||||
@@ -316,7 +316,7 @@
|
||||
|
||||
<div class="flex gap-2">
|
||||
<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 }}
|
||||
</a>
|
||||
<a href="/dashboard/health?refresh=true{% if lang and lang != 'en' %}&lang={{ lang }}{% endif %}"
|
||||
|
||||
@@ -439,17 +439,37 @@
|
||||
</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 %}
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% 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 %}
|
||||
|
||||
@@ -11,7 +11,7 @@
|
||||
<script src="https://unpkg.com/htmx.org@1.9.10"></script>
|
||||
|
||||
<!-- 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 -->
|
||||
<script>
|
||||
@@ -50,8 +50,6 @@
|
||||
}
|
||||
</script>
|
||||
|
||||
<meta name="csrf-token" content="{{ request.state.csrf_token }}">
|
||||
|
||||
<!-- Global CSRF Interceptor -->
|
||||
<script>
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
|
||||
@@ -52,7 +52,7 @@
|
||||
|
||||
{% if search_query or selected_plugin_type or selected_status %}
|
||||
<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'] }}
|
||||
</a>
|
||||
{% endif %}
|
||||
@@ -202,7 +202,7 @@
|
||||
<!-- Actions -->
|
||||
<td class="px-6 py-4">
|
||||
<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 }}
|
||||
<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">
|
||||
@@ -252,7 +252,7 @@
|
||||
<div class="flex items-center gap-2">
|
||||
{% 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 %}"
|
||||
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 %}
|
||||
</a>
|
||||
{% endif %}
|
||||
@@ -263,7 +263,7 @@
|
||||
{% 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_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 }}
|
||||
</a>
|
||||
{% elif page_num == page_number - 3 or page_num == page_number + 3 %}
|
||||
@@ -273,7 +273,7 @@
|
||||
|
||||
{% 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 %}"
|
||||
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 %}
|
||||
</a>
|
||||
{% endif %}
|
||||
|
||||
@@ -13,11 +13,11 @@
|
||||
<div class="space-y-4">
|
||||
<div class="flex items-center gap-4">
|
||||
<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
|
||||
</a>
|
||||
<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>
|
||||
</div>
|
||||
|
||||
134
core/templates/dashboard/sites/edit.html
Normal file
134
core/templates/dashboard/sites/edit.html
Normal 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 %}
|
||||
@@ -73,6 +73,10 @@
|
||||
id="test-btn-{{ site.id }}">
|
||||
{{ t.test_connection }}
|
||||
</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 }}')"
|
||||
class="text-sm text-red-600 dark:text-red-400 hover:text-red-500 dark:hover:text-red-300">
|
||||
{{ t.delete }}
|
||||
|
||||
241
core/templates/dashboard/user-oauth-clients.html
Normal file
241
core/templates/dashboard/user-oauth-clients.html
Normal 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 %}
|
||||
@@ -68,9 +68,57 @@
|
||||
{% if state %}
|
||||
<input type="hidden" name="state" value="{{ state }}">
|
||||
{% endif %}
|
||||
{% if resource %}
|
||||
<input type="hidden" name="resource" value="{{ resource }}">
|
||||
{% endif %}
|
||||
<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 %}>
|
||||
<label for="api_key" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
{{ 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 %}>
|
||||
{{ t.api_key_note }}
|
||||
</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>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<!-- Action Buttons -->
|
||||
<div class="flex gap-3 pt-4" {% if lang == 'fa' %}dir="rtl"{% endif %}>
|
||||
@@ -128,29 +202,51 @@
|
||||
|
||||
{% block scripts %}
|
||||
<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
|
||||
document.getElementById('authForm').addEventListener('submit', function(e) {
|
||||
const apiKey = document.getElementById('api_key').value;
|
||||
const action = e.submitter.value;
|
||||
var action = e.submitter.value;
|
||||
|
||||
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) {
|
||||
e.preventDefault();
|
||||
const lang = '{{ lang }}';
|
||||
const message = lang === 'fa'
|
||||
var lang = '{{ lang }}';
|
||||
var message = lang === 'fa'
|
||||
? 'لطفاً یک API Key معتبر وارد کنید'
|
||||
: 'Please enter a valid API key';
|
||||
alert(message);
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!apiKey.startsWith('cmp_') && !apiKey.startsWith('ckm_')) {
|
||||
const lang = '{{ lang }}';
|
||||
const message = lang === 'fa'
|
||||
if (!apiKey.startsWith('cmp_') && !apiKey.startsWith('ckm_') && !apiKey.startsWith('mhu_')) {
|
||||
var lang2 = '{{ lang }}';
|
||||
var msg = lang2 === 'fa'
|
||||
? 'فرمت API Key غیرمعمول به نظر میرسد. ادامه میدهید؟'
|
||||
: 'The API key format looks unusual. Continue anyway?';
|
||||
const confirm = window.confirm(message);
|
||||
if (!confirm) {
|
||||
if (!window.confirm(msg)) {
|
||||
e.preventDefault();
|
||||
return false;
|
||||
}
|
||||
@@ -158,13 +254,15 @@
|
||||
}
|
||||
});
|
||||
|
||||
// Focus effect
|
||||
const apiKeyInput = document.getElementById('api_key');
|
||||
// Focus effect for API key input (if present)
|
||||
var apiKeyInput = document.getElementById('api_key');
|
||||
if (apiKeyInput) {
|
||||
apiKeyInput.addEventListener('focus', function() {
|
||||
this.parentElement.classList.add('ring-2', 'ring-purple-200');
|
||||
});
|
||||
apiKeyInput.addEventListener('blur', function() {
|
||||
this.parentElement.classList.remove('ring-2', 'ring-purple-200');
|
||||
});
|
||||
}
|
||||
</script>
|
||||
{% endblock %}
|
||||
|
||||
@@ -210,6 +210,11 @@ async def user_mcp_handler(request: Request) -> Response:
|
||||
|
||||
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:
|
||||
from core.user_keys import get_user_key_manager
|
||||
|
||||
@@ -227,12 +232,47 @@ async def user_mcp_handler(request: Request) -> Response:
|
||||
status_code=401,
|
||||
)
|
||||
|
||||
# Ensure the API key belongs to the user in the URL
|
||||
if key_info["user_id"] != user_id:
|
||||
return JSONResponse(
|
||||
_jsonrpc_error(None, -32600, "API key does not match user"),
|
||||
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 ---
|
||||
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"))
|
||||
|
||||
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}
|
||||
required_level = scope_hierarchy.get(required_scope, 0)
|
||||
|
||||
@@ -42,6 +42,27 @@ For each WordPress site you want to manage:
|
||||
|
||||
## 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)
|
||||
|
||||
```bash
|
||||
@@ -162,6 +183,21 @@ GITEA_REPO1_ALIAS=mygitea
|
||||
OAUTH_JWT_SECRET_KEY=your-jwt-secret
|
||||
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
|
||||
# ============================================
|
||||
@@ -327,13 +363,15 @@ curl http://localhost:8000/health
|
||||
|
||||
**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:
|
||||
- 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
|
||||
- View audit logs
|
||||
- Monitor rate limits
|
||||
- **Generate AI client configs** on the Connect page (with correct transport type per client)
|
||||
- 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):**
|
||||
|
||||
|
||||
23
env.example
23
env.example
@@ -98,11 +98,26 @@ WORDPRESS_SITE1_ALIAS=mysite
|
||||
# N8N_INSTANCE1_API_KEY=your_n8n_api_key
|
||||
# N8N_INSTANCE1_ALIAS=myn8n
|
||||
|
||||
# --- Supabase ---
|
||||
# SUPABASE_PROJECT1_URL=https://xxxxx.supabase.co
|
||||
# SUPABASE_PROJECT1_API_KEY=your_supabase_api_key
|
||||
# SUPABASE_PROJECT1_SERVICE_ROLE=your_service_role_key
|
||||
# --- Supabase (Self-Hosted or Cloud) ---
|
||||
# Works with both self-hosted Supabase and supabase.com cloud projects.
|
||||
# For cloud: URL is https://xxxx.supabase.co
|
||||
# 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
|
||||
#
|
||||
# Optional: direct postgres-meta URL for self-hosted when /pg/ is not via Kong:
|
||||
# SUPABASE_PROJECT1_META_URL=http://localhost:5555
|
||||
|
||||
# --- OpenPanel ---
|
||||
# OPENPANEL_INSTANCE1_URL=https://openpanel.example.com
|
||||
|
||||
@@ -27,18 +27,30 @@ class SupabaseClient:
|
||||
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.
|
||||
|
||||
Args:
|
||||
base_url: Supabase instance URL (Kong gateway)
|
||||
anon_key: Public API key (RLS protected)
|
||||
service_role_key: Admin API key (bypasses RLS)
|
||||
base_url: Supabase instance URL (Kong gateway or supabase.co)
|
||||
anon_key: Public API key (RLS protected). Optional — if empty,
|
||||
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.anon_key = anon_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
|
||||
self.logger = logging.getLogger(f"SupabaseClient.{base_url}")
|
||||
@@ -56,7 +68,8 @@ class SupabaseClient:
|
||||
Returns:
|
||||
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 = {
|
||||
"apikey": key,
|
||||
@@ -70,15 +83,41 @@ class SupabaseClient:
|
||||
|
||||
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(
|
||||
self,
|
||||
method: str,
|
||||
endpoint: str,
|
||||
params: dict | None = None,
|
||||
json_data: dict | None = None,
|
||||
json_data: Any = None,
|
||||
data: bytes | None = None,
|
||||
headers_override: dict | None = None,
|
||||
use_service_role: bool = False,
|
||||
base_url_override: str | None = None,
|
||||
) -> Any:
|
||||
"""
|
||||
Make authenticated request to Supabase API.
|
||||
@@ -87,10 +126,11 @@ class SupabaseClient:
|
||||
method: HTTP method
|
||||
endpoint: API endpoint (with leading /)
|
||||
params: Query parameters
|
||||
json_data: JSON body data
|
||||
json_data: JSON body data (dict or list)
|
||||
data: Raw binary data (for file uploads)
|
||||
headers_override: Override/add headers
|
||||
use_service_role: Use service_role_key
|
||||
base_url_override: Override base URL (used for postgres-meta calls)
|
||||
|
||||
Returns:
|
||||
API response
|
||||
@@ -98,7 +138,7 @@ class SupabaseClient:
|
||||
Raises:
|
||||
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)
|
||||
|
||||
@@ -106,10 +146,10 @@ class SupabaseClient:
|
||||
if data is not None:
|
||||
headers.pop("Content-Type", None)
|
||||
|
||||
# Filter None values
|
||||
# Filter None values from params and dict json bodies
|
||||
if params:
|
||||
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}
|
||||
|
||||
self.logger.debug(f"{method} {url}")
|
||||
@@ -167,18 +207,70 @@ class SupabaseClient:
|
||||
def _extract_error_message(self, response_data: Any) -> str:
|
||||
"""Extract error message from various response formats."""
|
||||
if isinstance(response_data, dict):
|
||||
# PostgREST error format
|
||||
# PostgREST error format — includes code, details, hint
|
||||
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
|
||||
if "error_description" in response_data:
|
||||
return response_data["error_description"]
|
||||
if "msg" in response_data:
|
||||
return response_data["msg"]
|
||||
# postgres-meta error format — "error" key with optional PG fields
|
||||
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)
|
||||
|
||||
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)
|
||||
# =====================
|
||||
@@ -204,20 +296,15 @@ class SupabaseClient:
|
||||
limit: Maximum rows
|
||||
offset: Offset for pagination
|
||||
"""
|
||||
params = {"select": select, "limit": limit, "offset": offset}
|
||||
params: dict[str, Any] = {"select": select, "limit": limit, "offset": offset}
|
||||
|
||||
if order:
|
||||
params["order"] = order
|
||||
|
||||
# Build filter query string
|
||||
headers = {}
|
||||
if 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}"
|
||||
params.update(self._build_filter_params(filters))
|
||||
|
||||
headers = {}
|
||||
|
||||
# Request single objects as array
|
||||
headers["Accept"] = "application/json"
|
||||
@@ -240,15 +327,18 @@ class SupabaseClient:
|
||||
) -> list[dict]:
|
||||
"""Insert rows into a table."""
|
||||
headers = {"Prefer": "return=representation"}
|
||||
upsert_params: dict[str, str] = {}
|
||||
|
||||
if upsert:
|
||||
headers["Prefer"] = "return=representation,resolution=merge-duplicates"
|
||||
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(
|
||||
"POST",
|
||||
f"/rest/v1/{table}",
|
||||
params=upsert_params or None,
|
||||
json_data=rows if isinstance(rows, list) else [rows],
|
||||
headers_override=headers,
|
||||
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
|
||||
) -> list[dict]:
|
||||
"""Update rows matching filters."""
|
||||
params = {}
|
||||
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}"
|
||||
|
||||
params = self._build_filter_params(filters)
|
||||
headers = {"Prefer": "return=representation"}
|
||||
|
||||
return await self.request(
|
||||
@@ -281,14 +364,7 @@ class SupabaseClient:
|
||||
self, table: str, filters: list[dict], use_service_role: bool = False
|
||||
) -> list[dict]:
|
||||
"""Delete rows matching filters."""
|
||||
params = {}
|
||||
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}"
|
||||
|
||||
params = self._build_filter_params(filters)
|
||||
headers = {"Prefer": "return=representation"}
|
||||
|
||||
return await self.request(
|
||||
@@ -313,94 +389,133 @@ class SupabaseClient:
|
||||
async def count_rows(
|
||||
self, table: str, filters: list[dict] | None = None, use_service_role: bool = False
|
||||
) -> int:
|
||||
"""Count rows in a table."""
|
||||
params = {"select": "count"}
|
||||
"""Count rows in a table using HEAD + Content-Range header."""
|
||||
filter_params = self._build_filter_params(filters) if filters else {}
|
||||
|
||||
if 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 = {"Accept": "application/json", "Prefer": "count=exact"}
|
||||
|
||||
result = await self.request(
|
||||
"HEAD",
|
||||
response_headers = await self._head_request_headers(
|
||||
f"/rest/v1/{table}",
|
||||
params=params,
|
||||
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"}},
|
||||
params=filter_params or None,
|
||||
headers_override={"Prefer": "count=exact"},
|
||||
use_service_role=use_service_role,
|
||||
)
|
||||
|
||||
if isinstance(result, list) and len(result) > 0:
|
||||
return result[0].get("count", 0)
|
||||
# PostgREST returns count in Content-Range: 0-N/TOTAL or */TOTAL
|
||||
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
|
||||
|
||||
# =====================
|
||||
# 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]:
|
||||
"""List all tables in a schema."""
|
||||
"""List all tables via postgres-meta."""
|
||||
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:
|
||||
"""Get table schema/columns."""
|
||||
columns = await self.request(
|
||||
"GET", "/pg/columns", params={"table_name": table}, use_service_role=True
|
||||
"""Get table schema/columns via postgres-meta, filtered by table+schema in Python."""
|
||||
all_columns = await self.request(
|
||||
"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}
|
||||
|
||||
async def list_schemas(self) -> list[dict]:
|
||||
"""List all database schemas."""
|
||||
return await self.request("GET", "/pg/schemas", use_service_role=True)
|
||||
"""List all database schemas via postgres-meta."""
|
||||
return await self.request(
|
||||
"GET",
|
||||
"/schemas",
|
||||
use_service_role=True,
|
||||
base_url_override=self.meta_base_url,
|
||||
)
|
||||
|
||||
async def list_extensions(self) -> list[dict]:
|
||||
"""List installed extensions."""
|
||||
return await self.request("GET", "/pg/extensions", use_service_role=True)
|
||||
"""List installed extensions via postgres-meta."""
|
||||
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]:
|
||||
"""List RLS policies."""
|
||||
params = {}
|
||||
if table:
|
||||
params["table_name"] = table
|
||||
return await self.request("GET", "/pg/policies", params=params, use_service_role=True)
|
||||
"""List RLS policies via postgres-meta, optionally filtered by table name."""
|
||||
all_policies = await self.request(
|
||||
"GET",
|
||||
"/policies",
|
||||
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]:
|
||||
"""List database roles."""
|
||||
return await self.request("GET", "/pg/roles", use_service_role=True)
|
||||
"""List database roles via postgres-meta."""
|
||||
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]:
|
||||
"""List triggers."""
|
||||
params = {}
|
||||
if table:
|
||||
params["table_name"] = table
|
||||
return await self.request("GET", "/pg/triggers", params=params, use_service_role=True)
|
||||
"""List triggers via postgres-meta, optionally filtered by table name."""
|
||||
all_triggers = await self.request(
|
||||
"GET",
|
||||
"/triggers",
|
||||
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]:
|
||||
"""List database functions."""
|
||||
"""List database functions via postgres-meta."""
|
||||
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:
|
||||
"""Execute raw SQL query."""
|
||||
"""Execute raw SQL query via postgres-meta."""
|
||||
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)
|
||||
results["services"]["postgrest"] = "ok"
|
||||
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["healthy"] = False
|
||||
|
||||
|
||||
@@ -468,11 +468,15 @@ async def query_table(
|
||||
use_service_role=use_service_role,
|
||||
)
|
||||
|
||||
row_count = len(result) if isinstance(result, list) else 1
|
||||
return json.dumps(
|
||||
{
|
||||
"success": True,
|
||||
"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,
|
||||
},
|
||||
indent=2,
|
||||
@@ -500,16 +504,20 @@ async def insert_rows(
|
||||
use_service_role=use_service_role,
|
||||
)
|
||||
|
||||
return json.dumps(
|
||||
{
|
||||
affected = len(result) if isinstance(result, list) else 1
|
||||
response: dict = {
|
||||
"success": True,
|
||||
"table": table,
|
||||
"inserted": len(result) if isinstance(result, list) else 1,
|
||||
"inserted" if not upsert else "affected": affected,
|
||||
"data": result,
|
||||
},
|
||||
indent=2,
|
||||
ensure_ascii=False,
|
||||
}
|
||||
if upsert:
|
||||
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:
|
||||
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,
|
||||
)
|
||||
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:
|
||||
|
||||
@@ -41,8 +41,13 @@ class SupabasePlugin(BasePlugin):
|
||||
|
||||
@staticmethod
|
||||
def get_required_config_keys() -> list[str]:
|
||||
"""Return required configuration keys"""
|
||||
return ["url", "anon_key", "service_role_key"]
|
||||
"""Return required configuration keys.
|
||||
|
||||
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):
|
||||
"""
|
||||
@@ -50,9 +55,10 @@ class SupabasePlugin(BasePlugin):
|
||||
|
||||
Args:
|
||||
config: Configuration dictionary containing:
|
||||
- url: Supabase instance URL (Kong gateway)
|
||||
- anon_key: Anonymous key (RLS protected)
|
||||
- service_role_key: Service role key (bypasses RLS)
|
||||
- url: Supabase instance URL (Kong gateway or supabase.co)
|
||||
- service_role_key: Admin API key (bypasses RLS). Required.
|
||||
- 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)
|
||||
"""
|
||||
super().__init__(config, project_id=project_id)
|
||||
@@ -60,8 +66,9 @@ class SupabasePlugin(BasePlugin):
|
||||
# Create Supabase API client
|
||||
self.client = SupabaseClient(
|
||||
base_url=config["url"],
|
||||
anon_key=config["anon_key"],
|
||||
anon_key=config.get("anon_key", ""),
|
||||
service_role_key=config["service_role_key"],
|
||||
meta_url=config.get("meta_url"),
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
|
||||
@@ -47,7 +47,7 @@ class WooCommercePlugin(BasePlugin):
|
||||
@staticmethod
|
||||
def get_required_config_keys() -> list[str]:
|
||||
"""Return required configuration keys"""
|
||||
return ["url", "username", "app_password"]
|
||||
return ["url"]
|
||||
|
||||
def __init__(self, config: dict[str, Any], project_id: str | None = None):
|
||||
"""
|
||||
@@ -55,16 +55,30 @@ class WooCommercePlugin(BasePlugin):
|
||||
|
||||
Args:
|
||||
config: Configuration dictionary containing:
|
||||
- url: WordPress site URL
|
||||
- username: WordPress username
|
||||
- app_password: WordPress application password
|
||||
- url: WordPress/WooCommerce site URL
|
||||
- consumer_key/consumer_secret: WooCommerce REST API keys (preferred)
|
||||
- username/app_password: WordPress application password (fallback)
|
||||
project_id: Optional project ID (auto-generated if not provided)
|
||||
"""
|
||||
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(
|
||||
site_url=config["url"], username=config["username"], app_password=config["app_password"]
|
||||
site_url=config["url"], username=username, app_password=password
|
||||
)
|
||||
|
||||
# Initialize WooCommerce handlers
|
||||
|
||||
@@ -127,7 +127,7 @@ class WordPressClient:
|
||||
"""
|
||||
# Build URL based on endpoint type
|
||||
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}"
|
||||
elif use_woocommerce:
|
||||
# For WooCommerce endpoints
|
||||
|
||||
@@ -188,7 +188,7 @@ class SEOHandler:
|
||||
# First, try to use the new health check endpoint (v1.1.0+)
|
||||
try:
|
||||
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):
|
||||
@@ -383,7 +383,7 @@ class SEOHandler:
|
||||
try:
|
||||
# Use SEO API Bridge endpoint for products (same as update_product_seo)
|
||||
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)
|
||||
@@ -592,7 +592,7 @@ class SEOHandler:
|
||||
|
||||
# Use SEO API Bridge endpoint for products
|
||||
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,
|
||||
use_custom_namespace=True,
|
||||
)
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[project]
|
||||
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)"
|
||||
authors = [
|
||||
{name = "MCP Hub", email = "contact@mcphub.dev"}
|
||||
|
||||
246
server.py
246
server.py
@@ -18,6 +18,7 @@ Environment Variables:
|
||||
LOG_LEVEL: Logging level (DEBUG, INFO, WARNING, ERROR)
|
||||
"""
|
||||
|
||||
import base64
|
||||
import copy
|
||||
import logging
|
||||
import os
|
||||
@@ -66,6 +67,7 @@ from core.dashboard.routes import (
|
||||
api_list_keys,
|
||||
api_list_sites,
|
||||
api_test_site,
|
||||
api_update_site,
|
||||
# E.2: OAuth Social Login routes
|
||||
auth_callback,
|
||||
auth_login_page,
|
||||
@@ -106,7 +108,12 @@ from core.dashboard.routes import (
|
||||
dashboard_settings_page,
|
||||
# E.3: Site Management pages
|
||||
dashboard_sites_add,
|
||||
dashboard_sites_edit,
|
||||
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.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",
|
||||
"token_endpoint": f"{base_url}/oauth/token",
|
||||
"registration_endpoint": f"{base_url}/oauth/register", # RFC 7591 (requires auth)
|
||||
"revocation_endpoint": f"{base_url}/oauth/revoke",
|
||||
# Supported Features
|
||||
"response_types_supported": ["code"],
|
||||
"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"],
|
||||
"token_endpoint_auth_methods_supported": ["client_secret_post", "client_secret_basic"],
|
||||
# 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"],
|
||||
# Additional OAuth 2.1 Features
|
||||
"response_modes_supported": ["query"],
|
||||
@@ -2380,9 +2388,49 @@ async def oauth_authorize(request: Request):
|
||||
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
|
||||
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
|
||||
from core.oauth import get_client_registry
|
||||
|
||||
@@ -2417,9 +2465,13 @@ async def oauth_authorize(request: Request):
|
||||
"scope": validated["scope"],
|
||||
"scopes": scopes,
|
||||
"state": validated.get("state", ""),
|
||||
"resource": resource or "",
|
||||
"csrf_token": csrf_token,
|
||||
"lang": lang, # Language code (en/fa)
|
||||
"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
|
||||
form = await request.form()
|
||||
params = dict(form)
|
||||
resource = params.get("resource", "")
|
||||
|
||||
# Validate action
|
||||
action = params.get("action")
|
||||
@@ -2543,10 +2596,12 @@ async def oauth_authorize_confirm(request: Request):
|
||||
state=params.get("state"),
|
||||
)
|
||||
|
||||
# Validate API Key
|
||||
api_key = params.get("api_key")
|
||||
# Validate authentication (API Key or dashboard session)
|
||||
api_key = params.get("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_project_id = None
|
||||
@@ -2554,13 +2609,12 @@ async def oauth_authorize_confirm(request: Request):
|
||||
granted_scope = validated["scope"]
|
||||
|
||||
try:
|
||||
# Try as regular API key first
|
||||
# Path 1: Project API key (cmp_)
|
||||
if api_key.startswith("cmp_"):
|
||||
# Validate without project/scope check (we'll use its values)
|
||||
key_id = api_key_manager.validate_key(
|
||||
api_key,
|
||||
project_id="*", # Skip project check
|
||||
required_scope="read", # Skip scope check
|
||||
project_id="*",
|
||||
required_scope="read",
|
||||
skip_project_check=True,
|
||||
)
|
||||
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_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):
|
||||
# Master key → Full access
|
||||
api_key_id = "master"
|
||||
api_key_project_id = "*"
|
||||
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_project_id=api_key_project_id,
|
||||
api_key_scope=api_key_scope,
|
||||
resource=resource,
|
||||
)
|
||||
|
||||
# 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"
|
||||
)
|
||||
|
||||
# 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:
|
||||
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"]
|
||||
@@ -2773,6 +2904,79 @@ async def oauth_token(request: Request) -> JSONResponse:
|
||||
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 ===
|
||||
|
||||
|
||||
@@ -4590,7 +4794,14 @@ def create_multi_endpoint_app(transport: str = "streamable-http"):
|
||||
Route("/dashboard/logout", dashboard_logout, methods=["GET", "POST"]),
|
||||
Route("/dashboard/profile", dashboard_profile_page, 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"]),
|
||||
# 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", 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,
|
||||
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)
|
||||
Route("/dashboard/audit-logs", dashboard_audit_logs_list, 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/{id}/test", api_test_site, methods=["POST"]),
|
||||
Route("/api/sites/{id}", api_delete_site, methods=["DELETE"]),
|
||||
Route("/api/sites/{id}", api_update_site, methods=["PATCH"]),
|
||||
# User API Key routes (E.3)
|
||||
Route("/api/keys", api_list_keys, methods=["GET"]),
|
||||
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/confirm", oauth_authorize_confirm, 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)
|
||||
# Mount each FastMCP app - they handle their own /mcp path internally
|
||||
Mount("/system", app=system_app, name="mcp_system"), # Phase X.3
|
||||
|
||||
@@ -60,16 +60,18 @@ class TestClaudeFormat:
|
||||
assert server_name in config["mcpServers"]
|
||||
server = config["mcpServers"][server_name]
|
||||
assert server["url"] == EXPECTED_ENDPOINT
|
||||
assert server["type"] == "streamableHttp"
|
||||
assert f"Bearer {API_KEY}" in server["headers"]["Authorization"]
|
||||
|
||||
@pytest.mark.unit
|
||||
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")
|
||||
config = json.loads(snippet)
|
||||
assert "mcpServers" in config
|
||||
server_name = f"mcphub-{ALIAS}"
|
||||
assert server_name in config["mcpServers"]
|
||||
assert config["mcpServers"][server_name]["type"] == "http"
|
||||
|
||||
|
||||
# ── Cursor / VS Code Format ─────────────────────────────────
|
||||
@@ -80,7 +82,7 @@ class TestCursorVSCodeFormat:
|
||||
|
||||
@pytest.mark.unit
|
||||
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")
|
||||
config = json.loads(snippet)
|
||||
assert "mcp" in config
|
||||
@@ -89,15 +91,18 @@ class TestCursorVSCodeFormat:
|
||||
assert server_name in config["mcp"]["servers"]
|
||||
server = config["mcp"]["servers"][server_name]
|
||||
assert server["url"] == EXPECTED_ENDPOINT
|
||||
assert server["type"] == "http"
|
||||
assert f"Bearer {API_KEY}" in server["headers"]["Authorization"]
|
||||
|
||||
@pytest.mark.unit
|
||||
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")
|
||||
config = json.loads(snippet)
|
||||
assert "mcp" in config
|
||||
assert "servers" in config["mcp"]
|
||||
server_name = f"mcphub-{ALIAS}"
|
||||
assert config["mcp"]["servers"][server_name]["type"] == "http"
|
||||
|
||||
|
||||
# ── ChatGPT Format ───────────────────────────────────────────
|
||||
|
||||
165
tests/test_oauth_basic_auth.py
Normal file
165
tests/test_oauth_basic_auth.py
Normal 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"
|
||||
333
tests/test_oauth_resource.py
Normal file
333
tests/test_oauth_resource.py
Normal 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
227
tests/test_oauth_revoke.py
Normal 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", [])
|
||||
@@ -44,11 +44,10 @@ class TestWooCommercePluginInit:
|
||||
assert WooCommercePlugin.get_plugin_name() == "woocommerce"
|
||||
|
||||
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()
|
||||
assert "url" in keys
|
||||
assert "username" in keys
|
||||
assert "app_password" in keys
|
||||
assert len(keys) == 1
|
||||
|
||||
def test_missing_url_raises(self):
|
||||
"""Should raise ValueError for missing URL."""
|
||||
@@ -57,9 +56,11 @@ class TestWooCommercePluginInit:
|
||||
WooCommercePlugin(config)
|
||||
|
||||
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"}
|
||||
with pytest.raises(ValueError, match="Missing required configuration"):
|
||||
with pytest.raises(ConfigurationError, match="credentials not configured"):
|
||||
WooCommercePlugin(config)
|
||||
|
||||
def test_custom_project_id(self):
|
||||
|
||||
BIN
wordpress-plugin/airano-mcp-seo-bridge.zip
Normal file
BIN
wordpress-plugin/airano-mcp-seo-bridge.zip
Normal file
Binary file not shown.
@@ -1,4 +1,4 @@
|
||||
# SEO API Bridge - WordPress Plugin
|
||||
# Airano MCP SEO Bridge - WordPress Plugin
|
||||
|
||||
**Version:** 1.3.0
|
||||
**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
|
||||
|
||||
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
|
||||
3. Upload the ZIP file and click "Install Now"
|
||||
4. Click "Activate Plugin"
|
||||
|
||||
### 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
|
||||
3. Find "SEO API Bridge" and click "Activate"
|
||||
3. Find "Airano MCP SEO Bridge" and click "Activate"
|
||||
|
||||
### Method 3: WP-CLI
|
||||
|
||||
```bash
|
||||
cd /var/www/html/wp-content/plugins/
|
||||
# Copy the plugin folder here
|
||||
wp plugin activate seo-api-bridge
|
||||
wp plugin activate airano-mcp-seo-bridge
|
||||
```
|
||||
|
||||
## REST API Endpoints
|
||||
@@ -97,10 +97,10 @@ All endpoints require **WordPress Application Password** authentication.
|
||||
|
||||
### Status Endpoint
|
||||
|
||||
**GET** `/wp-json/seo-api-bridge/v1/status`
|
||||
**GET** `/wp-json/airano-mcp-seo-bridge/v1/status`
|
||||
|
||||
```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"
|
||||
```
|
||||
|
||||
@@ -121,17 +121,17 @@ curl -X GET "https://yoursite.com/wp-json/seo-api-bridge/v1/status" \
|
||||
|
||||
### 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
|
||||
# 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"
|
||||
|
||||
# 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" \
|
||||
-H "Content-Type: application/json" \
|
||||
-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
|
||||
|
||||
**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)
|
||||
|
||||
**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
|
||||
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" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"focus_keyword": "product keyword", "seo_title": "Product Title"}'
|
||||
@@ -223,7 +223,7 @@ result = await mcp.call_tool(
|
||||
|
||||
2. **Status Endpoint:**
|
||||
```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"
|
||||
```
|
||||
|
||||
@@ -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)
|
||||
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
|
||||
|
||||
@@ -285,7 +285,7 @@ curl -X GET "https://your-site.com/wp-json/wp/v2/posts/1" \
|
||||
- Improved MariaDB compatibility for meta field queries
|
||||
|
||||
### 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
|
||||
- Better compatibility with sites that have no content
|
||||
- Returns SEO plugin versions in status endpoint
|
||||
@@ -1,4 +1,4 @@
|
||||
=== SEO API Bridge ===
|
||||
=== Airano MCP SEO Meta Bridge ===
|
||||
Contributors: airano
|
||||
Tags: seo, rest-api, rank-math, yoast, mcp
|
||||
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 ==
|
||||
|
||||
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:**
|
||||
|
||||
@@ -26,16 +26,16 @@ SEO API Bridge is a WordPress plugin that exposes Rank Math SEO and Yoast SEO me
|
||||
|
||||
**REST API Endpoints:**
|
||||
|
||||
* `GET/POST /wp-json/seo-api-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/seo-api-bridge/v1/products/{id}/seo` — Product SEO data (WooCommerce)
|
||||
* `GET /wp-json/seo-api-bridge/v1/status` — Plugin status and SEO detection
|
||||
* `GET/POST /wp-json/airano-mcp-seo-bridge/v1/posts/{id}/seo` — Post SEO data
|
||||
* `GET/POST /wp-json/airano-mcp-seo-bridge/v1/pages/{id}/seo` — Page SEO data
|
||||
* `GET/POST /wp-json/airano-mcp-seo-bridge/v1/products/{id}/seo` — Product SEO data (WooCommerce)
|
||||
* `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.
|
||||
|
||||
== 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
|
||||
3. Ensure either Rank Math SEO or Yoast SEO is active
|
||||
4. The REST API endpoints are now available
|
||||
@@ -1,6 +1,6 @@
|
||||
<?php
|
||||
/**
|
||||
* Plugin Name: SEO API Bridge
|
||||
* Plugin Name: Airano MCP SEO Meta Bridge
|
||||
* 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.
|
||||
* Version: 1.3.0
|
||||
@@ -9,7 +9,7 @@
|
||||
* License: GPL-2.0-or-later
|
||||
* Requires at least: 5.0
|
||||
* Requires PHP: 7.4
|
||||
* Text Domain: seo-api-bridge
|
||||
* Text Domain: airano-mcp-seo-bridge
|
||||
*
|
||||
* Changelog:
|
||||
* 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 {
|
||||
|
||||
@@ -57,7 +57,7 @@ class SEO_API_Bridge {
|
||||
*/
|
||||
public function register_rest_routes() {
|
||||
// Status endpoint
|
||||
register_rest_route('seo-api-bridge/v1', '/status', [
|
||||
register_rest_route('airano-mcp-seo-bridge/v1', '/status', [
|
||||
'methods' => 'GET',
|
||||
'callback' => [$this, 'get_status'],
|
||||
'permission_callback' => function() {
|
||||
@@ -66,7 +66,7 @@ class SEO_API_Bridge {
|
||||
]);
|
||||
|
||||
// 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',
|
||||
'callback' => [$this, 'get_post_seo'],
|
||||
@@ -98,7 +98,7 @@ class SEO_API_Bridge {
|
||||
]);
|
||||
|
||||
// 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',
|
||||
'callback' => [$this, 'get_page_seo'],
|
||||
@@ -130,7 +130,7 @@ class SEO_API_Bridge {
|
||||
]);
|
||||
|
||||
// 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',
|
||||
'callback' => [$this, 'get_product_seo'],
|
||||
@@ -170,7 +170,7 @@ class SEO_API_Bridge {
|
||||
$yoast_active = $this->is_yoast_active();
|
||||
|
||||
$response = [
|
||||
'plugin' => 'SEO API Bridge',
|
||||
'plugin' => 'Airano MCP SEO Meta Bridge',
|
||||
'version' => self::VERSION,
|
||||
'active' => true,
|
||||
'seo_plugins' => [
|
||||
@@ -202,7 +202,7 @@ class SEO_API_Bridge {
|
||||
if ($rank_math_active) $active_plugins[] = 'Rank Math 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) {
|
||||
foreach ($this->supported_post_types as $post_type) {
|
||||
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, [
|
||||
'show_in_rest' => true,
|
||||
'show_in_rest' => $show_in_rest,
|
||||
'single' => $args['single'],
|
||||
'type' => $args['type'],
|
||||
'description' => $args['description'],
|
||||
@@ -686,7 +690,7 @@ class SEO_API_Bridge {
|
||||
|
||||
if (!$rank_math_active && !$yoast_active) {
|
||||
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>';
|
||||
} else {
|
||||
$active_plugins = [];
|
||||
@@ -696,11 +700,11 @@ class SEO_API_Bridge {
|
||||
$supported_types = implode(', ', $this->supported_post_types);
|
||||
|
||||
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>' . esc_html__( 'Supported post types:', 'seo-api-bridge' ) . '</strong> ' . esc_html( $supported_types ) . '</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:', 'airano-mcp-seo-bridge' ) . '</strong> ' . esc_html( $supported_types ) . '</p>';
|
||||
|
||||
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>';
|
||||
}
|
||||
Binary file not shown.
Reference in New Issue
Block a user