44 Commits

Author SHA1 Message Date
f203ca88de feat(v3.12.0): media pipeline, AI image generation, capability probe, companion v2.9.0
Some checks failed
Release / Test before release (push) Has been cancelled
Release / Publish to PyPI (push) Has been cancelled
Release / Publish to Docker Hub (push) Has been cancelled
Release / Create GitHub Release (push) Has been cancelled
Three-month batch sync from internal repo (~80 commits) covering Tracks F.5a, F.7e, F.8, F.17, F.18, F.X.

WordPress media pipeline
- Pillow-based optimization, AI image generation (OpenAI / Stability / Replicate / Google Nano Banana / OpenRouter), chunked + resumable uploads, bulk delete/reassign, idempotent retries.

Capability discovery (F.7e)
- Per-site credential probe + adapters for WordPress / WooCommerce / Gitea, tier-fit unions granted ∪ roles, capability badge UI with HTMX partial re-check, install hint in every companion-unreachable error.

Companion plugin overhaul
- Renamed wordpress-plugin/airano-mcp-seo-bridge → wordpress-plugin/airano-mcp-bridge.
- Eight new endpoints: /capabilities, /bulk-meta, /export, /cache-purge, /transient-flush, /site-health, /audit-hook, /upload-and-attach.
- wp.org Plugin Check pass: i18n, WP_Filesystem, scheme allowlist on audit-hook URL.

Other
- Gitea ergonomics (F.17): batch files, tree, search, compare, releases, fork.
- Opportunistic bcrypt upgrade for legacy SHA-256 admin keys (F.8).
- n8n refactor: structured errors, capability probe, missing tools backfilled.
- Idempotency-Key dedup for AI media upload retries; WP client fast-fails on unreachable sites.

Docs
- README + CLAUDE.md drop the fixed "633 tools" claim. The total grows with each release; per-plugin approximations + dashboard-surfaced counts replace it.
- Tools/Tests badges removed in favour of "Plugins: 10".

Deployment
- PyPI mirror chain, optional BUILD_HTTP_PROXY, Alpine→Yandex apk mirror, Debian-slim Plan-B Dockerfile, mirror.gcr.io variant.

CI
- Black + Ruff clean on Python 3.12; pytest tests/ green.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-25 16:25:58 +02:00
788439e377 feat(F.7+F.17): v3.11.0 — Coolify plugin (67 tools) + tool access overhaul
Some checks failed
Release / Test before release (push) Has been cancelled
Release / Publish to PyPI (push) Has been cancelled
Release / Publish to Docker Hub (push) Has been cancelled
Release / Create GitHub Release (push) Has been cancelled
Catch-up sync spanning v3.7.0 → v3.11.0 of the internal repo.

Platform
- Total tools: 565 → 633 (+68) across 10 plugins (Coolify added)
- Tests: 481 → 828 passing

New plugin: Coolify (67 tools, Track F.17)
- Applications (17): CRUD, lifecycle, logs, env vars
- Deployments (5): list/get/cancel/deploy, app history
- Servers (8): CRUD, resources, domains, validation
- Projects (8), Databases (16, 6 DB types + backups), Services (13)

Tool access system (Track F.7 → F.7d)
- Scope → category mapping with per-tool `category` + `sensitivity`
- Schema v7: `site_tool_toggles(site_id)` + `sites.tool_scope` column
- Schema v8: per-site API keys (`api_keys.site_id`)
- Plugin-specific access-level presets (WP / WC / Gitea / OpenPanel /
  Coolify 5-tier)
- Credential-requirement notice tailored per plugin and tier
- Admin Tools count card on service page
- Dropped redundant `write` tier on WP / WP Advanced / WooCommerce
  (admin-scope tool count = 0 → identical to admin tier)

Dashboard
- Unified site manage page (Connection / Tool Access / Connect)
- /dashboard/keys unified (was /api-keys and /connect)
- CSRF interceptor via meta-tag; removed conflicting cookie reader
- Tailwind: pre-built CSS (scripts/build-css.sh) replaces CDN

Docs
- README / DOCKER_README / CLAUDE updated to 633 tools / 10 plugins
- CHANGELOG entries for v3.7.0 → v3.11.0
- FastMCP compatibility note updated to 3.x (post-v3.5 upgrade)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-14 21:05:07 +02:00
d8a0805412 feat(F.7b): tool access UI + unified keys page (v3.9.0)
Some checks failed
Release / Test before release (push) Has been cancelled
Release / Publish to PyPI (push) Has been cancelled
Release / Publish to Docker Hub (push) Has been cancelled
Release / Create GitHub Release (push) Has been cancelled
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-06 21:00:42 +02:00
3fc02b5734 chore: bump version to v3.8.0
Some checks failed
Release / Test before release (push) Has been cancelled
Release / Publish to PyPI (push) Has been cancelled
Release / Publish to Docker Hub (push) Has been cancelled
Release / Create GitHub Release (push) Has been cancelled
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-04 15:43:37 +02:00
d7e3946e11 feat(F.17): add Coolify projects, databases, services — 67 tools total
Add 3 new Coolify plugin handlers (37 new tools, 67 total):
- projects.py: 8 tools (CRUD projects + environments)
- databases.py: 16 tools (CRUD, lifecycle, 6 DB types, backups)
- services.py: 13 tools (CRUD, lifecycle, env vars)

734 tests passing. Total platform tools: 633.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-04 15:34:30 +02:00
151ba8e6e6 feat: v3.7.0 — Coolify MCP plugin (30 tools)
Some checks failed
Release / Test before release (push) Has been cancelled
Release / Publish to PyPI (push) Has been cancelled
Release / Publish to Docker Hub (push) Has been cancelled
Release / Create GitHub Release (push) Has been cancelled
New plugin: Coolify deployment management (applications, deployments, servers).
10 plugins, 596 tools, 686 tests passing.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-02 18:55:01 +02:00
bf893a743a fix(F.16): update visibility tests — gitea is now public
Some checks failed
Release / Test before release (push) Has been cancelled
Release / Publish to PyPI (push) Has been cancelled
Release / Publish to Docker Hub (push) Has been cancelled
Release / Create GitHub Release (push) Has been cancelled
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-02 08:00:16 +02:00
2c9d4ed3d2 feat(F.16): Gitea plugin review, test & public release — v3.6.0
- Added update_webhook and delete_label tools (58 total Gitea tools)
- 97 unit tests (test_gitea_plugin.py)
- Gitea now public for all users (DEFAULT_PUBLIC_PLUGINS)
- Fixed merge_pull_request optional field handling

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-02 07:45:17 +02:00
4f0c2f9bb0 chore: add glama.json for MCP server registry
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-02 06:13:50 +02:00
d79c2fd94a style: fix black formatting in routes.py
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-02 06:08:56 +02:00
1615daf1a7 chore(F.15): FastMCP 3.x upgrade + legacy cleanup — v3.5.0
Some checks failed
Release / Test before release (push) Has been cancelled
Release / Publish to PyPI (push) Has been cancelled
Release / Publish to Docker Hub (push) Has been cancelled
Release / Create GitHub Release (push) Has been cancelled
- Upgrade fastmcp from >=2.14.0,<3.0.0 to >=3.0.0,<4.0.0
- Remove server_multi.py (legacy multi-endpoint server)
- Remove core/project_manager.py (legacy project manager)
- Refactor HealthMonitor to use SiteManager exclusively
- Replace _tool_manager._tools with tracked _tool_counts dict
- Clean core/__init__.py exports
- Update CHANGELOG, CLAUDE.md, tests
- Auto-format fixes (black + ruff)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-02 03:29:38 +02:00
c47ec7fd28 docs: fix tool counts (596→565), update live URL, clean Docker README
- Fix tool count: 596→565 (OpenPanel reduced from 73→42 in v3.4.0)
- Update DOCKER_README: dashboard-first site configuration
- Update .env.example version to 3.4.0

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-02 00:38:46 +02:00
3815f5a6d5 chore(F.4e): remove env-based site loading, unify OAuth Clients, update docs
- Remove env-based site discovery from SiteManager and ProjectManager
- Unify admin and user OAuth Clients into single role-based page
- Update all documentation to reflect DB-based site management
- Clean up env.example, README, ARCHITECTURE docs
- Improve Projects page empty state with link to My Sites
- Remove obsolete env discovery tests

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-02 00:24:33 +02:00
81417b552f fix: black formatting routes.py (Python 3.12 compat)
Some checks failed
Release / Test before release (push) Has been cancelled
Release / Publish to PyPI (push) Has been cancelled
Release / Publish to Docker Hub (push) Has been cancelled
Release / Create GitHub Release (push) Has been cancelled
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-31 17:58:30 +02:00
75cac1cb4d fix: ruff import organization in server.py
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-31 17:56:39 +02:00
12b06ce66f fix: black formatting for CI (Python 3.12 compat)
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-31 17:52:54 +02:00
9905a28eb0 feat: v3.4.0 — OpenPanel plugin public release (42 tools)
OpenPanel product analytics plugin fully reviewed, tested, and published.
Works with both self-hosted and cloud (openpanel.dev) instances.

- 42 tools: event tracking, data export, analytics, project/client management
- All tools use public REST APIs (Track, Export, Insights, Manage)
- Client modes: write (tracking), read (analytics), root (full access)
- Service page with description, setup notes, WordPress plugin download
- Dynamic URL hints in Add Site form
- 62 unit tests
- ENABLED_PLUGINS default now includes openpanel

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-31 17:48:12 +02:00
68c84b3ece fix(tests): update supported clients count to 6 (added claude_connectors)
Some checks failed
Release / Test before release (push) Has been cancelled
Release / Publish to PyPI (push) Has been cancelled
Release / Publish to Docker Hub (push) Has been cancelled
Release / Create GitHub Release (push) Has been cancelled
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-31 14:58:59 +02:00
9b70b259bb release: v3.3.0 — platform hardening & admin unification (Track F.1–F.8)
Plugin visibility control, UI/UX fixes, unified admin panel,
database-backed settings, and security hardening.

Highlights:
- ENABLED_PLUGINS env var for plugin visibility (F.1)
- Admin designation via ADMIN_EMAILS (F.4a)
- Master key scope control (F.4b)
- Panel unification + settings from UI (F.4c)
- exec() removal, shell injection fix, bcrypt migration (F.8)
- 5 new test suites

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-31 09:55:49 +02:00
airano
f6dbbeaab0 sync: v3.2.0 pre-release audit — namespace fix, config types, docs update
Some checks failed
Release / Test before release (push) Has been cancelled
Release / Publish to PyPI (push) Has been cancelled
Release / Publish to Docker Hub (push) Has been cancelled
Release / Create GitHub Release (push) Has been cancelled
- fix(seo): REST API namespace seo-api-bridge/v1 → airano-mcp-seo-bridge/v1
- fix(config): add transport type field (streamableHttp/http) to config snippets
- feat(dashboard): support section for OAuth users, connect page guidance
- docs: hosted option, test count 481, WP plugin link, OAuth env vars, per-user endpoints

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-11 04:23:45 +03:30
airano
c0d55f9061 sync: v3.2.0 — Supabase fixes, site edit, WP plugin rename, black formatting
- Supabase: fix filters, count_rows, upsert, anon_key optional, meta_url field
- Dashboard: site edit page + PATCH update route
- WP Plugin: rename SEO API Bridge → Airano MCP SEO Bridge
- supabase/client.py: black formatting fix
- .gitignore: exclude BUILD_REPORT.md

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

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

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

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

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

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

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

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

23 new tests (481 total), all passing.

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

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-24 07:16:05 +03:30
airano
983d93c2a7 fix: BUG-7, BUG-10, BUG-11, BUG-12 — remaining bug fixes
Some checks failed
Release / Test before release (push) Has been cancelled
Release / Publish to PyPI (push) Has been cancelled
Release / Publish to Docker Hub (push) Has been cancelled
Release / Create GitHub Release (push) Has been cancelled
- BUG-12: ENCRYPTION_KEY validation now exits with sys.exit(1) on invalid key
- BUG-11: WP Advanced health check adds authenticated fallback with auth_valid
- BUG-7: Catch-all GET route serves styled 404 HTML instead of plain text
- BUG-10: Recent Activity fetches extra entries before filtering, fixes None project

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-24 05:40:36 +03:30
airano
80377a1a22 fix: 15 bug fixes for v3.1.0 dashboard and Track E features
Bug fixes (ordered by priority):
- BUG-14 (P1): Fix ImportError in per-user MCP endpoint
- BUG-8 (P2): Raise clear error when PUBLIC_URL missing for OAuth
- BUG-12 (P3): Validate ENCRYPTION_KEY at startup
- BUG-13 (P4): Handle stale JWT gracefully on site creation
- BUG-11 (P5): Add auth validation to WordPress health checks
- BUG-15 (P6): Fix copy button showing Python repr (dict method shadow)
- BUG-1 (P7): Dynamic version from pyproject.toml instead of hardcoded
- BUG-5 (P8): Fix server mode default (sse → streamable-http)
- BUG-9 (P9): Plugin-specific example tools in endpoint instructions
- BUG-4 (P10): Fix settings page sidebar RBAC (session type mismatch)
- BUG-6 (P11): Show real plugin descriptions instead of 'No description'
- BUG-2 (P12): Remove stale 'phase: X.3' from system info
- BUG-10 (P13): Guard timestamps against None in Recent Activity
- BUG-3 (P14): Guard timestamps against None in audit logs
- BUG-7 (P15): Styled 404 page instead of plain text

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-24 03:30:16 +03:30
airano
684c889c27 style: fix import ordering in test_dashboard.py
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-23 23:31:00 +03:30
airano
55d30ba876 style: fix ruff lint errors (unused imports, import sorting, SIM103)
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-23 23:27:27 +03:30
airano
eceba04578 chore: remove pytest-out.txt and update .gitignore
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-23 23:24:31 +03:30
airano
1fcc539093 style: format 9 files with Black
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-23 23:22:52 +03:30
airano
9e6f06d933 feat: v3.1.0 — Live Platform Foundation (Track E.1-E.3)
Major release introducing the Live Platform architecture:

- SQLite database backend with async operations and migrations (E.1)
- AES-256-GCM credential encryption with HKDF key derivation (E.1)
- OAuth Social Login with GitHub and Google (E.2)
- Site management API with encrypted credential storage (E.3)
- Per-user MCP endpoints at /u/{user_id}/{alias}/mcp (E.3)
- User API keys with bcrypt hashing (E.3)
- Auto-generated config snippets for 5 MCP clients (E.3)
- Dashboard: dark/light mode, RBAC, My Sites, Connect page
- Active background health checks
- 452 tests (up from 303), all passing

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-23 23:20:03 +03:30
airano
42ea75bcb3 fix: remove unused asyncio import in test_wordpress_plugin
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-19 15:07:44 +03:30
airano
647a650629 feat(error-handling): add network error differentiation, retry, and REST API detection (C.5)
WordPress client improvements:
- Add ConnectionError exception for network-level failures
- Differentiate DNS, SSL, timeout, and connection refused errors
- Add retry with exponential backoff for transient errors (502, 503, 504, 429)
- Auth/config errors are never retried
- Add request timeout (30s default)

Health check improvements:
- Detect REST API disabled (403/404 on /wp-json)
- Classify errors by type: dns_failure, ssl_error, timeout, connection_refused

Bare except cleanup:
- Replaced all bare except: with except Exception: across 12 files

Tests: 303 passed (13 new tests for error handling)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-19 14:58:16 +03:30
airano
c57fd666c9 feat(registry): add smithery.yaml, server.json, and MCP Registry verification tag
- smithery.yaml: Smithery.ai configuration for PyPI install via uvx
- server.json: Official MCP Registry manifest (io.github.airano-ir/mcphub)
- README.md: add <!-- mcp-name --> verification tag for MCP Registry publishing

Part of D.4 — MCP Registry submissions

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-19 14:10:54 +03:30
airano
7a16a260f1 fix: use find_dotenv(usecwd=True) for PyPI .env loading
Some checks failed
Release / Test before release (push) Has been cancelled
Release / Publish to PyPI (push) Has been cancelled
Release / Publish to Docker Hub (push) Has been cancelled
Release / Create GitHub Release (push) Has been cancelled
load_dotenv() without usecwd=True fails for mcphub.exe because
find_dotenv() walks the call stack and searches from site-packages/
instead of the user's working directory. Bump to v3.0.4.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-19 04:45:53 +03:30
airano
f29dc299b2 fix: make version display dynamic across all surfaces
- routes.py: fallback reads from importlib.metadata instead of hardcoded
- login.html: remove hardcoded default version
- README: use dynamic GitHub Release badge from shields.io
- DOCKER_README: remove hardcoded version string

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-19 04:04:15 +03:30
airano
5d97b31c12 fix: auto-load .env file for PyPI installations
Some checks failed
Release / Test before release (push) Has been cancelled
Release / Publish to PyPI (push) Has been cancelled
Release / Publish to Docker Hub (push) Has been cancelled
Release / Create GitHub Release (push) Has been cancelled
PyPI users running `mcphub` had to manually export env vars because
server.py didn't call load_dotenv(). Docker users were unaffected
since docker-compose loads .env automatically.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-19 03:59:26 +03:30
airano
72cfdad5e3 style: fix black formatting in health.py
Some checks failed
Release / Test before release (push) Has been cancelled
Release / Publish to PyPI (push) Has been cancelled
Release / Publish to Docker Hub (push) Has been cancelled
Release / Create GitHub Release (push) Has been cancelled
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-19 03:40:08 +03:30
airano
5ce70f120e chore: bump version to 3.0.2
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-19 03:38:13 +03:30
airano
cafcd64eff fix(health): resolve "Site not found" for wordpress_advanced health checks
The health monitor's _basic_site_health_check() used split("_", 1) to parse
project IDs like "wordpress_advanced_site1", incorrectly yielding
plugin_type="wordpress" and site_id="advanced_site1". Now uses SiteManager's
list_all_sites() for proper full_id lookup, and creates a temporary plugin
instance for proper health checks (REST API, WP-CLI) instead of basic HTTP.

Also fixes the same split bug in SiteManager.get_effective_path_suffix().

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-19 03:29:05 +03:30
267 changed files with 49394 additions and 8801 deletions

View File

@@ -2,16 +2,19 @@
# MCP Hub - Environment Configuration
# ==============================================================================
#
# Version: 3.0.0
# Last Updated: 2026-02-17
# Version: 3.4.0
# Last Updated: 2026-04-02
#
# This file contains all environment variables needed to run the MCP server.
# This file contains environment variables needed to run the MCP server.
# Copy this file to .env and fill in your actual values.
#
# SITES: Sites are managed via the web dashboard (DB-based), not env vars.
# The plugin sections below are kept as credential reference only.
#
# SECURITY NOTE: Never commit .env file to version control!
#
# Multi-Endpoint Architecture (v3.0.0):
# - /mcp → Admin (all 587 tools, Master API Key required)
# - /mcp → Admin (all 565 tools, Master API Key required)
# - /system/mcp → System (17 tools, Master Key)
# - /wordpress/mcp → WordPress Core (65 tools)
# - /woocommerce/mcp → WooCommerce (28 tools)
@@ -19,7 +22,7 @@
# - /gitea/mcp → Gitea (56 tools)
# - /n8n/mcp → n8n Automation (56 tools)
# - /supabase/mcp → Supabase Self-Hosted (70 tools)
# - /openpanel/mcp → OpenPanel Analytics (73 tools)
# - /openpanel/mcp → OpenPanel Analytics (42 tools)
# - /appwrite/mcp → Appwrite Backend (100 tools)
# - /directus/mcp → Directus CMS (100 tools)
# - /project/{alias}/mcp → Project-specific (site-locked tools)

11
.gitignore vendored
View File

@@ -28,6 +28,8 @@ ENV/
.coverage.*
htmlcov/
.tox/
pytest-out.txt
scripts/community-build/BUILD_REPORT.md
.nox/
coverage.xml
*.cover
@@ -159,3 +161,12 @@ pytest-cache-files-*/
# Project specific
# ====================================
# Add project-specific ignores here
.worktrees/
worktrees/
/.agents
uv.lock
# planning-with-files skill artifacts (session-local)
task_plan.md
findings.md
progress.md

View File

@@ -5,6 +5,329 @@ 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.12.0] — 2026-04-25
### Media pipeline, AI image generation, capability probe, companion v2.9.0 (Tracks F.5a + F.7e + F.18)
The biggest release since v3.11.0 — three months of incremental work batched into one community drop. Core themes:
- **WordPress media pipeline**: end-to-end image flow with optimization, AI generation, and chunked uploads.
- **Capability discovery**: every site now publishes what it can actually do, and the dashboard reflects it.
- **Companion plugin overhaul**: `airano-mcp-bridge` (renamed from `airano-mcp-seo-bridge`) gained eight new endpoints and a wp.org Plugin Check pass.
- **Reliability polish**: idempotent retries, fast-fail on unreachable sites, install hints in every error.
#### Added
- **AI image generation** (`F.5a.4` / `F.5a.9`): pluggable provider system (OpenAI, Stability, Replicate, Google Nano Banana, OpenRouter image models) → `wordpress_generate_and_upload_image`, attached as featured media or to WC products in one call.
- **Pillow-based image optimization pipeline** (`F.5a.2`): client-side resize / format conversion / quality knobs before upload.
- **Chunked media upload** (`F.5a.5`): large files uploaded in resumable chunks; `upload_media_chunked_status` lets you resume after a disconnect (`F.5a.8.4`).
- **Bulk media tools** (`F.5a.8.3`): `bulk_delete_media` and `bulk_reassign_media`.
- **Idempotency-Key dedup** for AI media upload retries — same key returns the previous result instead of double-uploading.
- **Companion v2.9.0** (`airano-mcp-bridge`):
- `/upload-and-attach` — single-call combined endpoint (`F.5a.8.5`).
- `/regenerate-thumbnails` (`F.5a.8.2`).
- `/capabilities` + `wordpress_probe_capabilities` (`F.18.1`).
- `/bulk-meta` + `bulk_update_meta` (`F.18.2`).
- `/export` + `export_content` (`F.18.3`).
- `/cache-purge` + `cache_purge` (`F.18.4`).
- `/transient-flush` + `transient_flush` (`F.18.5`).
- `/site-health` + `site_health` (`F.18.6`).
- `/audit-hook` + MCPHub webhook receiver (`F.18.7`).
- **Provider Keys dashboard** (`F.18.8`): per-site AI provider key management UI with no-reload UX; AI tool visibility is now gated on per-site provider key presence.
- **Credential capability probe** (`F.7e`): WordPress / WooCommerce / Gitea adapters report what tier of operations the saved credential can actually perform; capability badge UI + HTMX partial re-check on the site manage page.
- **Tier requirements engine**: `TIER_REQUIREMENTS` + `evaluate_tier_fit(granted roles)` so alias matching unions both sources.
- **Companion install hint** in every companion-unreachable error (`F.7e`).
- **Gitea ergonomics** (`F.17`): batch file ops, tree, search, compare, releases, fork shortcuts.
- **Opportunistic bcrypt upgrade** for legacy SHA-256 admin keys (`F.8`).
- **n8n refactor**: structured errors, capability probe, missing tools backfilled.
#### Changed
- **`get_post`** returns `slug`, `featured_media`, `featured_media_url` by default.
- **`get_media` / `list_media`** expose `post_parent`.
- **WordPress client** fast-fails on unreachable sites with an `install_hint`.
- **Rank Math** integration uses canonical `rank_math_title` instead of the older `rank_math_seo_title`.
- **Tool prerequisites** surfaced in the dashboard; tool list shows the expected provider/credential per tier.
- **WooCommerce probe** infers capabilities from REST key scope.
- **Default model** + dark background polish across dashboard surfaces.
- **Companion route map** is single-source (`capabilities` and `site_health` share their definitions).
#### Fixed
- **AI provider gating**: per-site visibility now requires a present provider key; surfaces correctly hide tools when keys are absent.
- **Capability badge re-check** is an HTMX partial swap (no full reload).
- **WC media tools** accept WordPress App Password and global attribute id.
- **`set_featured_image` for WC products**, AI image attach to WC products, provider enum narrowing, WC badge wording.
- **Companion `airano-mcp-bridge` security**: scheme allowlist on the audit-hook endpoint URL; wp.org Plugin Check warnings (i18n + `WP_Filesystem`) cleared.
#### Deployment
- **PyPI mirror chain** + removed dead hardcoded proxy.
- **Optional `BUILD_HTTP_PROXY`** for restrictive build networks.
- Alpine `apk` swapped to Yandex mirror; **Debian-slim Plan-B** Dockerfile added.
- `mirror.gcr.io` variant for Docker Hub TLS-timeout fallback.
- Gitea mirror documented as fallback deploy source.
#### Documentation
- README: dropped fixed "633 tools" claims in favour of per-plugin approximations + a note that the count grows with each release.
- CLAUDE.md: total tool count is no longer asserted as a fixed number.
#### Companion plugin
- **Renamed**: `airano-mcp-seo-bridge``airano-mcp-bridge` (the plugin now does much more than SEO).
---
## [3.11.0] — 2026-04-14
### Plugin-specific access levels, regression tests, UX polish (Track F.7d)
Polish pass on top of v3.10.0 tool-access hardening. Access levels and credential guidance are now tailored per plugin; UX signals have been tightened.
#### Added
- **Plugin-aware scope presets** (`core/tool_access.py::get_scope_presets_for_plugin`): WordPress / WordPress Advanced / WooCommerce / Gitea / Coolify (5 tiers) / OpenPanel — each with tailored EN+FA labels and one-line hints. Custom preset always available.
- **Admin Tools card** on per-service dashboard page (4-col grid when `admin` tools > 0).
- **Credential requirement notice** on Tool Access: describes exactly what the saved Credential must grant, per plugin/tier (WP Application Password, WooCommerce REST key scope, Gitea scoped tokens, OpenPanel client mode, Coolify API token permissions).
- **Auto-scoped quick key** on the Connect section (site_id injected → key capped to this site's tier).
#### Changed
- Scope buttons rendered dynamically from `scope_presets_json` (was: hardcoded 4 tiers).
- WordPress / WP Advanced / WooCommerce: dropped redundant `write` tier (admin-scope tool count = 0) — now **Read Only + Full Access + Custom**.
- Coolify tool list visually filtered by category for all 5 tiers in JS (mirrors `SCOPE_TO_CATEGORIES`).
- OpenPanel Client ID hint clarifies the correct dashboard path (`{org}/{project-id}/settings/clients`).
---
## [3.10.0] — 2026-04-12
### Tool access hardening + per-site API keys + Tailwind build (Track F.7c)
#### Added
- **Per-site API keys** (DB migration v8 adds `site_id` column): UI site selector in create modal; endpoint enforces site-scoped key access.
- **Configuration Snippet section** on keys page (transport / WP / bearer notes).
- **Pre-built Tailwind CSS** via `scripts/build-css.sh` (replaces CDN).
- **Unified site manage page**: collapsible Connection / Tool Access / Connect sections.
#### Fixed
- **CSRF bug**: removed conflicting `getCsrf()` that tried to read an `httponly` cookie; now relies on the global meta-tag CSRF interceptor in `head_assets.html`.
- **Tool scope UI**: tools outside the selected scope are visually dimmed; summary shows in-scope count.
- Sidebar logo hidden when collapsed.
#### Changed
- **828 tests passing** (was: 766).
- `/dashboard/connect` and `/dashboard/edit` redirect to unified site page.
---
## [3.9.0] — 2026-04-09
### Tool access UI + unified keys page (Track F.7b)
#### Added
- `sites/edit`: Tool Access card with scope dropdown + per-tool toggle grid.
- `sites/view`: new `/dashboard/sites/{id}` page with MCP URL + config snippets.
- `/dashboard/keys`: unified page (user view with scope selector, admin view).
- Sidebar: "Connect" replaced with "API Keys" → `/dashboard/keys`.
- `sites/list`: added "Connect" link per site row.
- 12 new tests (keys unified + tool access UI smoke).
#### Changed
- **Site-scoped tool access** (schema v7): dropped `user_tool_toggles` in favour of `site_tool_toggles(site_id)`; added `sites.tool_scope` column (default `admin`). Resolves the limitation that a user with multiple sites of the same plugin (e.g. Coolify production + staging) needed independent tool filters.
- `ToolAccessManager` now takes `site_id`; `get_visible_tools` intersects key-scope categories with the site's `tool_scope` preset, then applies `site_tool_toggles` overrides.
- Dashboard API routes moved under `/api/sites/{site_id}/...`.
- `/dashboard/connect``/dashboard/keys` (301); `/dashboard/api-keys``/dashboard/keys` (301).
---
## [3.8.0] — 2026-04-07
### Coolify plugin Phase 2+3 — projects, databases, services (Track F.17)
#### Added
- **Coolify projects handler** (8 tools): CRUD projects + environments.
- **Coolify databases handler** (16 tools): CRUD, lifecycle, 6 DB types, backups.
- **Coolify services handler** (13 tools): CRUD, lifecycle, env vars.
- **Scope-based tool visibility + per-user toggles** (Track F.7 core): every `ToolDefinition` now carries `category` + `sensitivity` fields. Extends the scope model with `deploy` and `read:sensitive`. `tools/list` filters by the API key's scopes via `SCOPE_TO_CATEGORIES` in `core/tool_access.py`. Coolify handlers are annotated; other plugins keep the default `read` category (backward compatible).
#### Changed
- **Coolify plugin total: 67 tools** (was 30). Platform total: **633 tools**.
- **766 tests passing** (48 new).
---
## [3.7.0] — 2026-04-04
### Coolify MCP plugin — Phase 1 MVP (Track F.17)
#### Added
- **Coolify plugin** (30 tools, admin-only):
- **Applications** (17): CRUD, lifecycle (start/stop/restart), logs, env vars.
- **Deployments** (5): list, get, cancel, deploy by tag/UUID, app history.
- **Servers** (8): CRUD, resources, domains, validation.
- Coolify credential fields added to `site_api` (URL + API token), with health check.
- Registered in `server.py` startup so Coolify tools show up in `/coolify/mcp`.
#### Changed
- Platform plugin count: **10** (added Coolify). Tool count: **597**.
---
## [3.6.0] — 2026-04-02
### Gitea Plugin — Public Release (Track F.16)
Gitea plugin fully reviewed, tested, and published for public use. Two missing tools added, comprehensive test suite created.
#### Added
- **`update_webhook` tool**: Update existing webhook configuration, events, and active status (admin scope)
- **`delete_label` tool**: Delete labels from repositories (write scope)
- **`tests/test_gitea_plugin.py`**: Comprehensive test suite — 85+ tests covering client init, headers, tool specs, all 5 handler groups, health check, and plugin delegation
#### Changed
- **Gitea plugin now public**: Added `gitea` to `DEFAULT_PUBLIC_PLUGINS` — available to all OAuth users
- **Tool count**: 565 → 567 (added update_webhook + delete_label)
- **env.example**: Updated default `ENABLED_PLUGINS` to include `gitea`
---
## [3.5.0] — 2026-04-02
### FastMCP Upgrade & Legacy Cleanup (Track F.15)
Major cleanup release: upgraded to FastMCP 3.x, removed all legacy modules, and simplified the architecture.
#### Changed
- **FastMCP 3.x**: Upgraded from `fastmcp>=2.14.0,<3.0.0` to `fastmcp>=3.0.0,<4.0.0`
- **HealthMonitor refactored**: Removed `ProjectManager` dependency — now uses `SiteManager` exclusively for site discovery and health checks
- **Endpoint introspection**: Replaced internal `_tool_manager._tools` access with tracked `_tool_counts` dict in `MCPEndpointFactory`
#### Removed
- **`server_multi.py`**: Legacy multi-endpoint server entry point (replaced by unified `server.py` since v3.0)
- **`core/project_manager.py`**: Legacy project manager shell — HealthMonitor no longer depends on it
- **Legacy exports**: Removed `ProjectManager` and `get_project_manager` from `core/__init__.py`
- References from `pyproject.toml` (py-modules, ruff per-file-ignores), community build script, and documentation
---
## [3.4.0] — 2026-03-31
### OpenPanel Plugin — Public Release (Track F.10)
OpenPanel product analytics plugin fully reviewed, tested, and published for public use. Works with both self-hosted and cloud (openpanel.dev) instances.
#### Added
- **OpenPanel Plugin** (42 tools): Complete rewrite using public REST APIs — no tRPC/session dependency
- **Track API** (11 tools): track_event, page_view, screen_view, identify_user, set_user_properties, increment/decrement_property, create_group, assign_group, track_revenue, track_batch
- **Export API** (10 tools): export_events, export_events_csv, export_chart_data, get_event_count, get_unique_users, get_page_views, get_top_pages, get_top_referrers, get_geo_data, get_device_data
- **Insights API** (2 tools): get_overview_report, get_realtime_stats
- **Profile API** (3 tools): get_profile_events, get_profile_sessions, export_profile_data
- **Manage API** (10 tools): list/get/create/update/delete projects and clients
- **System** (6 tools): health_check, get_instance_info, get_usage_stats, get_storage_stats, test_connection, get_rate_limit_status
- **Service Page descriptions**: Per-plugin description and notes section on service detail pages
- **Dynamic URL hints**: Add Site form shows per-plugin URL guidance (e.g., OpenPanel API URL vs dashboard URL)
- **WordPress plugin download**: OpenPanel WordPress plugin bundled at `/static/plugins/openpanel-self-hosted.zip`
- **62 unit tests** for OpenPanel plugin (`tests/test_openpanel_plugin.py`)
#### Changed
- `ENABLED_PLUGINS` default: `wordpress,woocommerce,supabase,openpanel` (was: `wordpress,woocommerce,supabase`)
- OpenPanel health check uses `GET /healthcheck` (was: non-existent `/api/v1/oauth/token`)
- OpenPanel credential fields: added Project ID and Organization ID (optional)
#### Removed
- OpenPanel `alias_user` tool (explicitly unsupported by OpenPanel API)
- OpenPanel dashboard handler (18 tools) — no public API available, was placeholder stubs
- OpenPanel funnel handler (8 tools) — no public API available, was placeholder stubs
- tRPC client dependency — all tools now use public REST APIs
---
## [3.3.0] — 2026-03-31
### Platform Hardening & Admin Unification (Track F.1F.8)
Major quality release: plugin visibility control, UI/UX polish, unified admin panel, database-backed settings, and security hardening. No breaking API changes.
#### Added
- **Plugin Visibility Control** (F.1): `ENABLED_PLUGINS` env var controls which plugins public users see. Default: `wordpress,woocommerce,supabase`. Admin sees all. New module: `core/plugin_visibility.py`
- **MCP Service Pages** (F.3): Dedicated `/dashboard/services/{type}` pages showing plugin capabilities, tool list, and setup requirements. Services list page with grid of plugin cards
- **Admin by Email** (F.4a): `ADMIN_EMAILS` env var for designating OAuth users as admin (supports multiple emails). OAuth admin users see full admin sidebar
- **Master Key Scope Control** (F.4b): `DISABLE_MASTER_KEY_LOGIN` to block dashboard login via master key. `MASTER_KEY_SCOPE` (`all`/`admin`) to restrict master key to admin endpoints only
- **Panel Unification** (F.4c): OAuth admin sees both "My Tools" and "Administration" sidebar sections. Master key admin auto-creates user record on first login for unified site management
- **Settings from UI** (F.4c.3): Database-backed settings with DB > ENV > Default priority. Admin can edit `ENABLED_PLUGINS`, `MAX_SITES_PER_USER`, rate limits, registration toggle from dashboard. New module: `core/settings.py`
- **Dashboard Stats** (F.3): Admin home page shows Total Users and User Sites stat cards
- **Pre-configured OAuth** (F.2): Default OAuth redirect URIs for Claude.ai; green tip about optional OAuth
#### Fixed
- **Connect Page** (F.2): WordPress/SEO amber info box shown only for WordPress/WooCommerce sites (was shown for all plugin types)
- **Sidebar Version** (F.2): Fixed "v" displaying when version string is empty
- **Auth Page Language** (F.2): Auth page defaults to English regardless of Accept-Language header
- **Donation Link** (F.2): Moved from home page to sidebar for consistent visibility
- **Test Connection** (F.3): Fixed 504/HTML error handling; status badge auto-updates without page refresh; added `last_tested_at` timestamp (DB schema v4)
- **Admin Auth** (F.4): Fixed `_require_admin_session()` to recognize OAuth admin sessions; hide "Admin Login with API Key" button when `DISABLE_MASTER_KEY_LOGIN=true`
- **Starlette API** (F.2): Updated `TemplateResponse` to new API (request as first arg)
#### Security
- **exec() Removal** (F.8): Replaced `exec()` in `core/tool_generator.py` with closure-based tool generation
- **Shell Injection Fix** (F.8): Replaced `create_subprocess_shell` with `create_subprocess_exec` in WP-CLI handler
- **bcrypt Migration** (F.8): Admin API key hashing upgraded from unsalted SHA-256 to bcrypt
#### Tests
- New test suites: `test_plugin_visibility.py`, `test_admin_system.py`, `test_f3_admin_stats.py`, `test_f3_last_tested.py`, `test_f3_service_pages.py`
---
## [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
### Live Platform Foundation (Track E.1 - E.3)
Major release introducing the Live Platform architecture — SQLite database, OAuth social login, site management, and per-user MCP endpoints. All features are included in the Community Edition.
#### Added
- **SQLite Database Backend** (E.1): Async SQLite via aiosqlite, WAL mode, schema versioning with migrations framework, CRUD for users/sites/API keys/connection tokens
- **Credential Encryption** (E.1): AES-256-GCM with HKDF per-site key derivation, versioned wire format for future migration support
- **OAuth Social Login** (E.2): GitHub + Google OAuth 2.0, CSRF state tokens, JWT session management, dual session types (admin + oauth_user)
- **Site Management API** (E.3): Plugin credential definitions for all 9 plugins, connection validation, encrypted site CRUD, MAX_SITES_PER_USER=10
- **User API Keys** (E.3): bcrypt-hashed keys with `mhu_` prefix, 8-char prefix for indexed lookup, 5-minute validation cache
- **Per-User MCP Endpoints** (E.3): Direct JSON-RPC handler at `/u/{user_id}/{alias}/mcp`, per-user rate limiting (30/min, 500/hr)
- **Config Snippets** (E.3): Auto-generated config for Claude Desktop, Claude Code, Cursor, VS Code, and ChatGPT
- **Dashboard Pages**: My Sites (list/add/test/delete), Connect (API keys + config snippets), Profile, OAuth Login
- **Dark/Light Mode Toggle**: Theme switcher across all dashboard pages
- **RBAC**: Role-based access control for dashboard
- **Active Health Checks**: Background health monitoring for connected services
#### Fixed
- CSRF middleware body consumption bug
- OAuth log noise and DCR crash on startup
- WordPress site connection validation (uses aiohttp to match plugin client)
- Tenant isolation enforced on all site queries
#### Security
- All bare `except:` replaced with `except Exception:` across 12 files
- Network error differentiation (DNS, SSL, timeout, connection refused)
- Retry with exponential backoff for transient errors only
- Auth/config errors never retried
#### Tests
- 452 total tests (up from 303), all passing
- New test suites: database (37), encryption (27), user_auth (32), site_api (17), user_keys (14), user_endpoints (12), config_snippets (10)
---
## [2.9.0] - 2026-02-14

View File

@@ -4,7 +4,7 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
## Project Overview
**MCP Hub** — a Python MCP (Model Context Protocol) server that manages multiple self-hosted services through a unified plugin architecture. Supports 9 plugin types (WordPress, WooCommerce, WordPress Advanced, Gitea, n8n, Supabase, OpenPanel, Appwrite, Directus) with 596 tools total. The tool count stays constant regardless of how many sites are configured.
**MCP Hub** — a Python MCP (Model Context Protocol) server that manages multiple self-hosted services through a unified plugin architecture. Supports 10 plugin types (WordPress, WooCommerce, WordPress Advanced, Gitea, n8n, Supabase, OpenPanel, Appwrite, Directus, Coolify). Total tool count varies with which plugins are enabled and grows as new plugins are added; per-plugin counts are surfaced in the dashboard. The exposed count for a given user/endpoint stays constant regardless of how many sites of the same type are configured.
## Quick Setup
@@ -69,9 +69,8 @@ All configured in `pyproject.toml`:
```
├── server.py # Primary entry point
├── server_multi.py # Alternative multi-endpoint server
├── core/ # Layer 1: Core system modules
├── plugins/ # Layer 2: Plugin system (9 plugins)
├── plugins/ # Layer 2: Plugin system (10 plugins)
├── core/templates/ # Jinja2 templates (dashboard + OAuth)
├── tests/ # Organized test suite
├── scripts/ # Setup & deployment scripts
@@ -86,15 +85,13 @@ All configured in `pyproject.toml`:
```
Layer 1: Core System (core/) — Auth, site discovery, tool registry, health, rate limiting
Layer 2: Plugin System (plugins/) — 9 plugin types, each with handlers + schemas
Layer 2: Plugin System (plugins/) — 10 plugin types, each with handlers + schemas
Layer 3: API & Web UI (server.py + core/dashboard/) — FastMCP server, Starlette routes, dashboard
```
### Entry Points
- **`server.py`** (~3500 lines) — Primary entry point. Handles FastMCP server, Starlette routes, middleware, plugins.
- **`server_multi.py`** — Alternative multi-endpoint server (legacy, predates unified `server.py` endpoints).
### Multi-Endpoint Architecture
```
@@ -119,30 +116,23 @@ plugins/{name}/
└── schemas/ # Pydantic models for validation
```
**Registered plugins**: wordpress, woocommerce, wordpress_advanced, gitea, n8n, supabase, openpanel, appwrite, directus
**Registered plugins**: wordpress, woocommerce, wordpress_advanced, gitea, n8n, supabase, openpanel, appwrite, directus, coolify
**Plugin visibility** (Track F.1): Public users only see plugins listed in `ENABLED_PLUGINS` env var (default: `wordpress,woocommerce,supabase`). Admin sees all. Controlled by `core/plugin_visibility.py`.
### Tool Generation
Tools are dynamically generated at startup:
1. `SiteManager` discovers sites from env vars (`{PLUGIN_TYPE}_{SITE_ID}_{CONFIG_KEY}`)
2. `ToolGenerator` creates unified tools with a `site` parameter injected
3. Tools are registered in `ToolRegistry` and exposed via FastMCP
Tools are dynamically generated at startup from plugin specifications:
1. `ToolGenerator` creates unified tools with a `site` parameter injected
2. Tools are registered in `ToolRegistry` and exposed via FastMCP
Unified tool pattern: `wordpress_create_post(site="myblog", title="Hello")` — the `site` parameter accepts either a site_id or alias.
### Site Configuration via Environment Variables
### Site Configuration
Pattern: `{PLUGIN_TYPE}_{SITE_ID}_{CONFIG_KEY}`
```bash
WORDPRESS_SITE1_URL=https://example.com
WORDPRESS_SITE1_USERNAME=admin
WORDPRESS_SITE1_APP_PASSWORD=xxxx xxxx xxxx xxxx
WORDPRESS_SITE1_ALIAS=myblog # optional friendly name
WORDPRESS_SITE1_CONTAINER=wp-docker # optional, for WP-CLI
```
Parsed by `core/site_manager.py` into `SiteConfig` (Pydantic model). Sites are auto-discovered on startup.
Sites are managed via the web dashboard and stored in SQLite (DB-based).
User sites are encrypted with AES-256-GCM and accessed via `core/site_api.py` + `core/database.py`.
`SiteManager` (`core/site_manager.py`) provides registration and lookup infrastructure for tool generation.
### Key Core Modules
@@ -150,7 +140,7 @@ Parsed by `core/site_manager.py` into `SiteConfig` (Pydantic model). Sites are a
|--------|---------|
| `core/auth.py` | Master API key validation, request authentication |
| `core/api_keys.py` | Per-project API keys with scopes (read/write/admin) |
| `core/site_manager.py` | Type-safe site config discovery from env vars |
| `core/site_manager.py` | Type-safe site config registration and lookup |
| `core/tool_registry.py` | Central tool definitions and lookup |
| `core/tool_generator.py` | Dynamic unified tool creation with site injection |
| `core/health.py` | Health monitoring, metrics, alerts |
@@ -159,14 +149,24 @@ Parsed by `core/site_manager.py` into `SiteConfig` (Pydantic model). Sites are a
| `core/oauth/` | OAuth 2.1 with PKCE (RFC 8414, 7591, 7636) |
| `core/dashboard/routes.py` | Web UI dashboard (login, projects, API keys, health, audit) |
| `core/endpoints/` | Multi-endpoint architecture (factory, registry, config) |
| `core/plugin_visibility.py` | Plugin enable/disable for public vs admin users |
| `core/user_auth.py` | OAuth Social Login (GitHub + Google) |
| `core/user_endpoints.py` | Per-user MCP endpoints (`/u/{user_id}/{alias}/mcp`) |
| `core/site_api.py` | User site CRUD, connection testing, credential encryption |
| `core/user_keys.py` | User API key management (bcrypt, `mhu_` prefix) |
| `core/database.py` | SQLite backend (aiosqlite, WAL mode, migrations) |
| `core/encryption.py` | AES-256-GCM credential encryption |
### User System (Track E)
OAuth Social Login (GitHub + Google) via `core/user_auth.py`. Users register, add sites, get personal MCP endpoints at `/u/{user_id}/{alias}/mcp`. Per-user API keys with `mhu_` prefix (bcrypt-hashed). Credentials encrypted with AES-256-GCM in SQLite.
### Dashboard
Web UI at the server root, built with Starlette + Jinja2 + HTMX + Tailwind CSS. Supports EN/FA i18n (`core/i18n.py`). 8 pages: Login, Home, Projects, API Keys, OAuth Clients, Audit Logs, Health, Settings.
Web UI at the server root, built with Starlette + Jinja2 + HTMX + Tailwind CSS. Supports EN/FA i18n (`core/i18n.py`).
### Legacy Modules (Deprecated)
`core/project_manager.py`, `core/site_registry.py`, `core/unified_tools.py` — kept for backward compatibility. New code should use `SiteManager`, `ToolRegistry`, and `ToolGenerator` instead.
**Admin pages**: Login, Home, Projects, API Keys, OAuth Clients, Audit Logs, Health, Settings.
**User pages**: Login (OAuth), Home, My Sites (list/add/edit/test/delete), Connect (API keys + config snippets + OAuth clients), Profile.
## Commit Style
@@ -175,12 +175,26 @@ Web UI at the server root, built with Starlette + Jinja2 + HTMX + Tailwind CSS.
```
Types: `feat`, `fix`, `docs`, `style`, `refactor`, `test`, `chore`
## Live Instances
- **Platform**: `mcp.example.com` — Live MCP Hub with OAuth login
- **Blog**: `blog.example.com` — WordPress test site + project blog
- **Deployment**: Coolify / Docker Compose, port 8000
## Current Development (Track F)
v4 development cycle with 15 phases. See `docs/ROADMAP.md` (Track F) and `.claude/plans/structured-popping-adleman.md` for full plan.
**Active plugins for public users**: WordPress, WooCommerce, Supabase, OpenPanel, Gitea (configurable via `ENABLED_PLUGINS` env var)
## Gotchas
- 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-bridge) — these are PHP, not Python
- `env.example` has "FUTURE" labels for Supabase/Gitea but both are fully implemented
- 5 plugins are tested for public use: WordPress, WooCommerce, Supabase, OpenPanel, Gitea. Others are admin-only or disabled.
- OAuth Clients (Client ID/Secret) are for MCP endpoint auth, NOT the same as GitHub/Google dashboard login
- All sites stored in SQLite (`core/database.py`), managed via web dashboard
- 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)
- The `scripts/` directory has platform-specific setup scripts: `setup.sh` (Linux/Mac), `setup.ps1` (Windows)
@@ -192,3 +206,6 @@ Types: `feat`, `fix`, `docs`, `style`, `refactor`, `test`, `chore`
- Docker socket mount required for WP-CLI tools: `/var/run/docker.sock:/var/run/docker.sock:ro`
- Persistent volumes: `mcp-data` (API keys, OAuth), `mcp-logs` (audit, health)
- Required env vars: `MASTER_API_KEY`, `OAUTH_JWT_SECRET_KEY`, `OAUTH_BASE_URL`
- OAuth env vars: `GITHUB_CLIENT_ID`, `GITHUB_CLIENT_SECRET`, `GOOGLE_CLIENT_ID`, `GOOGLE_CLIENT_SECRET`, `PUBLIC_URL`
- User limits: `MAX_SITES_PER_USER=10`, `USER_RATE_LIMIT_PER_MIN=30`, `USER_RATE_LIMIT_PER_HR=500`
- Plugin visibility: `ENABLED_PLUGINS=wordpress,woocommerce,supabase,openpanel,gitea` (default)

View File

@@ -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
```

View File

@@ -2,7 +2,9 @@
**The AI-native management hub for WordPress, WooCommerce, and self-hosted services.**
**Version 3.0.1** — 596 tools across 9 plugins. Connect your sites, stores, repos, and databases — manage them all through Claude, ChatGPT, Cursor, or any MCP client.
633 tools across 10 plugins (incl. Coolify). 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
@@ -11,14 +13,10 @@
```bash
# Recommended (auto-generates temp key if omitted — check container logs)
MASTER_API_KEY=your-secure-key-here
# Add at least one WordPress site
WORDPRESS_SITE1_URL=https://your-site.com
WORDPRESS_SITE1_USERNAME=admin
WORDPRESS_SITE1_APP_PASSWORD=xxxx xxxx xxxx xxxx
WORDPRESS_SITE1_ALIAS=mysite
```
After starting, open the **web dashboard** to add your sites — no env vars needed for site configuration.
### 2. Run the container
```bash
@@ -91,9 +89,10 @@ 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 |
| `/mcp` | 633 | Yes | Admin & discovery only |
Available plugin endpoints: `/wordpress/mcp`, `/woocommerce/mcp`, `/wordpress-advanced/mcp`, `/gitea/mcp`, `/n8n/mcp`, `/supabase/mcp`, `/openpanel/mcp`, `/appwrite/mcp`, `/directus/mcp`, `/system/mcp`
@@ -131,7 +130,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 +145,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

View File

@@ -27,7 +27,8 @@ RUN pip install --no-cache-dir --user -r requirements.txt
FROM python:3.12-alpine AS production
# CRITICAL: Install wget for health checks + docker-cli for WP-CLI tools
RUN apk add --no-cache wget curl docker-cli
# libmagic is required by python-magic (F.5a media upload MIME sniffing)
RUN apk add --no-cache wget curl docker-cli libmagic
# Create non-root user for security and grant Docker socket access
# Docker group (GID 999) allows access to /var/run/docker.sock

123
Dockerfile.mirror Normal file
View File

@@ -0,0 +1,123 @@
# ===================================
# MCP Hub — Dockerfile (mirror + PyPI-mirror-fallback variant)
# ===================================
# Identical to Dockerfile except:
# 1. Base images come from mirror.gcr.io (Docker Hub mirror) — the
# build host doesn't need to reach registry-1.docker.io.
# Alternative Docker registry mirrors reachable from this host
# (swap in by s/mirror.gcr.io/... if gcr also fails):
# - dockerhub.timeweb.cloud/library
# - docker.m.daocloud.io
# - docker.arvancloud.ir
# 2. Alpine apk repos point to mirror.yandex.ru (more reachable than
# dl-cdn.alpinelinux.org from restrictive networks).
# 3. pip install tries pypi.org first, then falls back through Aliyun
# and Tsinghua PyPI mirrors. If an optional BUILD_PROXY ARG is
# provided, it is tried as a final fallback.
#
# Switch back to vanilla Dockerfile once Docker Hub + Alpine CDN +
# pypi.org are all reachable directly from the build host.
# ===================================
# Stage 1: Build stage
FROM mirror.gcr.io/library/python:3.12-alpine AS builder
# Optional HTTP proxy (empty by default — only used if set via Coolify
# build-arg). ARG values are not baked into the runtime image.
ARG BUILD_PROXY=""
# Use Yandex apk mirror — reachable when dl-cdn.alpinelinux.org is not.
RUN sed -i 's|dl-cdn.alpinelinux.org|mirror.yandex.ru/mirrors|g' /etc/apk/repositories
# Install build dependencies — direct first, proxy fallback if provided.
RUN apk add --no-cache gcc musl-dev libffi-dev openssl-dev \
|| ( [ -n "${BUILD_PROXY}" ] \
&& echo "==> direct apk failed; retrying via BUILD_PROXY" \
&& HTTP_PROXY="${BUILD_PROXY}" HTTPS_PROXY="${BUILD_PROXY}" \
apk add --no-cache gcc musl-dev libffi-dev openssl-dev )
# Create build directory
WORKDIR /build
# Install Python dependencies — four-step fallback chain:
# 1. pypi.org (direct)
# 2. Aliyun mirror (mirrors.aliyun.com)
# 3. Tsinghua mirror (pypi.tuna.tsinghua.edu.cn)
# 4. Optional BUILD_PROXY (only tried if the ARG is non-empty)
COPY requirements.txt .
RUN pip install --no-cache-dir --user --retries 1 --timeout 15 -r requirements.txt \
|| ( echo "==> pypi.org failed; trying Aliyun mirror" \
&& pip install --no-cache-dir --user --retries 1 --timeout 15 \
-i https://mirrors.aliyun.com/pypi/simple/ \
--trusted-host mirrors.aliyun.com \
-r requirements.txt ) \
|| ( echo "==> Aliyun failed; trying Tsinghua mirror" \
&& pip install --no-cache-dir --user --retries 1 --timeout 20 \
-i https://pypi.tuna.tsinghua.edu.cn/simple/ \
--trusted-host pypi.tuna.tsinghua.edu.cn \
-r requirements.txt ) \
|| ( [ -n "${BUILD_PROXY}" ] \
&& echo "==> Tsinghua failed; retrying via BUILD_PROXY" \
&& pip install --no-cache-dir --user \
--proxy "${BUILD_PROXY}" -r requirements.txt )
# Stage 2: Production stage
FROM mirror.gcr.io/library/python:3.12-alpine AS production
# Re-declare proxy ARG (ARGs don't cross stage boundaries).
ARG BUILD_PROXY=""
# Same Yandex apk mirror swap as the builder stage.
RUN sed -i 's|dl-cdn.alpinelinux.org|mirror.yandex.ru/mirrors|g' /etc/apk/repositories
# CRITICAL: Install wget for health checks + docker-cli for WP-CLI tools
# libmagic is required by python-magic (F.5a media upload MIME sniffing)
RUN apk add --no-cache wget curl docker-cli libmagic \
|| ( [ -n "${BUILD_PROXY}" ] \
&& echo "==> direct apk failed; retrying via BUILD_PROXY" \
&& HTTP_PROXY="${BUILD_PROXY}" HTTPS_PROXY="${BUILD_PROXY}" \
apk add --no-cache wget curl docker-cli libmagic )
# Create non-root user for security and grant Docker socket access
# Docker group (GID 999) allows access to /var/run/docker.sock
RUN addgroup -g 1001 appgroup && \
adduser -u 1001 -G appgroup -s /bin/sh -D appuser && \
addgroup -g 999 docker 2>/dev/null || true && \
adduser appuser docker 2>/dev/null || true
# Set working directory
WORKDIR /app
# Copy Python packages from builder
COPY --from=builder /root/.local /home/appuser/.local
# Copy application code
COPY --chown=appuser:appgroup . .
# Create data directories for API keys and logs with correct ownership
# This must be done before switching to non-root user
RUN mkdir -p /app/data /app/logs && \
chown -R appuser:appgroup /app/data /app/logs && \
chmod 755 /app/data /app/logs
# Make server.py executable
RUN chmod +x server.py
# Switch to non-root user
USER appuser
# Add local packages to PATH
ENV PATH=/home/appuser/.local/bin:$PATH
ENV PYTHONUNBUFFERED=1
# CRITICAL: EXPOSE port for Coolify
EXPOSE 8000
# CRITICAL: Health check
HEALTHCHECK --interval=30s --timeout=10s --start-period=40s --retries=3 \
CMD wget --no-verbose --tries=1 --spider http://localhost:8000/health || exit 1
# CRITICAL: Listen on 0.0.0.0 (not localhost!)
# Run server with streamable-http transport on port 8000
CMD ["python", "server.py", "--transport", "streamable-http", "--port", "8000", "--host", "0.0.0.0"]

113
Dockerfile.slim Normal file
View File

@@ -0,0 +1,113 @@
# ===================================
# MCP Hub — Dockerfile (Debian slim, restrictive-network variant)
# ===================================
# Plan B fallback when both registry-1.docker.io AND
# dl-cdn.alpinelinux.org are unreachable from the build host.
#
# Switches the base from python:3.12-alpine (musl + apk) to
# python:3.12-slim-bookworm (Debian + apt). Debian's package mirrors
# are CDN-fronted (Cloudflare/Fastly) and are usually reachable from
# networks that block Alpine's CDN. Image is pulled from mirror.gcr.io
# to also bypass Docker Hub TLS issues.
#
# Tradeoffs vs Dockerfile.mirror (Alpine):
# - Image size: ~120 MB (slim) vs ~60 MB (alpine) — acceptable
# - Compatibility: ALL Python wheels work (no musl rebuild needed)
# - Security baseline: equivalent (slim is minimal Debian, no shell extras)
#
# Switch back to Dockerfile.mirror once dl-cdn.alpinelinux.org reachable
# OR back to Dockerfile once registry-1.docker.io is reachable.
# ===================================
# Stage 1: Build stage
FROM mirror.gcr.io/library/python:3.12-slim-bookworm AS builder
# Optional HTTP proxy for restricted networks. ARG values are NOT baked
# into the final image, so the runtime container never carries the proxy.
ARG BUILD_HTTP_PROXY=""
ARG BUILD_HTTPS_PROXY=""
ARG BUILD_NO_PROXY=""
# Install build dependencies via apt (proxy honoured if BUILD_HTTP_PROXY set)
RUN export HTTP_PROXY="${BUILD_HTTP_PROXY}" \
HTTPS_PROXY="${BUILD_HTTPS_PROXY}" \
NO_PROXY="${BUILD_NO_PROXY}" \
&& apt-get update && apt-get install -y --no-install-recommends \
build-essential \
libffi-dev \
libssl-dev \
&& rm -rf /var/lib/apt/lists/*
# Create build directory
WORKDIR /build
# Copy requirements and install Python dependencies (proxy honoured)
COPY requirements.txt .
RUN export HTTP_PROXY="${BUILD_HTTP_PROXY}" \
HTTPS_PROXY="${BUILD_HTTPS_PROXY}" \
NO_PROXY="${BUILD_NO_PROXY}" \
&& pip install --no-cache-dir --user -r requirements.txt
# Stage 2: Production stage
FROM mirror.gcr.io/library/python:3.12-slim-bookworm AS production
# Re-declare proxy ARGs in this stage (ARGs don't cross stage boundaries).
ARG BUILD_HTTP_PROXY=""
ARG BUILD_HTTPS_PROXY=""
ARG BUILD_NO_PROXY=""
# CRITICAL: Install wget for health checks + docker-cli for WP-CLI tools
# libmagic1 is required by python-magic (F.5a media upload MIME sniffing)
RUN export HTTP_PROXY="${BUILD_HTTP_PROXY}" \
HTTPS_PROXY="${BUILD_HTTPS_PROXY}" \
NO_PROXY="${BUILD_NO_PROXY}" \
&& apt-get update && apt-get install -y --no-install-recommends \
wget \
curl \
docker.io \
libmagic1 \
&& rm -rf /var/lib/apt/lists/*
# Create non-root user for security and grant Docker socket access
# Docker group (GID 999) allows access to /var/run/docker.sock
RUN groupadd -g 1001 appgroup && \
useradd -u 1001 -g appgroup -s /bin/sh -m appuser && \
(groupadd -g 999 docker 2>/dev/null || true) && \
(usermod -aG docker appuser 2>/dev/null || true)
# Set working directory
WORKDIR /app
# Copy Python packages from builder
COPY --from=builder /root/.local /home/appuser/.local
# Copy application code
COPY --chown=appuser:appgroup . .
# Create data directories for API keys and logs with correct ownership
# This must be done before switching to non-root user
RUN mkdir -p /app/data /app/logs && \
chown -R appuser:appgroup /app/data /app/logs && \
chmod 755 /app/data /app/logs
# Make server.py executable
RUN chmod +x server.py
# Switch to non-root user
USER appuser
# Add local packages to PATH
ENV PATH=/home/appuser/.local/bin:$PATH
ENV PYTHONUNBUFFERED=1
# CRITICAL: EXPOSE port for Coolify
EXPOSE 8000
# CRITICAL: Health check
HEALTHCHECK --interval=30s --timeout=10s --start-period=40s --retries=3 \
CMD wget --no-verbose --tries=1 --spider http://localhost:8000/health || exit 1
# CRITICAL: Listen on 0.0.0.0 (not localhost!)
# Run server with streamable-http transport on port 8000
CMD ["python", "server.py", "--transport", "streamable-http", "--port", "8000", "--host", "0.0.0.0"]

193
README.md
View File

@@ -1,3 +1,5 @@
<!-- mcp-name: io.github.airano-ir/mcphub -->
# MCP Hub
<div align="center">
@@ -6,13 +8,12 @@
Connect your sites, stores, repos, and databases — manage them all through Claude, ChatGPT, Cursor, or any MCP client.
[![Version: 3.0.1](https://img.shields.io/badge/version-3.0.1-blue.svg)](https://github.com/airano-ir/mcphub/releases)
[![GitHub Release](https://img.shields.io/github/v/release/airano-ir/mcphub)](https://github.com/airano-ir/mcphub/releases)
[![License: MIT](https://img.shields.io/badge/license-MIT-blue.svg)](LICENSE)
[![Python 3.11+](https://img.shields.io/badge/python-3.11+-3776ab.svg)](https://www.python.org/)
[![PyPI](https://img.shields.io/pypi/v/mcphub-server.svg)](https://pypi.org/project/mcphub-server/)
[![Docker](https://img.shields.io/docker/v/airano/mcphub?label=docker)](https://hub.docker.com/r/airano/mcphub)
[![Tests: 290 passing](https://img.shields.io/badge/tests-290%20passing-brightgreen.svg)]()
[![Tools: 596](https://img.shields.io/badge/tools-596-orange.svg)]()
[![Plugins: 10](https://img.shields.io/badge/plugins-10-orange.svg)]()
[![CI](https://github.com/airano-ir/mcphub/actions/workflows/ci.yml/badge.svg)](https://github.com/airano-ir/mcphub/actions/workflows/ci.yml)
</div>
@@ -23,7 +24,7 @@ Connect your sites, stores, repos, and databases — manage them all through Cla
WordPress powers 43% of the web. WooCommerce runs 36% of online stores. Yet **no MCP server existed** for managing them through AI — until now.
MCP Hub is the first MCP server that lets you manage WordPress, WooCommerce, and 7 other self-hosted services through any AI assistant. Instead of clicking through dashboards, just tell your AI what to do:
MCP Hub is the first MCP server that lets you manage WordPress, WooCommerce, and 8 other self-hosted services through any AI assistant. Instead of clicking through dashboards, just tell your AI what to do:
> *"Update the SEO meta description for all WooCommerce products that don't have one"*
>
@@ -39,7 +40,7 @@ MCP Hub is the first MCP server that lets you manage WordPress, WooCommerce, and
| AI agent integration | No | No | No | **Native (MCP)** |
| Full WordPress API | Dashboard | Dashboard | Content only | **67 tools** |
| WooCommerce management | No | Limited | No | **28 tools** |
| Git/CI management | No | No | No | **56 tools (Gitea)** |
| Git/CI management | No | No | No | **65 tools (Gitea)** |
| Automation workflows | No | No | No | **56 tools (n8n)** |
| Self-hosted | No | Yes | N/A | **Yes** |
| Open source | No | Core only | Varies | **Fully open** |
@@ -47,21 +48,28 @@ MCP Hub is the first MCP server that lets you manage WordPress, WooCommerce, and
---
## 596 Tools Across 9 Plugins
## 10 Plugins, Hundreds of Tools
| Plugin | Tools | What You Can Do |
|--------|-------|-----------------|
| **WordPress** | 67 | Posts, pages, media, users, menus, taxonomies, SEO (Rank Math/Yoast) |
| **WooCommerce** | 28 | Products, orders, customers, coupons, reports, shipping |
| **WordPress Advanced** | 22 | Database ops, bulk operations, WP-CLI, system management |
| **Gitea** | 56 | Repos, issues, pull requests, releases, webhooks, organizations |
| **n8n** | 56 | Workflows, executions, credentials, variables, audit |
| **Supabase** | 70 | Database, auth, storage, edge functions, realtime |
| **OpenPanel** | 73 | Events, funnels, profiles, dashboards, projects |
| **Appwrite** | 100 | Databases, auth, storage, functions, teams, messaging |
| **Directus** | 100 | Collections, items, users, files, flows, permissions |
| **System** | 24 | Health monitoring, API keys, OAuth management, audit |
| **Total** | **596** | Constant count — scales to unlimited sites |
The exact tool count grows as new plugins ship and existing ones gain endpoints.
What you actually expose is controlled by your `ENABLED_PLUGINS` setting and per-key
scope — pick a plugin-specific endpoint to keep the surface area small.
| Plugin | Approx. Tools | What You Can Do |
|--------|---------------:|-----------------|
| **WordPress** | ~70 | Posts, pages, media (incl. AI image generation), users, menus, taxonomies, SEO (Rank Math/Yoast) |
| **WooCommerce** | ~30 | Products, orders, customers, coupons, reports, shipping |
| **WordPress Advanced** | ~20 | Database ops, bulk operations, WP-CLI, system management |
| **Gitea** | ~65 | Repos, issues, pull requests, releases, webhooks, organizations, labels, batch files, tree, search, compare |
| **n8n** | ~55 | Workflows, executions, credentials, variables, audit |
| **Supabase** | ~70 | Database, auth, storage, edge functions, realtime |
| **OpenPanel** | ~40 | Events, export, insights, profiles, projects, system |
| **Appwrite** | ~100 | Databases, auth, storage, functions, teams, messaging |
| **Directus** | ~100 | Collections, items, users, files, flows, permissions |
| **Coolify** | ~65 | Applications, deployments, servers, projects, databases, services |
| **System** | ~25 | Health monitoring, API keys, OAuth management, audit |
> Per-site duplication does **not** inflate the tool count — adding a second
> WordPress site reuses the same WordPress tools with a different `site` argument.
---
@@ -73,14 +81,14 @@ MCP Hub is the first MCP server that lets you manage WordPress, WooCommerce, and
git clone https://github.com/airano-ir/mcphub.git
cd mcphub
cp env.example .env
# Edit .env — set MASTER_API_KEY and add your site credentials
# Edit .env — set MASTER_API_KEY, then add sites via the web dashboard
docker compose up -d
```
### Option 2: Docker Hub (No Clone)
```bash
# Create a .env file with your credentials (see "Configure Your Sites" below)
# Create a .env file with MASTER_API_KEY (see "Configure Your Sites" below)
docker run -d --name mcphub -p 8000:8000 --env-file .env airano/mcphub:latest
```
@@ -91,7 +99,7 @@ git clone https://github.com/airano-ir/mcphub.git
cd mcphub
pip install -e .
cp env.example .env
# Edit .env with your site credentials
# Edit .env — set MASTER_API_KEY
python server.py --transport streamable-http --port 8000
```
@@ -106,32 +114,32 @@ 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
Add site credentials to `.env`:
Sites are managed via the **web dashboard** — no environment variables needed.
1. Set `MASTER_API_KEY` in your `.env` file
2. Start the server and open the dashboard
3. Add sites with their credentials (URL, username, password/token)
```bash
# Master API Key (recommended — auto-generates temp key if omitted)
# .env — only system configuration needed
MASTER_API_KEY=your-secure-key-here
# WordPress Site
WORDPRESS_SITE1_URL=https://myblog.com
WORDPRESS_SITE1_USERNAME=admin
WORDPRESS_SITE1_APP_PASSWORD=xxxx xxxx xxxx xxxx
WORDPRESS_SITE1_ALIAS=myblog
# WooCommerce Store
WOOCOMMERCE_STORE1_URL=https://mystore.com
WOOCOMMERCE_STORE1_CONSUMER_KEY=ck_xxxxx
WOOCOMMERCE_STORE1_CONSUMER_SECRET=cs_xxxxx
WOOCOMMERCE_STORE1_ALIAS=mystore
# Gitea Instance
GITEA_REPO1_URL=https://git.example.com
GITEA_REPO1_TOKEN=your_gitea_token
GITEA_REPO1_ALIAS=mygitea
```
<details>
@@ -143,45 +151,25 @@ GITEA_REPO1_ALIAS=mygitea
|----------|----------|---------|-------------|
| `MASTER_API_KEY` | Recommended | Auto-generated | Master API key for admin access |
| `LOG_LEVEL` | No | `INFO` | Logging level (DEBUG, INFO, WARNING, ERROR) |
| `ENCRYPTION_KEY` | For Live Platform | — | AES-256-GCM key for credential encryption |
| `OAUTH_JWT_SECRET_KEY` | For OAuth | — | JWT secret for ChatGPT auto-registration (not needed for Claude/Cursor) |
| `OAUTH_BASE_URL` | For OAuth | — | Public URL of your server (not needed for Claude/Cursor) |
| `OAUTH_JWT_ALGORITHM` | No | `HS256` | JWT algorithm |
| `OAUTH_ACCESS_TOKEN_TTL` | No | `3600` | Access token TTL in seconds |
| `OAUTH_REFRESH_TOKEN_TTL` | No | `604800` | Refresh token TTL in seconds |
| `OAUTH_STORAGE_TYPE` | No | `json` | Token storage type |
| `OAUTH_STORAGE_PATH` | No | `/app/data` | Data directory path |
> **OAuth** is 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.
**Plugin Site Configuration** — Pattern: `{PLUGIN_TYPE}_{SITE_ID}_{KEY}`
**Plugin Credential Reference** — when adding sites via dashboard, you'll need:
| Plugin | Required Keys | Optional Keys |
|--------|--------------|---------------|
| `WORDPRESS` | `URL`, `USERNAME`, `APP_PASSWORD` | `ALIAS`, `CONTAINER` |
| `WOOCOMMERCE` | `URL`, `CONSUMER_KEY`, `CONSUMER_SECRET` | `ALIAS` |
| `WORDPRESS_ADVANCED` | `URL`, `USERNAME`, `APP_PASSWORD`, `CONTAINER` | `ALIAS` |
| `GITEA` | `URL`, `TOKEN` | `ALIAS` |
| `N8N` | `URL`, `API_KEY` | `ALIAS` |
| `SUPABASE` | `URL`, `SERVICE_ROLE_KEY` | `ALIAS` |
| `OPENPANEL` | `URL`, `CLIENT_ID`, `CLIENT_SECRET` | `ALIAS` |
| `APPWRITE` | `URL`, `API_KEY`, `PROJECT_ID` | `ALIAS` |
| `DIRECTUS` | `URL`, `TOKEN` | `ALIAS` |
> **CONTAINER**: Docker container name of your WordPress site. Optional for WordPress (enables WP-CLI tools like cache flush, transient management). **Required** for WordPress Advanced (all 22 tools use WP-CLI). Find your container: `docker ps --filter name=wordpress`. Also requires Docker socket mount.
**Example** — Multiple WordPress sites:
```bash
WORDPRESS_BLOG_URL=https://blog.example.com
WORDPRESS_BLOG_USERNAME=admin
WORDPRESS_BLOG_APP_PASSWORD=xxxx xxxx xxxx xxxx
WORDPRESS_BLOG_ALIAS=blog
WORDPRESS_SHOP_URL=https://shop.example.com
WORDPRESS_SHOP_USERNAME=admin
WORDPRESS_SHOP_APP_PASSWORD=yyyy yyyy yyyy yyyy
WORDPRESS_SHOP_ALIAS=shop
```
| Plugin | Required Credentials | Notes |
|--------|---------------------|-------|
| WordPress | URL, Username, App Password | [How to create App Password](https://wordpress.org/documentation/article/application-passwords/) |
| WooCommerce | URL, Consumer Key, Consumer Secret | WooCommerce → Settings → Advanced → REST API |
| WordPress Advanced | URL, Username, App Password, Container | Container = Docker container name (for WP-CLI) |
| Gitea | URL, Token | Settings → Applications → Personal Access Token |
| n8n | URL, API Key | Settings → API → Create API Key |
| Supabase | URL, Service Role Key | Supabase Dashboard → Settings → API |
| OpenPanel | URL, Client ID, Client Secret | OpenPanel Dashboard → Project Settings |
| Appwrite | URL, API Key, Project ID | Appwrite Console → Settings → API Keys |
| Directus | URL, Static Token | Directus Admin → Settings |
</details>
@@ -283,27 +271,32 @@ MCP Hub supports **Open Dynamic Client Registration** (RFC 7591). ChatGPT can au
## Architecture
```
/mcp → Admin endpoint (all 596 tools)
/system/mcp → System tools only (24 tools)
/wordpress/mcp → WordPress tools (67 tools)
/woocommerce/mcp → WooCommerce tools (28 tools)
/wordpress-advanced/mcp → WordPress Advanced tools (22 tools)
/gitea/mcp → Gitea tools (56 tools)
/n8n/mcp → n8n tools (56 tools)
/supabase/mcp → Supabase tools (70 tools)
/openpanel/mcp → OpenPanel tools (73 tools)
/appwrite/mcp → Appwrite tools (100 tools)
/directus/mcp → Directus tools (100 tools)
/mcp → Admin endpoint (every enabled tool)
/system/mcp → System tools only
/wordpress/mcp → WordPress tools
/woocommerce/mcp → WooCommerce tools
/wordpress-advanced/mcp → WordPress Advanced tools
/gitea/mcp → Gitea tools
/n8n/mcp → n8n tools
/supabase/mcp → Supabase tools
/openpanel/mcp → OpenPanel tools
/appwrite/mcp → Appwrite tools
/directus/mcp → Directus tools
/coolify/mcp → Coolify 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.
**Recommendation**: Use plugin-specific endpoints instead of the all-tools `/mcp`
admin endpoint to keep your AI client's tool window small (and your token bill
lower).
| Endpoint | Use Case | Tools |
|----------|----------|------:|
| `/project/{alias}/mcp` | Single-site workflow (recommended) | 22-100 |
| `/{plugin}/mcp` | Multi-site management | 23-101 |
| `/mcp` | Admin & discovery only | 596 |
| Endpoint | Use Case |
|----------|----------|
| `/u/{user_id}/{alias}/mcp` | Hosted users (OAuth login) — single service |
| `/project/{alias}/mcp` | Single-site workflow (recommended) |
| `/{plugin}/mcp` | Multi-site management for one service |
| `/mcp` | Admin & discovery only — every enabled tool |
### Security
@@ -313,7 +306,7 @@ MCP Hub supports **Open Dynamic Client Registration** (RFC 7591). ChatGPT can au
- **GDPR-compliant audit logging** with automatic sensitive data filtering
- **Web dashboard** with real-time health monitoring (8 pages, EN/FA i18n)
> **Compatibility Note**: MCP Hub requires FastMCP 2.x (`>=2.14.0,<3.0.0`). FastMCP 3.0 introduced breaking changes and is not yet supported. If you install dependencies manually, ensure you don't upgrade to FastMCP 3.x.
> **Compatibility Note**: MCP Hub requires FastMCP 3.x (`>=3.0.0,<4.0.0`). The legacy multi-endpoint server and ProjectManager have been removed in v3.5.0.
### WordPress Plugin Requirements
@@ -321,22 +314,20 @@ 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 |
| 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 |
| SEO + capability/audit tools (`wordpress_get_post_seo`, capability probe, audit hook, etc.) | [Airano MCP Bridge](https://wordpress.org/plugins/airano-mcp-bridge/) ([GitHub](wordpress-plugin/airano-mcp-bridge/)) + Rank Math or Yoast SEO |
| WP-CLI tools (15 tools: `wp_cache_*`, `wp_db_*`, etc.) | Docker socket + `CONTAINER` config |
| WordPress Advanced database/system tools | Docker socket + `CONTAINER` config |
| OpenPanel analytics integration | [OpenPanel Self-Hosted](wordpress-plugin/openpanel-self-hosted/) ([Download ZIP](wordpress-plugin/openpanel-self-hosted.zip)) |
| WooCommerce tools | WooCommerce plugin (separate `WOOCOMMERCE_` config) |
| WooCommerce tools | WooCommerce plugin installed on your WordPress site |
**Docker socket** is needed for WP-CLI and WordPress Advanced system tools. Add to your docker-compose:
```yaml
volumes:
- /var/run/docker.sock:/var/run/docker.sock:ro
environment:
WORDPRESS_SITE1_CONTAINER: your-wp-container-name
```
Without Docker socket, WP-CLI tools return "not available" but all REST API tools work normally.
Set the `container` field when adding a WordPress site in the dashboard. Without Docker socket, WP-CLI tools return "not available" but all REST API tools work normally.
---
@@ -361,7 +352,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
pytest
# Format and lint

View File

@@ -37,20 +37,13 @@ from core.health import (
initialize_health_monitor,
)
# Project and Site Management
from core.project_manager import ProjectManager, get_project_manager
# Rate Limiting
from core.rate_limiter import RateLimitConfig, RateLimiter, get_rate_limiter
from core.site_manager import SiteConfig, SiteManager, get_site_manager
# Legacy (kept for backward compatibility, will be removed in v2.0)
from core.site_registry import SiteInfo, SiteRegistry, get_site_registry
from core.tool_generator import ToolGenerator
# Tool Management (Option B architecture)
from core.tool_registry import ToolDefinition, ToolRegistry, get_tool_registry
from core.unified_tools import UnifiedToolGenerator
__all__ = [
# Authentication
@@ -58,17 +51,10 @@ __all__ = [
"get_auth_manager",
"APIKeyManager",
"get_api_key_manager",
# Project/Site Management
"ProjectManager",
"get_project_manager",
# Site Management
"SiteManager",
"SiteConfig",
"get_site_manager",
# Legacy (deprecated)
"SiteRegistry",
"SiteInfo",
"get_site_registry",
"UnifiedToolGenerator",
# Tool Management
"ToolRegistry",
"ToolDefinition",

27
core/admin_utils.py Normal file
View File

@@ -0,0 +1,27 @@
"""Admin utility functions for role determination.
Centralizes admin email checking so it can be used in OAuth callback,
dashboard auth, and future admin/user panel unification (F.5+).
"""
import os
def is_admin_email(email: str | None) -> bool:
"""Check if an email is in the ADMIN_EMAILS env var list.
Args:
email: Email address to check.
Returns:
True if email matches an admin email (case-insensitive).
"""
if not email:
return False
admin_emails_raw = os.environ.get("ADMIN_EMAILS", "")
if not admin_emails_raw.strip():
return False
admin_emails = {e.strip().lower() for e in admin_emails_raw.split(",") if e.strip()}
return email.strip().lower() in admin_emails

View File

@@ -6,6 +6,7 @@ and audit trail.
"""
import hashlib
import hmac
import json
import logging
import os
@@ -14,6 +15,8 @@ from dataclasses import asdict, dataclass
from datetime import datetime, timedelta
from pathlib import Path
import bcrypt
logger = logging.getLogger(__name__)
@@ -79,7 +82,7 @@ class APIKey:
"""
key_id: str
key_hash: str
key_hash: str # bcrypt hash (new keys) or SHA-256 hex (legacy)
project_id: str
scope: Scope
created_at: str
@@ -180,8 +183,63 @@ class APIKeyManager:
logger.error(f"Failed to save keys: {e}")
def _hash_key(self, api_key: str) -> str:
"""Hash API key for storage."""
return hashlib.sha256(api_key.encode()).hexdigest()
"""Hash API key for storage using bcrypt."""
return bcrypt.hashpw(api_key.encode(), bcrypt.gensalt()).decode()
@staticmethod
def _is_bcrypt_hash(key_hash: str) -> bool:
"""Return True if the stored hash is in bcrypt format ($2a/$2b/$2y)."""
return key_hash.startswith("$2")
def _verify_key(self, api_key: str, key_hash: str) -> bool:
"""Verify API key against stored hash.
Supports both the current bcrypt format and a legacy SHA-256
fallback so pre-bcrypt keys keep working. F.8 security-
hardening note: SHA-256 is a fast hash with no per-hash salt —
a stolen keys.json file would enable offline brute-force on
any legacy entry. We therefore:
1. accept legacy hashes here (so customers aren't locked out), and
2. opportunistically upgrade every legacy hash to bcrypt the
first time it's successfully verified (see
:meth:`_upgrade_legacy_hash`).
Brand-new keys created via :meth:`create_key` are always
bcrypt-hashed — legacy SHA-256 only appears for rows that
existed before the F.4 / F.8 hardening passes.
"""
if self._is_bcrypt_hash(key_hash):
try:
return bcrypt.checkpw(api_key.encode(), key_hash.encode())
except ValueError:
# Truncated / corrupt bcrypt hash — treat as mismatch
# rather than raising.
return False
# Legacy SHA-256 fallback (constant-time compare to avoid
# timing oracles on the legacy-hash path).
expected = hashlib.sha256(api_key.encode()).hexdigest()
return hmac.compare_digest(expected, key_hash)
def _upgrade_legacy_hash(self, key: APIKey, api_key: str) -> bool:
"""Re-hash a legacy SHA-256 entry with bcrypt and persist.
Called from the verify paths the moment a legacy hash is
successfully matched, so the next verify uses bcrypt. Returns
True when an upgrade happened, False otherwise. Errors during
persist are logged but swallowed — the key still validates,
we just try again next time.
"""
if self._is_bcrypt_hash(key.key_hash):
return False
try:
key.key_hash = self._hash_key(api_key)
self._save_keys()
logger.info("Upgraded legacy SHA-256 key hash %s to bcrypt", key.key_id)
return True
except Exception as exc: # pragma: no cover — defensive
logger.warning("Failed to upgrade legacy hash for key %s: %s", key.key_id, exc)
return False
def create_key(
self,
@@ -269,12 +327,13 @@ class APIKeyManager:
Returns:
Optional[str]: key_id if valid, None otherwise
"""
key_hash = self._hash_key(api_key)
# Find key by hash
# Find key by verifying against stored hash (bcrypt or legacy SHA-256)
for key_id, key in self.keys.items():
if key.key_hash != key_hash:
if not self._verify_key(api_key, key.key_hash):
continue
# F.8: opportunistically upgrade legacy SHA-256 hashes to bcrypt
# the moment they validate successfully.
self._upgrade_legacy_hash(key, api_key)
# Check if valid (not revoked, not expired)
if not key.is_valid():
@@ -341,10 +400,10 @@ class APIKeyManager:
Returns:
Optional[APIKey]: The APIKey object if found, None otherwise
"""
key_hash = self._hash_key(api_key)
for key_id, key in self.keys.items():
if key.key_hash == key_hash:
if self._verify_key(api_key, key.key_hash):
# F.8: upgrade legacy SHA-256 hashes on first successful match.
self._upgrade_legacy_hash(key, api_key)
logger.debug(f"Found API key {key_id} by token")
return key

View File

@@ -51,10 +51,13 @@ class AuthManager:
Returns:
bool: True if valid
"""
is_valid = secrets.compare_digest(api_key, self.master_api_key)
if not self.master_api_key:
return False
is_valid = secrets.compare_digest(api_key.strip(), self.master_api_key.strip())
if not is_valid:
logger.warning("Invalid API key attempt")
logger.debug("Master key validation failed for provided token")
return is_valid

485
core/capability_probe.py Normal file
View File

@@ -0,0 +1,485 @@
"""F.7e — per-site credential capability probe.
Each plugin's ``probe_capabilities()`` (defined on ``BasePlugin``) knows
how to ask its upstream service what the saved credential can actually
do. This module wraps those calls with:
* Per-site in-memory TTL cache (default 10 min) so the probe doesn't
hammer the upstream service on every dashboard page view.
* A thin wrapper that decrypts the site's credentials, instantiates the
plugin, calls the probe, and normalises the result.
* ``/api/sites/{id}/capabilities`` Starlette handler that the dashboard
UI consumes (wired in ``core/dashboard/routes.py``).
The cache is process-local and not persisted. Workers start cold and
populate on demand; invalidation happens on cache expiry or explicit
site-credential update.
"""
from __future__ import annotations
import logging
import os
import time
from typing import Any
from starlette.requests import Request
from starlette.responses import JSONResponse
logger = logging.getLogger("mcphub.capability_probe")
# Cache TTL in seconds (default 10 min). Upstream capability rarely
# changes — most drift happens when the operator rotates an
# app_password or changes a Gitea token's scopes, both of which are
# infrequent.
_CACHE_TTL_SECONDS = int(os.environ.get("CAPABILITY_PROBE_TTL", "600"))
class _ProbeCache:
"""Trivial in-memory ``site_id -> (expires_at, payload)`` cache."""
def __init__(self, ttl_seconds: int = _CACHE_TTL_SECONDS) -> None:
self._ttl = ttl_seconds
self._entries: dict[str, tuple[float, dict[str, Any]]] = {}
def get(self, site_id: str) -> dict[str, Any] | None:
entry = self._entries.get(site_id)
if entry is None:
return None
expires_at, payload = entry
if expires_at < time.time():
self._entries.pop(site_id, None)
return None
return payload
def set(self, site_id: str, payload: dict[str, Any]) -> None:
self._entries[site_id] = (time.time() + self._ttl, payload)
def invalidate(self, site_id: str) -> bool:
return self._entries.pop(site_id, None) is not None
_cache = _ProbeCache()
def get_probe_cache() -> _ProbeCache:
return _cache
# ---------------------------------------------------------------------------
# F.7e — tier-fit evaluation: compare probe.granted with what the site's
# selected ``tool_scope`` actually needs.
# ---------------------------------------------------------------------------
# Capability names required for each (plugin_type, tier) pair. Tiers come
# from ``core/tool_access.py``. For plugins whose probe doesn't map 1:1
# onto these names (e.g. Gitea scopes use ``read:repository`` rather than
# ``read``) the fit evaluator's ``_aliased_granted`` helper below
# normalises both sides before comparing.
TIER_REQUIREMENTS: dict[str, dict[str, set[str]]] = {
"wordpress": {
"read": {"read"},
"write": {"edit_posts", "upload_files"},
# WP-admin-tier tools need the same cap as WP's admin area itself.
"admin": {"manage_options"},
},
"woocommerce": {
"read": {"read_products"},
"write": {"write_products"},
"admin": {"write_products"},
},
"gitea": {
"read": {"read:repository"},
"write": {"write:repository"},
"admin": {"admin:repo_hook"},
},
}
# Aliases that plugins may return from their probe which should be
# treated as equivalent to the canonical cap names in TIER_REQUIREMENTS.
# Keeps the adapter implementations honest about what the upstream
# service actually says while letting the evaluator compare sets.
_CAP_ALIASES: dict[str, set[str]] = {
"manage_options": {"administrator", "manage_options"},
"edit_posts": {"edit_posts", "editor", "administrator"},
"upload_files": {"upload_files", "editor", "administrator"},
"read": {"read", "subscriber", "contributor", "author", "editor", "administrator"},
"read_products": {"read_products", "read", "read_write"},
"write_products": {"write_products", "write", "read_write"},
"read_orders": {"read_orders", "read", "read_write"},
"write_orders": {"write_orders", "write", "read_write"},
}
def _cap_matches(required: str, granted: set[str]) -> bool:
"""Return True if ``required`` is satisfied by any cap in ``granted``.
Uses ``_CAP_ALIASES`` to accept role names (WP) and permission
strings (WC) alongside the canonical capability names.
"""
if required in granted:
return True
aliases = _CAP_ALIASES.get(required, {required})
return bool(aliases & granted)
def evaluate_tier_fit(
plugin_type: str,
tier: str | None,
probe_payload: dict[str, Any],
) -> dict[str, Any]:
"""Decide whether ``probe.granted`` covers what ``tier`` requires.
Args:
plugin_type: e.g. "wordpress", "woocommerce", "gitea".
tier: the site's selected ``tool_scope`` preset (read / write /
admin / custom / None).
probe_payload: the dict returned by ``probe_site_capabilities``
(must contain ``probe_available`` and ``granted``).
Returns:
A dict with:
* ``status``: one of ``ok`` | ``warning`` | ``probe_unavailable``
| ``unknown_tier``.
* ``required``: list[str] of caps the tier needs (empty when
the tier is ``custom`` / not in the table).
* ``missing``: list[str] of required caps not present in
``granted``.
* ``reason``: passthrough from the probe when probe is
unavailable, else ``None``.
``custom`` tier always returns ``status='ok'`` because by definition
the caller picked individual tools — we can't check a tier-level
contract.
"""
tier_norm = (tier or "").strip().lower()
if not probe_payload.get("probe_available", False):
return {
"status": "probe_unavailable",
"required": [],
"missing": [],
"reason": probe_payload.get("reason"),
}
if tier_norm in {"", "custom"}:
return {
"status": "ok",
"required": [],
"missing": [],
"reason": None,
}
requirements = (TIER_REQUIREMENTS.get(plugin_type) or {}).get(tier_norm)
if requirements is None:
return {
"status": "unknown_tier",
"required": [],
"missing": [],
"reason": f"no_tier_table_for:{plugin_type}/{tier_norm}",
}
# F.X.fix #5: the probe places WP role names under ``roles`` and
# individual capability strings under ``granted`` — historically we
# only compared against ``granted``, so the ``read`` tier always
# reported ``warning: Missing read`` even for admin users, because
# ``read`` is implied by every role but not emitted as a bare cap
# in the companion's capability map. Union the two sets so the
# alias resolver in ``_cap_matches`` can see roles too.
granted = set(probe_payload.get("granted") or [])
roles = set(probe_payload.get("roles") or [])
effective = granted | roles
missing = sorted(cap for cap in requirements if not _cap_matches(cap, effective))
return {
"status": "warning" if missing else "ok",
"required": sorted(requirements),
"missing": missing,
"reason": None,
}
async def probe_site_capabilities(
site_id: str,
user_id: str,
*,
force: bool = False,
) -> dict[str, Any]:
"""Return the capability probe payload for a user-owned site.
Uses a 10-minute per-site TTL cache unless ``force=True``.
Response shape:
{
"site_id": str,
"plugin_type": str,
"probe_available": bool,
"granted": list[str],
"source": str,
"reason": str | None, # only when probe_available=False
"cached": bool,
# + any plugin-specific extras (roles, plugin_version, ...)
}
"""
from core.database import get_database
from core.encryption import get_credential_encryption
from plugins import registry as plugin_registry
db = get_database()
site = await db.get_site(site_id, user_id)
if site is None:
return {
"site_id": site_id,
"plugin_type": None,
"probe_available": False,
"granted": [],
"source": "unavailable",
"reason": "site_not_found",
"cached": False,
}
if not force:
cached = _cache.get(site_id)
if cached is not None:
out = dict(cached)
out["cached"] = True
return out
plugin_type = site["plugin_type"]
if not plugin_registry.is_registered(plugin_type):
return {
"site_id": site_id,
"plugin_type": plugin_type,
"probe_available": False,
"granted": [],
"source": "unavailable",
"reason": f"plugin_not_registered:{plugin_type}",
"cached": False,
}
try:
encryptor = get_credential_encryption()
credentials = encryptor.decrypt_credentials(site["credentials"], site_id)
except Exception as exc: # noqa: BLE001
logger.warning("capability_probe: decrypt failed for site %s: %s", site_id, exc)
return {
"site_id": site_id,
"plugin_type": plugin_type,
"probe_available": False,
"granted": [],
"source": "unavailable",
"reason": "credentials_decrypt_failed",
"cached": False,
}
config_dict: dict[str, Any] = {
"site_url": site["url"],
"url": site["url"],
"alias": site["alias"],
"user_id": user_id,
"site_id": site_id,
**credentials,
}
try:
instance = plugin_registry.create_instance(
plugin_type,
project_id=f"probe_{site_id}",
config=config_dict,
)
except Exception as exc: # noqa: BLE001
logger.warning("capability_probe: plugin instantiation failed for %s: %s", site_id, exc)
return {
"site_id": site_id,
"plugin_type": plugin_type,
"probe_available": False,
"granted": [],
"source": "unavailable",
"reason": f"plugin_instantiation_failed: {exc}",
"cached": False,
}
try:
result = await instance.probe_credential_capabilities()
except Exception as exc: # noqa: BLE001
logger.warning("capability_probe: probe call raised for site %s: %s", site_id, exc)
result = {
"probe_available": False,
"granted": [],
"source": "unavailable",
"reason": f"probe_call_failed: {exc}",
}
payload: dict[str, Any] = {
"site_id": site_id,
"plugin_type": plugin_type,
"probe_available": bool(result.get("probe_available", False)),
"granted": list(result.get("granted") or []),
"source": result.get("source") or "unavailable",
}
if not payload["probe_available"]:
payload["reason"] = result.get("reason")
# F.X.fix #3: propagate install_hint so the dashboard can render
# the "site unreachable / install companion" prompt without a
# second probe call.
# F.X.fix-pass3: also propagate routes + features so the
# tool-prerequisites resolver in core/tool_access can compute
# tool availability without a second probe call.
# F.X.fix-pass5: also propagate wp_credentials_present so the
# prerequisites resolver can auto-disable WC media tools when
# the site has no WP Application Password configured.
for extra in (
"roles",
"plugin_version",
"install_hint",
"routes",
"features",
"wp_credentials_present",
):
if extra in result:
payload[extra] = result[extra]
# F.X.fix-pass2 — surface the site's configured AI-provider set so
# the badge can show a distinct "no AI provider key" warning
# independent of tier fit. Even an Administrator WP credential
# can't run the AI image tool without a provider key. Cheap: one
# SQLite query over site_provider_keys.
try:
from core.site_api import list_site_providers_set
payload["ai_providers_configured"] = sorted(await list_site_providers_set(site_id))
except Exception as exc: # noqa: BLE001
logger.debug("capability_probe: provider-set lookup skipped for %s: %s", site_id, exc)
payload["ai_providers_configured"] = []
# Cache everything, including the "probe unavailable" answer — a
# missing companion plugin is a stable fact until the operator
# installs it and re-tests the connection.
_cache.set(site_id, payload)
payload_out = dict(payload)
payload_out["cached"] = False
return payload_out
# ---------------------------------------------------------------------------
# Starlette handler: GET /api/sites/{id}/capabilities
# ---------------------------------------------------------------------------
async def api_site_capabilities(request: Request) -> JSONResponse:
"""Return the capability probe for a user-owned site.
Auth: same OAuth user session guard as the other site endpoints.
Query params:
* ``force=1`` — bypass the 10-minute cache.
"""
from core.dashboard.routes import _require_user_session
user_session, redirect = _require_user_session(request)
if redirect or user_session is None:
return JSONResponse({"ok": False, "error": "unauthorized"}, status_code=401)
site_id = (request.path_params.get("id") or "").strip()
if not site_id:
return JSONResponse({"ok": False, "error": "invalid_request"}, status_code=400)
force = request.query_params.get("force") in {"1", "true", "True"}
payload = await probe_site_capabilities(
site_id=site_id, user_id=user_session["user_id"], force=force
)
if payload.get("reason") == "site_not_found":
return JSONResponse({"ok": False, "error": "site_not_found"}, status_code=404)
# F.7e: also evaluate fit against the site's currently-selected
# tool_scope tier so the UI can render the badge without needing a
# second request. The caller-supplied ?tier=... query param lets the
# dashboard preview a tier before save.
from core.database import get_database
db = get_database()
site = await db.get_site(site_id, user_session["user_id"])
site_tier = (site or {}).get("tool_scope")
# Allow the caller to override the tier (e.g. preview before save).
tier_override = request.query_params.get("tier")
tier = tier_override or site_tier
fit = evaluate_tier_fit(
plugin_type=payload.get("plugin_type") or "",
tier=tier,
probe_payload=payload,
)
return JSONResponse(
{
"ok": True,
**payload,
"tier": tier,
"fit": fit,
}
)
async def api_site_capabilities_badge(request: Request):
"""F.X.fix #9 — render the capability-badge template fragment.
Used by the HTMX Re-check button so the badge swaps in place
instead of forcing a full-page reload. Response is HTML (not JSON)
— the caller sets ``hx-swap="outerHTML"`` on the badge element.
"""
from starlette.responses import HTMLResponse
from core.dashboard.routes import _require_user_session, templates
user_session, redirect = _require_user_session(request)
if redirect or user_session is None:
return HTMLResponse("<div>unauthorized</div>", status_code=401)
site_id = (request.path_params.get("id") or "").strip()
if not site_id:
return HTMLResponse("<div>invalid_request</div>", status_code=400)
force = request.query_params.get("force") in {"1", "true", "True"}
payload = await probe_site_capabilities(
site_id=site_id, user_id=user_session["user_id"], force=force
)
if payload.get("reason") == "site_not_found":
return HTMLResponse("<div>site_not_found</div>", status_code=404)
from core.database import get_database
db = get_database()
site = await db.get_site(site_id, user_session["user_id"])
if site is None:
return HTMLResponse("<div>site_not_found</div>", status_code=404)
tier = request.query_params.get("tier") or site.get("tool_scope")
fit = evaluate_tier_fit(
plugin_type=payload.get("plugin_type") or "",
tier=tier,
probe_payload=payload,
)
capability_probe = {**payload, "tier": tier, "fit": fit}
# Pull up the companion download URL the same way the page does.
try:
from plugins.wordpress.handlers._companion_hint import COMPANION_DOWNLOAD_URL
companion_download_url: str | None = COMPANION_DOWNLOAD_URL
except Exception: # noqa: BLE001
companion_download_url = None
lang = request.query_params.get("lang") or request.cookies.get("lang") or "en"
return templates.TemplateResponse(
request,
"dashboard/sites/_capability_badge.html",
{
"capability_probe": capability_probe,
"site": site,
"lang": lang,
"companion_download_url": companion_download_url,
},
)

325
core/companion_audit.py Normal file
View File

@@ -0,0 +1,325 @@
"""F.18.7 — Companion audit-hook receiver + per-site secret store.
The ``airano-mcp-bridge`` companion plugin (v2.7.0+) pushes WordPress
action events (post transitions, user events, plugin activations, etc.)
to MCPHub as HMAC-SHA256-signed webhooks. This module provides:
1. ``CompanionAuditSecretStore`` — file-backed map of ``site_url -> secret``.
2. ``verify_companion_signature`` — constant-time HMAC verification.
3. ``handle_companion_audit_request`` — Starlette handler that validates
the signature, appends the event to the audit log, and returns 200.
The store is intentionally independent of the SQLite sites DB so the
webhook can land before a dashboard UI exists; the UI (future work) can
replace ``CompanionAuditSecretStore`` with DB-backed storage without
changing the wire format.
"""
from __future__ import annotations
import hashlib
import hmac
import json
import logging
import os
import threading
import time
from datetime import datetime
from pathlib import Path
from typing import Any
from starlette.requests import Request
from starlette.responses import JSONResponse
logger = logging.getLogger("mcphub.companion_audit")
def _parse_timestamp(raw: Any) -> float | None:
"""Coerce the envelope ``timestamp`` field to epoch seconds.
Accepts either an ISO 8601 string (``2026-04-15T09:00:00Z`` — the
companion plugin's current format via PHP ``gmdate``) or a numeric
epoch value (for forward compatibility with other senders).
Returns None on any parse failure so callers can emit a uniform
401 without leaking which step failed.
"""
if raw is None:
return None
if isinstance(raw, int | float):
return float(raw)
if isinstance(raw, str):
s = raw.strip()
if not s:
return None
# Python 3.11+ fromisoformat handles "Z" suffix; on older
# versions we translate it to +00:00 explicitly.
iso = s.replace("Z", "+00:00") if s.endswith("Z") else s
try:
return datetime.fromisoformat(iso).timestamp()
except ValueError:
pass
# Numeric-string fallback (e.g. "1712345678").
try:
return float(s)
except ValueError:
return None
return None
# Pre-F.20 security sweep: bound incoming payload so a misconfigured
# (or malicious) companion can't queue up unbounded memory with a
# single request. Real audit events are well under 4 KB; 64 KB is a
# generous ceiling that still fits within Starlette's default limit.
_MAX_BODY_BYTES = int(os.environ.get("COMPANION_AUDIT_MAX_BODY", str(64 * 1024)))
# Replay-protection window (seconds). Events whose ``timestamp`` field
# falls outside ``now ± _REPLAY_WINDOW_SECONDS`` are rejected with 401.
# Set to 0 or negative to disable (not recommended). Default 5 minutes
# balances clock-skew tolerance against replay risk.
_REPLAY_WINDOW_SECONDS = int(os.environ.get("COMPANION_AUDIT_REPLAY_WINDOW", "300"))
def _normalise_url(url: str) -> str:
return url.rstrip("/").strip().lower()
class CompanionAuditSecretStore:
"""File-backed ``site_url -> shared_secret`` map.
The file is JSON; keys are normalised (lowercased, trailing slash
stripped). Access is serialised through a lock to keep reads and
writes atomic on a single-process server. Multi-process deployments
(gunicorn workers) can still race on the write path — that's fine
because the dashboard UI will be the only writer and operates in
the master process for this MVP.
"""
def __init__(self, path: str | Path) -> None:
self.path = Path(path)
self._lock = threading.Lock()
self._cache: dict[str, str] | None = None
def _load(self) -> dict[str, str]:
if self._cache is not None:
return self._cache
if not self.path.exists():
self._cache = {}
return self._cache
try:
data = json.loads(self.path.read_text(encoding="utf-8"))
if not isinstance(data, dict):
logger.warning(
"companion audit secret file %s is not a JSON object; ignoring.",
self.path,
)
self._cache = {}
else:
self._cache = {_normalise_url(str(k)): str(v) for k, v in data.items() if v}
except (OSError, json.JSONDecodeError) as exc:
logger.warning("Could not read companion audit secret file %s: %s", self.path, exc)
self._cache = {}
return self._cache
def _save(self, data: dict[str, str]) -> None:
self.path.parent.mkdir(parents=True, exist_ok=True)
tmp = self.path.with_suffix(self.path.suffix + ".tmp")
tmp.write_text(json.dumps(data, indent=2, sort_keys=True), encoding="utf-8")
tmp.replace(self.path)
try:
os.chmod(self.path, 0o600)
except OSError:
pass
self._cache = dict(data)
def get(self, site_url: str) -> str | None:
with self._lock:
return self._load().get(_normalise_url(site_url))
def set(self, site_url: str, secret: str) -> None:
if not secret or len(secret) < 16:
raise ValueError("companion audit secret must be at least 16 characters")
with self._lock:
data = dict(self._load())
data[_normalise_url(site_url)] = secret
self._save(data)
def delete(self, site_url: str) -> bool:
with self._lock:
data = dict(self._load())
key = _normalise_url(site_url)
if key not in data:
return False
del data[key]
self._save(data)
return True
def list_sites(self) -> list[dict[str, Any]]:
"""List sites with secret metadata only — never returns plaintext."""
with self._lock:
return [
{
"site_url": site_url,
"secret_set": True,
"secret_last4": secret[-4:] if len(secret) >= 4 else "",
}
for site_url, secret in self._load().items()
]
_DEFAULT_STORE_PATH = Path(
os.environ.get(
"COMPANION_AUDIT_SECRETS_PATH",
"/tmp/mcphub-data/companion-audit-secrets.json",
)
)
_default_store: CompanionAuditSecretStore | None = None
def get_companion_audit_store(
path: str | Path | None = None,
) -> CompanionAuditSecretStore:
global _default_store
if path is not None:
return CompanionAuditSecretStore(path)
if _default_store is None:
_default_store = CompanionAuditSecretStore(_DEFAULT_STORE_PATH)
return _default_store
def verify_companion_signature(
body_bytes: bytes, signature_header: str | None, secret: str
) -> bool:
"""Constant-time HMAC-SHA256 verification.
Accepts the PHP side's ``sha256=HEX`` format and a bare-hex variant.
Returns False on any shape error so callers can emit 401 uniformly.
"""
if not signature_header or not secret:
return False
sig = signature_header.strip()
if sig.startswith("sha256="):
sig = sig[len("sha256=") :]
if not sig or not all(c in "0123456789abcdefABCDEF" for c in sig):
return False
expected = hmac.new(secret.encode("utf-8"), body_bytes, hashlib.sha256).hexdigest()
return hmac.compare_digest(expected.lower(), sig.lower())
# Accepted event names — mirror the PHP side. Events outside this list
# are still accepted but tagged so operators can spot rogue sources.
_KNOWN_EVENTS = frozenset(
{
"transition_post_status",
"deleted_post",
"user_register",
"profile_update",
"deleted_user",
"activated_plugin",
"deactivated_plugin",
"switch_theme",
}
)
async def handle_companion_audit_request(request: Request) -> JSONResponse:
"""Starlette handler for ``POST /api/companion-audit``.
Validates the HMAC signature using a per-site secret, parses the
envelope, applies a replay window on the ``timestamp`` field, and
writes an audit-log entry. Returns 200 on success, 400 on shape
errors, 401 on signature/replay failure, 413 on oversized body.
"""
# Pre-F.20 security sweep: bound the incoming body before reading the
# whole thing. Prefer the framework's Content-Length hint; fall back
# to reading up to one byte past the ceiling so we can cleanly 413.
content_length_header = request.headers.get("content-length")
if content_length_header:
try:
content_length = int(content_length_header)
except ValueError:
return JSONResponse({"ok": False, "error": "invalid_length"}, status_code=400)
if content_length > _MAX_BODY_BYTES:
return JSONResponse(
{"ok": False, "error": "body_too_large", "max_bytes": _MAX_BODY_BYTES},
status_code=413,
)
# Read raw body — we need the exact bytes for HMAC verification.
body_bytes = await request.body()
if len(body_bytes) > _MAX_BODY_BYTES:
return JSONResponse(
{"ok": False, "error": "body_too_large", "max_bytes": _MAX_BODY_BYTES},
status_code=413,
)
if not body_bytes:
return JSONResponse({"ok": False, "error": "empty_body"}, status_code=400)
site_header = request.headers.get("X-Airano-MCP-Site") or ""
signature = request.headers.get("X-Airano-MCP-Signature")
try:
envelope = json.loads(body_bytes.decode("utf-8"))
except (UnicodeDecodeError, json.JSONDecodeError):
return JSONResponse({"ok": False, "error": "invalid_json"}, status_code=400)
if not isinstance(envelope, dict):
return JSONResponse({"ok": False, "error": "invalid_envelope"}, status_code=400)
site_url = str(envelope.get("site_url") or site_header or "")
if not site_url:
return JSONResponse({"ok": False, "error": "missing_site"}, status_code=400)
store = get_companion_audit_store()
secret = store.get(site_url)
if secret is None:
# Don't leak whether the site exists; same response as a bad sig.
return JSONResponse({"ok": False, "error": "unauthorized"}, status_code=401)
if not verify_companion_signature(body_bytes, signature, secret):
return JSONResponse({"ok": False, "error": "unauthorized"}, status_code=401)
# Pre-F.20 security sweep: enforce a replay window on the signed
# timestamp. Captured webhooks replayed outside the window are
# rejected with 401 (same opaque response as a bad signature to
# avoid giving attackers a distinguishing oracle).
if _REPLAY_WINDOW_SECONDS > 0:
ts = _parse_timestamp(envelope.get("timestamp"))
if ts is None:
return JSONResponse({"ok": False, "error": "unauthorized"}, status_code=401)
now = time.time()
if abs(now - ts) > _REPLAY_WINDOW_SECONDS:
logger.warning(
"companion audit replay rejected site=%s skew=%.1fs",
site_url,
now - ts,
)
return JSONResponse({"ok": False, "error": "unauthorized"}, status_code=401)
event_name = str(envelope.get("event") or "unknown")
details = {
"site_url": site_url,
"event": event_name,
"known_event": event_name in _KNOWN_EVENTS,
"timestamp": envelope.get("timestamp"),
"wp_user_id": envelope.get("user_id"),
"plugin_version": envelope.get("plugin_version"),
"data": envelope.get("data"),
}
try:
# Deferred import — avoid pulling audit_log into the store for tests.
from core.audit_log import get_audit_logger
audit_logger = get_audit_logger()
audit_logger.log_system_event(
event=f"companion_audit:{event_name}",
details=details,
)
except Exception as exc: # noqa: BLE001
logger.error("failed to append companion audit event: %s", exc)
return JSONResponse(
{"ok": False, "error": "audit_write_failed"},
status_code=500,
)
return JSONResponse({"ok": True, "event": event_name}, status_code=200)

128
core/config_snippets.py Normal file
View File

@@ -0,0 +1,128 @@
"""MCP client configuration snippet generation (Track E.3).
Generates copy-paste configuration snippets for connecting AI clients
(Claude Desktop, Claude Code, Cursor, VS Code, ChatGPT) to per-user
MCP endpoints.
Usage:
from core.config_snippets import generate_config, get_supported_clients
snippet = generate_config(
base_url="https://mcp.example.com",
user_id="abc123",
alias="myblog",
api_key="mhu_...",
client_type="claude_desktop",
)
"""
import json
# Supported MCP client types
SUPPORTED_CLIENTS = [
{
"id": "claude_connectors",
"label": "Claude.ai Connectors",
"description": "claude.ai/customize/connectors",
},
{
"id": "claude_desktop",
"label": "Claude Desktop",
"description": "Anthropic's desktop app for Claude",
},
{
"id": "claude_code",
"label": "Claude Code",
"description": "Anthropic's CLI for Claude",
},
{
"id": "cursor",
"label": "Cursor",
"description": "AI-first code editor",
},
{
"id": "vscode",
"label": "VS Code",
"description": "Visual Studio Code with MCP extension",
},
{
"id": "chatgpt",
"label": "ChatGPT",
"description": "OpenAI ChatGPT (URL-based)",
},
]
# Clients that only need a URL (no JSON config snippet, no transport Note)
WEB_CLIENTS = {"claude_connectors", "chatgpt"}
def get_supported_clients() -> list[dict[str, str]]:
"""Return the list of supported MCP client types."""
return SUPPORTED_CLIENTS
def generate_config(
base_url: str,
user_id: str,
alias: str,
api_key: str,
client_type: str,
) -> str:
"""Generate a configuration snippet for the given MCP client.
Args:
base_url: Public URL of the MCP Hub instance (no trailing slash).
user_id: User UUID.
alias: Site alias.
api_key: User API key (``mhu_...``).
client_type: One of the supported client type IDs.
Returns:
JSON configuration string ready for copy-paste.
Raises:
ValueError: If client_type is not supported.
"""
base_url = base_url.rstrip("/")
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}",
},
}
}
}
return json.dumps(config, indent=2)
elif client_type in ("cursor", "vscode"):
config = {
"mcp": {
"servers": {
server_name: {
"type": transport_type,
"url": endpoint_url,
"headers": {
"Authorization": f"Bearer {api_key}",
},
}
}
}
}
return json.dumps(config, indent=2)
elif client_type in ("claude_connectors", "chatgpt"):
return endpoint_url
else:
valid = [c["id"] for c in SUPPORTED_CLIENTS]
raise ValueError(f"Unsupported client type '{client_type}'. Valid: {valid}")

View File

@@ -89,19 +89,30 @@ class DashboardAuth:
if not api_key:
return False, "", None
api_key_clean = api_key.strip()
# Check if master key dashboard login is disabled
master_login_disabled = (
os.environ.get("DISABLE_MASTER_KEY_LOGIN", "false").lower() == "true"
)
# Check master API key (from env var)
if self.master_api_key and secrets.compare_digest(api_key, self.master_api_key):
if (
not master_login_disabled
and self.master_api_key
and secrets.compare_digest(api_key_clean, self.master_api_key.strip())
):
return True, "master", None
# Check AuthManager's master key (covers auto-generated temp keys)
try:
from core.auth import get_auth_manager
if not master_login_disabled:
try:
from core.auth import get_auth_manager
auth_mgr = get_auth_manager()
if auth_mgr.validate_master_key(api_key):
return True, "master", None
except Exception as e:
logger.debug(f"AuthManager check skipped: {e}")
auth_mgr = get_auth_manager()
if auth_mgr.validate_master_key(api_key):
return True, "master", None
except Exception as e:
logger.debug(f"AuthManager check skipped: {e}")
# Check project API keys with admin scope
try:
@@ -202,6 +213,9 @@ class DashboardAuth:
user_type=payload["type"],
key_id=payload.get("kid"),
)
except KeyError as e:
logger.debug(f"Invalid dashboard session payload (missing key): {e}")
return None
except jwt.ExpiredSignatureError:
logger.debug("Dashboard session expired")
return None
@@ -224,6 +238,27 @@ class DashboardAuth:
return None
return self.validate_session(token)
def get_user_session_from_request(self, request: Request) -> dict | None:
"""Extract and validate an OAuth user session from request.
Args:
request: Starlette request object.
Returns:
User session dict (user_id, email, name, role, type)
or None.
"""
token = request.cookies.get(self.COOKIE_NAME)
if not token:
return None
try:
from core.user_auth import get_user_auth
user_auth = get_user_auth()
return user_auth.validate_user_session(token)
except (RuntimeError, Exception):
return None
def set_session_cookie(self, response: Response, token: str) -> Response:
"""
Set session cookie on response.
@@ -273,13 +308,15 @@ class DashboardAuth:
RedirectResponse to login page if not authenticated, None if OK.
"""
session = self.get_session_from_request(request)
if not session:
user_session = self.get_user_session_from_request(request)
if not session and not user_session:
# Store original URL for redirect after login
next_url = str(request.url.path)
if request.url.query:
next_url += f"?{request.url.query}"
return RedirectResponse(
url=f"/dashboard/login?next={next_url}",
url=f"/auth/login?next={next_url}",
status_code=303,
)
return None
@@ -291,3 +328,58 @@ def get_dashboard_auth() -> DashboardAuth:
if _dashboard_auth is None:
_dashboard_auth = DashboardAuth()
return _dashboard_auth
# ── Role-checking helpers ──────────────────────────────────────
def is_admin_session(session) -> bool:
"""Check if session is admin (master key or API key with admin scope).
Args:
session: DashboardSession or OAuth user dict.
Returns:
True if admin session.
"""
if isinstance(session, DashboardSession):
return session.user_type in ("master", "api_key")
if isinstance(session, dict):
return session.get("type") == "master" or session.get("role") == "admin"
return False
def get_session_display_info(session) -> dict:
"""Get display info for header/UI.
Args:
session: DashboardSession or OAuth user dict.
Returns:
Dict with name, type, email, avatar keys.
"""
if isinstance(session, DashboardSession):
return {"name": "Admin", "type": "admin", "email": None, "avatar": None}
if isinstance(session, dict):
session_type = "admin" if session.get("role") == "admin" else "user"
return {
"name": session.get("name") or session.get("email", "User"),
"type": session_type,
"email": session.get("email"),
"avatar": None,
}
return {"name": "Unknown", "type": "unknown", "email": None, "avatar": None}
def get_session_user_id(session) -> str | None:
"""Get user_id for OAuth sessions, None for admin.
Args:
session: DashboardSession or OAuth user dict.
Returns:
User UUID string or None.
"""
if isinstance(session, dict):
return session.get("user_id")
return None

File diff suppressed because it is too large Load Diff

1147
core/database.py Normal file

File diff suppressed because it is too large Load Diff

241
core/encryption.py Normal file
View File

@@ -0,0 +1,241 @@
"""Credential encryption for the Live Platform.
Provides AES-256-GCM encryption with HKDF key derivation for per-site
credential storage. Credentials are encrypted as JSON blobs and stored
in SQLite. Decryption happens only during tool execution and plaintext
is never logged.
Usage:
encryption = get_credential_encryption()
cipherdata = encryption.encrypt_credentials(
{"username": "admin", "app_password": "xxxx xxxx"},
site_id="site_abc123",
)
credentials = encryption.decrypt_credentials(cipherdata, site_id="site_abc123")
"""
import base64
import json
import logging
import os
from cryptography.hazmat.primitives import hashes
from cryptography.hazmat.primitives.ciphers.aead import AESGCM
from cryptography.hazmat.primitives.kdf.hkdf import HKDF
logger = logging.getLogger(__name__)
# Constants
_NONCE_LENGTH = 12 # 96-bit nonce for AES-GCM
_KEY_LENGTH = 32 # 256-bit key
_HKDF_SALT = b"mcphub-v1"
_FORMAT_VERSION = b"\x01" # Wire format version for future migration support
class CredentialEncryption:
"""AES-256-GCM encryption with per-site HKDF-derived keys.
The master key is read from the ENCRYPTION_KEY environment variable
(base64-encoded 32-byte key). Per-site keys are derived via HKDF
using the site_id as the info parameter, ensuring each site has a
unique encryption key.
Storage format: version (1 byte) || nonce (12 bytes) || ciphertext || tag (16 bytes)
"""
def __init__(self, encryption_key: str | None = None) -> None:
"""Initialize credential encryption.
Args:
encryption_key: Base64-encoded 32-byte key. If not provided,
reads from the ENCRYPTION_KEY environment variable.
Raises:
ValueError: If the encryption key is missing or invalid.
"""
raw_key = encryption_key or os.getenv("ENCRYPTION_KEY")
if not raw_key:
raise ValueError(
"ENCRYPTION_KEY is required. Set it as an environment variable "
"or pass it directly. Generate one with: "
'python -c "import os, base64; print(base64.b64encode(os.urandom(32)).decode())"'
)
try:
self._master_key = base64.b64decode(raw_key)
except Exception as exc:
raise ValueError("ENCRYPTION_KEY must be a valid base64-encoded string.") from exc
if len(self._master_key) != _KEY_LENGTH:
raise ValueError(
f"ENCRYPTION_KEY must decode to exactly {_KEY_LENGTH} bytes, "
f"got {len(self._master_key)} bytes."
)
logger.info("Credential encryption initialized")
def _derive_key(self, site_id: str) -> bytes:
"""Derive a per-site encryption key using HKDF.
Args:
site_id: Unique identifier for the site.
Returns:
32-byte derived key for the given site.
"""
hkdf = HKDF(
algorithm=hashes.SHA256(),
length=_KEY_LENGTH,
salt=_HKDF_SALT,
info=site_id.encode("utf-8"),
)
return hkdf.derive(self._master_key)
def encrypt(self, plaintext: str, site_id: str) -> bytes:
"""Encrypt a plaintext string for a specific site.
Args:
plaintext: The string to encrypt.
site_id: Site identifier used for key derivation.
Returns:
Encrypted bytes: version (1) || nonce (12) || ciphertext || tag (16).
"""
derived_key = self._derive_key(site_id)
aesgcm = AESGCM(derived_key)
nonce = os.urandom(_NONCE_LENGTH)
ciphertext_with_tag = aesgcm.encrypt(nonce, plaintext.encode("utf-8"), None)
return _FORMAT_VERSION + nonce + ciphertext_with_tag
def decrypt(self, cipherdata: bytes, site_id: str) -> str:
"""Decrypt cipherdata for a specific site.
Args:
cipherdata: Encrypted bytes (nonce || ciphertext || tag).
site_id: Site identifier used for key derivation.
Returns:
Original plaintext string.
Raises:
cryptography.exceptions.InvalidTag: If decryption fails
(wrong key, tampered data, or wrong site_id).
ValueError: If cipherdata is too short or has unsupported version.
"""
# Minimum: 1 (version) + 12 (nonce) + 16 (tag) = 29 bytes
min_length = 1 + _NONCE_LENGTH + 16
if len(cipherdata) < min_length:
raise ValueError(
f"Cipherdata too short: expected at least {min_length} bytes, "
f"got {len(cipherdata)}."
)
version = cipherdata[:1]
if version != _FORMAT_VERSION:
raise ValueError(
f"Unsupported encryption format version: {version!r}. "
f"Expected {_FORMAT_VERSION!r}."
)
nonce = cipherdata[1 : 1 + _NONCE_LENGTH]
ciphertext_with_tag = cipherdata[1 + _NONCE_LENGTH :]
derived_key = self._derive_key(site_id)
aesgcm = AESGCM(derived_key)
plaintext_bytes = aesgcm.decrypt(nonce, ciphertext_with_tag, None)
return plaintext_bytes.decode("utf-8")
def encrypt_credentials(self, credentials: dict, site_id: str) -> bytes:
"""Encrypt a credentials dictionary for a specific site.
The dictionary is serialized to JSON, then encrypted.
Args:
credentials: Dictionary of credentials (e.g., username, password).
site_id: Site identifier used for key derivation.
Returns:
Encrypted bytes.
"""
json_str = json.dumps(credentials, separators=(",", ":"), sort_keys=True)
return self.encrypt(json_str, site_id)
def decrypt_credentials(self, cipherdata: bytes, site_id: str) -> dict:
"""Decrypt cipherdata back to a credentials dictionary.
Args:
cipherdata: Encrypted bytes from encrypt_credentials.
site_id: Site identifier used for key derivation.
Returns:
Original credentials dictionary.
Raises:
cryptography.exceptions.InvalidTag: If decryption fails.
json.JSONDecodeError: If decrypted data is not valid JSON.
"""
json_str = self.decrypt(cipherdata, site_id)
return json.loads(json_str)
def encrypt_for_scope(self, plaintext: str, scope: str) -> bytes:
"""Encrypt a plaintext string using an arbitrary HKDF scope string.
Used when the encrypted value is not a per-site credentials blob but
still needs per-key isolation (e.g. per-site AI provider API keys
where scope is ``site_provider:{site_id}:{provider}``).
Args:
plaintext: The string to encrypt.
scope: Scope string used as HKDF info for key derivation. Any
caller reading back the ciphertext must pass the same scope.
Returns:
Encrypted bytes (same wire format as :meth:`encrypt`).
"""
return self.encrypt(plaintext, scope)
def decrypt_for_scope(self, cipherdata: bytes, scope: str) -> str:
"""Decrypt cipherdata produced by :meth:`encrypt_for_scope`.
Args:
cipherdata: Encrypted bytes.
scope: Scope string — must exactly match what was used to encrypt.
Returns:
Original plaintext string.
"""
return self.decrypt(cipherdata, scope)
# Global credential encryption instance
_credential_encryption: CredentialEncryption | None = None
def initialize_credential_encryption(
encryption_key: str | None = None,
) -> CredentialEncryption:
"""Initialize the global credential encryption instance.
Args:
encryption_key: Base64-encoded 32-byte key. If not provided,
reads from the ENCRYPTION_KEY environment variable.
Returns:
The initialized CredentialEncryption instance.
"""
global _credential_encryption
_credential_encryption = CredentialEncryption(encryption_key)
return _credential_encryption
def get_credential_encryption() -> CredentialEncryption:
"""Get the global credential encryption instance.
Lazily initializes from the ENCRYPTION_KEY environment variable
if not already initialized via initialize_credential_encryption().
"""
global _credential_encryption
if _credential_encryption is None:
_credential_encryption = CredentialEncryption()
return _credential_encryption

View File

@@ -249,16 +249,15 @@ ENDPOINT_CONFIGS = {
},
max_tools=80,
),
# OpenPanel endpoint - Product Analytics (73 tools) - Phase H
# OpenPanel endpoint - Product Analytics (42 tools) - Phase H
EndpointType.OPENPANEL: EndpointConfig(
path="/openpanel",
name="OpenPanel Analytics",
description="OpenPanel product analytics management (events, export, funnels, dashboards)",
description="OpenPanel self-hosted product analytics (events, export, insights, project/client management)",
endpoint_type=EndpointType.OPENPANEL,
plugin_types=["openpanel"],
require_master_key=False,
allowed_scopes={"read", "write", "admin"},
# Blacklist system and admin tools
tool_blacklist={
"manage_api_keys_create",
"manage_api_keys_delete",
@@ -266,7 +265,7 @@ ENDPOINT_CONFIGS = {
"oauth_register_client",
"oauth_revoke_client",
},
max_tools=80,
max_tools=50,
),
# Appwrite endpoint - Backend-as-a-Service (100 tools) - Phase I
EndpointType.APPWRITE: EndpointConfig(

View File

@@ -48,6 +48,7 @@ class MCPEndpointFactory:
self.tool_registry = tool_registry
self.middleware_classes = middleware_classes or []
self.endpoints: dict[str, FastMCP] = {}
self._tool_counts: dict[str, int] = {}
self._tool_handlers: dict[str, Callable] = {}
def register_tool_handler(self, tool_name: str, handler: Callable):
@@ -92,8 +93,9 @@ class MCPEndpointFactory:
for middleware in custom_middleware:
mcp.add_middleware(middleware)
# Store endpoint
# Store endpoint and tool count
self.endpoints[config.path] = mcp
self._tool_counts[config.path] = len(tools)
logger.info(f" - Endpoint {config.path} created successfully")
@@ -282,9 +284,7 @@ class MCPEndpointFactory:
"""
info = []
for path, mcp in self.endpoints.items():
# Get tool count
# Note: This requires accessing FastMCP internals
tool_count = len(mcp._tool_manager._tools) if hasattr(mcp, "_tool_manager") else 0
tool_count = self._tool_counts.get(path, 0)
info.append(
{

View File

@@ -149,11 +149,7 @@ class EndpointRegistry:
tool_count = 0
if mcp:
# Try to get tool count from FastMCP
try:
tool_count = len(mcp._tool_manager._tools)
except AttributeError:
pass
tool_count = self.factory._tool_counts.get(path, 0)
endpoints.append(
EndpointInfo(

View File

@@ -12,6 +12,7 @@ This module provides comprehensive health monitoring capabilities including:
Author: MCP Hub Team
"""
import asyncio
import json
import logging
import time
@@ -22,7 +23,6 @@ from pathlib import Path
from typing import Any
from core.audit_log import AuditLogger
from core.project_manager import ProjectManager
from core.site_manager import SiteManager
logger = logging.getLogger(__name__)
@@ -129,7 +129,6 @@ class HealthMonitor:
def __init__(
self,
project_manager: ProjectManager,
audit_logger: AuditLogger | None = None,
metrics_retention_hours: int = 24,
max_metrics_per_project: int = 1000,
@@ -139,13 +138,11 @@ class HealthMonitor:
Initialize health monitor.
Args:
project_manager: Project manager instance
audit_logger: Optional audit logger for logging health events
metrics_retention_hours: Hours to retain historical metrics
max_metrics_per_project: Maximum metrics to store per project
site_manager: Optional SiteManager for comprehensive site discovery
site_manager: SiteManager for site discovery
"""
self.project_manager = project_manager
self.site_manager = site_manager
self.audit_logger = audit_logger
self.metrics_retention_hours = metrics_retention_hours
@@ -175,6 +172,11 @@ class HealthMonitor:
# Request rate tracking (for requests per minute)
self.request_timestamps: deque = deque(maxlen=1000)
# Active background checks
self.latest_health_status: dict[str, ProjectHealthStatus] = {}
self._bg_task: asyncio.Task | None = None
self._is_running = False
logger.info("HealthMonitor initialized")
def _setup_default_thresholds(self):
@@ -404,16 +406,8 @@ class HealthMonitor:
start_time = time.time()
try:
# Get plugin instance from ProjectManager
plugin = self.project_manager.projects.get(project_id)
if plugin:
# Perform health check via plugin instance
health_result = await plugin.health_check()
elif self.site_manager:
# Fallback: site exists in SiteManager but not ProjectManager
# (e.g. WooCommerce uses CONSUMER_KEY, not USERNAME/APP_PASSWORD)
health_result = await self._basic_site_health_check(project_id)
if self.site_manager:
health_result = await self._site_manager_health_check(project_id)
else:
return ProjectHealthStatus(
project_id=project_id,
@@ -470,7 +464,7 @@ class HealthMonitor:
}
alerts = self._check_alerts(project_id, alert_check_data)
return ProjectHealthStatus(
status = ProjectHealthStatus(
project_id=project_id,
healthy=is_healthy,
last_check=datetime.now(UTC),
@@ -480,6 +474,8 @@ class HealthMonitor:
alerts=alerts,
details=health_result,
)
self.latest_health_status[project_id] = status
return status
except Exception as e:
response_time_ms = (time.time() - start_time) * 1000
@@ -493,7 +489,7 @@ class HealthMonitor:
error_message=error_msg,
)
return ProjectHealthStatus(
status = ProjectHealthStatus(
project_id=project_id,
healthy=False,
last_check=datetime.now(UTC),
@@ -502,28 +498,99 @@ class HealthMonitor:
recent_errors=[error_msg],
alerts=[f"CRITICAL: Health check failed - {error_msg}"],
)
self.latest_health_status[project_id] = status
return status
async def _basic_site_health_check(self, project_id: str) -> dict[str, Any]:
"""Basic HTTP health check for sites not in ProjectManager."""
import aiohttp
def _find_site_info(self, project_id: str) -> dict[str, Any] | None:
"""Find site info from SiteManager by full_id."""
if not self.site_manager:
return None
for info in self.site_manager.list_all_sites():
if info["full_id"] == project_id:
return info
return None
async def _site_manager_health_check(self, project_id: str) -> dict[str, Any]:
"""
Health check for a site via SiteManager.
Creates a temporary plugin instance and calls its health_check() method,
falling back to a basic HTTP check if plugin instantiation fails.
"""
if not self.site_manager:
return {"healthy": False, "message": "SiteManager not available"}
# Parse plugin_type and site_id from full_id (e.g. "woocommerce_site1")
parts = project_id.split("_", 1)
if len(parts) < 2:
return {"healthy": False, "message": f"Invalid project_id format: {project_id}"}
# Look up site info by full_id (handles multi-word plugin types like wordpress_advanced)
site_info = self._find_site_info(project_id)
if not site_info:
return {"healthy": False, "message": f"Site not found: {project_id}"}
plugin_type = parts[0]
site_id = parts[1]
plugin_type = site_info["plugin_type"]
site_id = site_info["site_id"]
try:
config = self.site_manager.get_site_config(plugin_type, site_id)
except (KeyError, ValueError):
return {"healthy": False, "message": f"Site not found: {project_id}"}
return {"healthy": False, "message": f"Site config not found: {project_id}"}
# Try to create a temporary plugin instance for a proper health check
try:
from plugins import registry as plugin_registry
config_dict = config.to_dict()
plugin_instance = plugin_registry.create_instance(plugin_type, site_id, config_dict)
return await plugin_instance.health_check()
except Exception as e:
logger.warning(
f"Could not create plugin instance for {project_id}, "
f"falling back to authenticated HTTP check: {e}"
)
# Fallback: authenticated health check for WordPress-based plugins
if plugin_type in ("wordpress", "wordpress_advanced", "woocommerce"):
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(
auth_check_url,
timeout=aiohttp.ClientTimeout(total=10),
ssl=False,
) as resp:
auth_valid = resp.status == 200
basic_result = await self._basic_http_health_check(config.url, project_id)
basic_result["auth_valid"] = auth_valid
if not auth_valid:
basic_result["auth_warning"] = "Site accessible but credentials may be invalid"
return basic_result
except Exception:
pass
# Last resort: basic HTTP check
return await self._basic_http_health_check(config.url, project_id)
async def _basic_http_health_check(self, url: str | None, project_id: str) -> dict[str, Any]:
"""Basic HTTP health check as a last-resort fallback."""
import aiohttp
url = config.url
if not url:
return {"healthy": False, "message": "No URL configured for site"}
@@ -537,8 +604,27 @@ class HealthMonitor:
"status_code": resp.status,
"message": f"HTTP {resp.status} from {url}",
}
except TimeoutError:
return {
"healthy": False,
"error_type": "timeout",
"message": f"Site at {url} did not respond within 10 seconds.",
}
except aiohttp.ClientConnectorDNSError:
host = url.split("://")[-1].split("/")[0]
return {
"healthy": False,
"error_type": "dns_failure",
"message": f"DNS resolution failed for '{host}'. Check the URL.",
}
except aiohttp.ClientConnectorError:
return {
"healthy": False,
"error_type": "connection_refused",
"message": f"Cannot connect to {url}. Server unreachable.",
}
except Exception as e:
return {"healthy": False, "message": f"Connection failed: {e}"}
return {"healthy": False, "error_type": "unknown", "message": f"Connection failed: {e}"}
async def check_all_projects_health(self, include_metrics: bool = True) -> dict[str, Any]:
"""
@@ -552,8 +638,8 @@ class HealthMonitor:
"""
health_statuses = {}
# Collect all known project/site IDs from both sources
all_project_ids = set(self.project_manager.projects.keys())
# Collect all known site IDs from SiteManager
all_project_ids = set()
if self.site_manager:
for site_info in self.site_manager.list_all_sites():
all_project_ids.add(site_info["full_id"])
@@ -711,8 +797,46 @@ class HealthMonitor:
self.failed_requests = 0
self.response_times.clear()
self.request_timestamps.clear()
self.latest_health_status.clear()
logger.warning("All metrics have been reset")
async def start_background_checks(self, interval_seconds: int = 60):
"""Start background health checks for all projects."""
if self._is_running:
return
self._is_running = True
logger.info(f"Starting background health checks every {interval_seconds} seconds")
async def _loop():
# Initial wait to let server start up fully
await asyncio.sleep(5)
while self._is_running:
try:
await self.check_all_projects_health(include_metrics=True)
except Exception as e:
logger.error(f"Error in background health check loop: {e}")
# Sleep interval, check _is_running periodically
for _ in range(interval_seconds):
if not self._is_running:
break
await asyncio.sleep(1)
self._bg_task = asyncio.create_task(_loop())
async def stop_background_checks(self):
"""Stop background health checks."""
self._is_running = False
if self._bg_task:
self._bg_task.cancel()
try:
await self._bg_task
except asyncio.CancelledError:
pass
self._bg_task = None
logger.info("Background health checks stopped")
# Singleton instance
_health_monitor: HealthMonitor | None = None
@@ -724,7 +848,6 @@ def get_health_monitor() -> HealthMonitor | None:
def initialize_health_monitor(
project_manager: ProjectManager,
audit_logger: AuditLogger | None = None,
site_manager: SiteManager | None = None,
**kwargs,
@@ -733,16 +856,13 @@ def initialize_health_monitor(
Initialize the global health monitor.
Args:
project_manager: Project manager instance
audit_logger: Optional audit logger
site_manager: Optional SiteManager for comprehensive site discovery
site_manager: SiteManager for site discovery
**kwargs: Additional configuration options
Returns:
HealthMonitor instance
"""
global _health_monitor
_health_monitor = HealthMonitor(
project_manager, audit_logger, site_manager=site_manager, **kwargs
)
_health_monitor = HealthMonitor(audit_logger=audit_logger, site_manager=site_manager, **kwargs)
return _health_monitor

67
core/media_audit.py Normal file
View File

@@ -0,0 +1,67 @@
"""F.5a.6.4 — Audit-log emission for media uploads.
One ``media.upload`` entry is written per **successful** upload regardless
of source (base64 / url / chunked / ai:<provider>). Failures are intentionally
NOT logged here — they surface to the caller as typed ``UploadError`` JSON
and to the dashboard via the existing tool-call audit emitted by the
ToolRouter wrapper. Logging failures twice would double-count error rates.
GDPR: never log raw bytes, base64, prompts, or URLs that may carry tokens.
"""
from __future__ import annotations
import logging
from typing import Any
logger = logging.getLogger(__name__)
def log_media_upload(
*,
site: str | None,
user_id: str | None,
mime: str | None,
size_bytes: int,
source: str,
media_id: int | None,
cost_usd: float | None = None,
) -> None:
"""Emit a single ``media.upload`` audit entry. Best-effort — never raises.
Args:
site: Site URL or alias (whichever the handler has on hand).
user_id: Calling user id (None for admin / env-fallback).
mime: Sniffed MIME type after security validation.
size_bytes: Final uploaded byte count (post-optimization).
source: One of ``"base64"``, ``"url"``, ``"chunked"``, ``"ai:<provider>"``.
media_id: WordPress media library id of the resulting attachment.
cost_usd: Provider cost in USD for AI-generated uploads only.
"""
try:
from core.audit_log import get_audit_logger
audit = get_audit_logger()
except Exception: # noqa: BLE001
return
params: dict[str, Any] = {
"site": site,
"mime": mime,
"size_bytes": int(size_bytes),
"source": source,
"media_id": media_id,
}
if cost_usd is not None:
params["cost_usd"] = round(float(cost_usd), 6)
try:
audit.log_tool_call(
tool_name="media.upload",
site=site,
params=params,
result_summary=(f"{source} {size_bytes}B {mime or '?'} -> media_id={media_id}"),
user_id=user_id,
)
except Exception: # noqa: BLE001
logger.debug("media.upload audit emit failed", exc_info=True)

91
core/media_error_codes.py Normal file
View File

@@ -0,0 +1,91 @@
"""F.5a.6.2 — Stable error-code taxonomy for media upload tools.
Every :class:`plugins.wordpress.handlers._media_security.UploadError` and
:class:`core.upload_sessions.UploadSessionError` raised inside the media
stack MUST use one of the codes listed here (or match the dynamic
``WP_<status>`` pattern for upstream WordPress REST responses).
The accompanying test ``tests/plugins/wordpress/test_media_error_taxonomy.py``
scans the source files for raise-site literals and asserts each one is in
this set — so the contract stays stable as the code evolves.
The full reference is in ``docs/media-error-codes.md``.
"""
from __future__ import annotations
import re
#: All stable upload/media error codes. Keep alphabetical within groups.
MEDIA_ERROR_CODES: frozenset[str] = frozenset(
{
# --- Input / validation ------------------------------------------
"BAD_BASE64",
"BAD_MODE",
"BAD_ROLE",
"BAD_SIZE",
"BAD_SOURCE",
"EMPTY_FILE",
"MEDIA_NOT_FOUND",
"MIME_REJECTED",
"MISSING_FIELD",
"SSRF",
"TOO_LARGE",
"URL_FETCH_FAILED",
# --- WordPress REST upstream -------------------------------------
"WP_413",
"WP_AUTH",
"WP_BAD_RESPONSE",
# F.X.fix-pass4 — WC sites with consumer_key/consumer_secret
# auth need a separate WP Application Password to upload to
# /wp/v2/media. Surfaced by media_attach.py when the user
# hasn't filled wp_username/wp_app_password in Connection
# Settings.
"WP_CREDENTIALS_MISSING",
# WP_<status> (e.g. WP_500) is also allowed — see MEDIA_ERROR_CODE_RE
# --- Companion plugin upload-chunk route (F.5a.7) ----------------
"COMPANION_BAD_RESPONSE",
# COMPANION_<status> (e.g. COMPANION_500) is also allowed — same
# shape as WP_<status>, see _COMPANION_STATUS_RE.
# --- Chunked upload session --------------------------------------
"BAD_STATE",
"CHECKSUM_MISMATCH",
"CHUNK_CHECKSUM",
"CHUNK_ORDER",
"CHUNK_OVERFLOW",
"EXPIRED",
"INCOMPLETE",
"NO_SESSION",
"QUOTA_EXCEEDED",
"SESSION_TOO_LARGE",
# --- AI generation providers -------------------------------------
"GENERATION_FAILED",
"NO_PROVIDER_KEY",
"PROVIDER_AUTH",
"PROVIDER_BAD_REQUEST",
"PROVIDER_BAD_RESPONSE",
"PROVIDER_QUOTA",
"PROVIDER_TIMEOUT",
"PROVIDER_UNAVAILABLE",
"PROVIDER_UNKNOWN",
# --- Rate / policy -----------------------------------------------
"TOOL_RATE_LIMITED",
# --- Catchall ----------------------------------------------------
"INTERNAL",
}
)
#: Dynamic WP REST status codes (e.g. WP_400, WP_500).
_WP_STATUS_RE = re.compile(r"^WP_\d{3}$")
#: Dynamic companion upload-chunk status codes (e.g. COMPANION_400, COMPANION_500).
_COMPANION_STATUS_RE = re.compile(r"^COMPANION_\d{3}$")
def is_valid_code(code: str) -> bool:
"""Return True if ``code`` is a documented media error code."""
return (
code in MEDIA_ERROR_CODES
or bool(_WP_STATUS_RE.match(code))
or bool(_COMPANION_STATUS_RE.match(code))
)

View File

@@ -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.
@@ -97,8 +98,9 @@ class ClientRegistry:
client_name=client_name,
redirect_uris=redirect_uris,
grant_types=grant_types or ["authorization_code", "refresh_token"],
allowed_scopes=allowed_scopes or ["read", "write"],
allowed_scopes=allowed_scopes or ["read", "write", "admin"],
metadata=metadata or {},
owner_user_id=owner_user_id,
)
# Save

View File

@@ -18,15 +18,18 @@ class OAuthClient(BaseModel):
default=["authorization_code", "refresh_token"], description="Allowed grant types"
)
response_types: list[str] = Field(default=["code"], description="Allowed response types")
scope: str = Field(default="read", description="Default scope for this client")
scope: str = Field(default="read write admin", description="Default scope for this client")
allowed_scopes: list[str] = Field(
default=["read", "write"], description="All scopes this client can request"
default=["read", "write", "admin"], description="All scopes this client can request"
)
token_endpoint_auth_method: str = Field(
default="client_secret_post", description="Token endpoint authentication method"
)
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"""

View File

@@ -115,7 +115,7 @@ class OAuthServer:
)
# Validate scope
requested_scopes = scope.split() if scope else ["read"]
requested_scopes = scope.split() if scope else ["read", "write", "admin"]
for s in requested_scopes:
if s not in client.allowed_scopes:
raise OAuthError(
@@ -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(

View File

@@ -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
},
)
@@ -241,8 +251,8 @@ class TokenManager:
try:
old_payload = self.validate_access_token(token_data.access_token)
scope = old_payload.get("scope", "read")
except:
pass # Old token might be expired, use default
except Exception:
pass # Old token might be expired, use default scope
# Generate new tokens
new_access_token = self.generate_access_token(

59
core/plugin_visibility.py Normal file
View File

@@ -0,0 +1,59 @@
"""Plugin visibility control for public vs admin users (Track F.1).
Controls which plugins are visible to public (OAuth) users vs admin users.
Admin users (MASTER_API_KEY) always see all plugins. Public users only see
plugins listed in the ENABLED_PLUGINS setting (DB > ENV > default).
Usage:
from core.plugin_visibility import get_public_plugin_types, is_plugin_public
public_types = get_public_plugin_types() # {"wordpress", "woocommerce", "supabase"}
if is_plugin_public("gitea"): # True
...
"""
import os
# Default plugins available to public (OAuth) users
DEFAULT_PUBLIC_PLUGINS = {"wordpress", "woocommerce", "supabase", "openpanel", "gitea"}
def _parse_plugins(val: str) -> set[str]:
"""Parse comma-separated plugin string into set."""
return {p.strip().lower() for p in val.split(",") if p.strip()}
def get_public_plugin_types() -> set[str]:
"""Return the set of plugin types visible to public users.
Checks DB settings first (sync-safe), then env var, then defaults.
Returns:
Set of lowercase plugin type strings.
"""
# Try DB setting (sync access via cached value)
try:
from core.settings import _cached_plugins
if _cached_plugins is not None:
return set(_cached_plugins)
except (ImportError, AttributeError):
pass
# Fallback to env var
env_val = os.getenv("ENABLED_PLUGINS", "").strip()
if not env_val:
return set(DEFAULT_PUBLIC_PLUGINS)
return _parse_plugins(env_val)
def is_plugin_public(plugin_type: str) -> bool:
"""Check if a plugin type is enabled for public users.
Args:
plugin_type: Plugin type string (e.g., "wordpress").
Returns:
True if the plugin is in the public set.
"""
return plugin_type.lower() in get_public_plugin_types()

View File

@@ -1,259 +0,0 @@
"""
Project Manager
Discovers and manages project instances from environment variables.
Handles plugin lifecycle and tool registration.
"""
import logging
import os
import re
from typing import Any
from plugins import BasePlugin, registry
logger = logging.getLogger(__name__)
class ProjectManager:
"""
Manage multiple project instances.
Projects are discovered from environment variables:
- {PLUGIN_TYPE}_{PROJECT_ID}_{CONFIG_KEY}
Example:
WORDPRESS_SITE1_URL=https://example.com
WORDPRESS_SITE1_USERNAME=admin
WORDPRESS_SITE1_APP_PASSWORD=xxxx
WORDPRESS_SITE2_URL=https://other.com
...
"""
def __init__(self):
"""Initialize project manager."""
self.projects: dict[str, BasePlugin] = {}
self.logger = logging.getLogger("ProjectManager")
def discover_projects(self) -> None:
"""
Discover projects from environment variables.
Scans environment for project configurations and creates
plugin instances.
"""
self.logger.info("Starting project discovery...")
# Get all registered plugin types
plugin_types = registry.get_registered_types()
for plugin_type in plugin_types:
self._discover_plugin_type(plugin_type)
self.logger.info(f"Discovery complete. Found {len(self.projects)} projects.")
def _discover_plugin_type(self, plugin_type: str) -> None:
"""
Discover all projects of a specific plugin type.
Args:
plugin_type: Type of plugin (e.g., 'wordpress')
"""
prefix = plugin_type.upper() + "_"
# Build list of longer prefixes from other plugin types to avoid collisions.
# e.g. WORDPRESS_ must not match WORDPRESS_ADVANCED_ env vars.
plugin_types = registry.get_registered_types()
longer_prefixes = [
pt.upper() + "_"
for pt in plugin_types
if pt != plugin_type and pt.upper().startswith(plugin_type.upper() + "_")
]
# Find all project IDs for this plugin type
project_ids = set()
env_pattern = re.compile(f"^{prefix}([A-Z0-9_]+?)_(.+)$")
for env_key in os.environ.keys():
# Skip env vars that belong to a more specific plugin type
if any(env_key.startswith(lp) for lp in longer_prefixes):
continue
match = env_pattern.match(env_key)
if match:
project_id = match.group(1).lower()
project_ids.add(project_id)
# Create plugin instance for each project
for project_id in project_ids:
try:
config = self._load_project_config(plugin_type, project_id)
if config:
self._create_project_instance(plugin_type, project_id, config)
except Exception as e:
self.logger.debug(f"Legacy ProjectManager: skipped {plugin_type}/{project_id}: {e}")
def _load_project_config(self, plugin_type: str, project_id: str) -> dict[str, Any] | None:
"""
Load configuration for a project from environment.
Args:
plugin_type: Plugin type
project_id: Project ID
Returns:
Dict with configuration or None if incomplete
"""
prefix = f"{plugin_type.upper()}_{project_id.upper()}_"
config = {}
# Collect all config keys for this project
for env_key, env_value in os.environ.items():
if env_key.startswith(prefix):
# Extract config key (everything after prefix)
config_key = env_key[len(prefix) :].lower()
config[config_key] = env_value
if not config:
return None
self.logger.debug(f"Loaded config for {plugin_type}/{project_id}: {list(config.keys())}")
return config
def _create_project_instance(
self, plugin_type: str, project_id: str, config: dict[str, Any]
) -> None:
"""
Create a plugin instance for a project.
Args:
plugin_type: Plugin type
project_id: Project ID
config: Project configuration
"""
try:
# Create plugin instance
plugin = registry.create_instance(plugin_type, project_id, config)
# Store with full identifier
full_id = f"{plugin_type}_{project_id}"
self.projects[full_id] = plugin
self.logger.info(f"Created project: {full_id}")
except Exception as e:
raise Exception(f"Failed to instantiate {plugin_type}/{project_id}: {e}")
def get_project(self, full_id: str) -> BasePlugin | None:
"""
Get a project plugin instance.
Args:
full_id: Full project identifier (plugin_type_project_id)
Returns:
Plugin instance or None
"""
return self.projects.get(full_id)
def get_all_projects(self) -> dict[str, BasePlugin]:
"""Get all project instances."""
return self.projects.copy()
def get_projects_by_type(self, plugin_type: str) -> dict[str, BasePlugin]:
"""
Get all projects of a specific type.
Args:
plugin_type: Plugin type to filter by
Returns:
Dict of project_id -> plugin
"""
prefix = plugin_type + "_"
return {
full_id: plugin
for full_id, plugin in self.projects.items()
if full_id.startswith(prefix)
}
def get_all_tools(self) -> list[dict[str, Any]]:
"""
Get all MCP tools from all projects.
Returns:
List of tool definitions
"""
all_tools = []
for full_id, plugin in self.projects.items():
try:
tools = plugin.get_tools()
all_tools.extend(tools)
self.logger.debug(f"Loaded {len(tools)} tools from {full_id}")
except Exception as e:
self.logger.error(f"Error loading tools from {full_id}: {e}", exc_info=True)
self.logger.debug(f"Total tools loaded: {len(all_tools)}")
return all_tools
async def check_all_health(self) -> dict[str, dict[str, Any]]:
"""
Check health of all projects.
Returns:
Dict mapping project ID to health status
"""
health_results = {}
for full_id, plugin in self.projects.items():
try:
health = await plugin.health_check()
health_results[full_id] = health
except Exception as e:
health_results[full_id] = {
"healthy": False,
"message": f"Health check failed: {str(e)}",
}
return health_results
def get_project_info(self, full_id: str) -> dict[str, Any] | None:
"""
Get information about a specific project.
Args:
full_id: Full project identifier
Returns:
Project info dict or None
"""
plugin = self.get_project(full_id)
if plugin:
return plugin.get_project_info()
return None
def list_projects(self) -> list[dict[str, Any]]:
"""
List all projects with basic information.
Returns:
List of project info dicts
"""
return [
{"id": full_id, "type": plugin.get_plugin_name(), "project_id": plugin.project_id}
for full_id, plugin in self.projects.items()
]
# Global project manager instance
_project_manager: ProjectManager | None = None
def get_project_manager() -> ProjectManager:
"""Get the global project manager instance."""
global _project_manager
if _project_manager is None:
_project_manager = ProjectManager()
_project_manager.discover_projects()
return _project_manager

162
core/settings.py Normal file
View File

@@ -0,0 +1,162 @@
"""Unified settings access with DB > ENV > Default priority (Phase 4C.3).
Usage:
from core.settings import get_setting
enabled = await get_setting("ENABLED_PLUGINS", "wordpress,woocommerce,supabase,openpanel,gitea")
max_sites = int(await get_setting("MAX_SITES_PER_USER", "10"))
"""
import logging
import os
logger = logging.getLogger(__name__)
# Cached plugin set for sync access (updated when settings change)
_cached_plugins: set[str] | None = None
# Default values for all managed settings
SETTING_DEFAULTS: dict[str, str] = {
"ENABLED_PLUGINS": "wordpress,woocommerce,supabase,openpanel,gitea",
"MAX_SITES_PER_USER": "10",
"USER_RATE_LIMIT_PER_MIN": "30",
"USER_RATE_LIMIT_PER_HR": "500",
}
# Human-readable labels for the settings UI
SETTING_LABELS: dict[str, dict[str, str]] = {
"ENABLED_PLUGINS": {
"label": "Enabled Plugins",
"label_fa": "پلاگین‌های فعال",
"hint": "Comma-separated plugin types visible to public users",
"hint_fa": "انواع پلاگین قابل مشاهده برای کاربران عمومی (با کاما جدا شوند)",
},
"MAX_SITES_PER_USER": {
"label": "Max Sites per User",
"label_fa": "حداکثر سایت هر کاربر",
"hint": "Maximum number of sites each user can create",
"hint_fa": "حداکثر تعداد سایت‌هایی که هر کاربر می‌تواند بسازد",
},
"USER_RATE_LIMIT_PER_MIN": {
"label": "User Rate Limit (per minute)",
"label_fa": "محدودیت نرخ کاربر (در دقیقه)",
"hint": "Maximum MCP requests per user per minute",
"hint_fa": "حداکثر درخواست MCP هر کاربر در دقیقه",
},
"USER_RATE_LIMIT_PER_HR": {
"label": "User Rate Limit (per hour)",
"label_fa": "محدودیت نرخ کاربر (در ساعت)",
"hint": "Maximum MCP requests per user per hour",
"hint_fa": "حداکثر درخواست MCP هر کاربر در ساعت",
},
}
async def get_setting(key: str, default: str | None = None) -> str | None:
"""Get a setting value with priority: Database > Environment > Default.
Args:
key: Setting key (e.g., "ENABLED_PLUGINS").
default: Fallback if not found anywhere. If None, uses SETTING_DEFAULTS.
Returns:
Setting value string, or None if not found anywhere.
"""
# 1. Try database
try:
from core.database import get_database
db = get_database()
db_val = await db.get_setting(key)
if db_val is not None:
return db_val
except Exception:
pass # DB not initialized yet (startup) — fall through
# 2. Try environment variable
env_val = os.environ.get(key)
if env_val is not None:
return env_val
# 3. Use provided default or SETTING_DEFAULTS
if default is not None:
return default
return SETTING_DEFAULTS.get(key)
async def refresh_plugin_cache() -> None:
"""Refresh the cached plugin set from DB/ENV/default."""
global _cached_plugins
val = await get_setting("ENABLED_PLUGINS")
if val:
_cached_plugins = {p.strip().lower() for p in val.split(",") if p.strip()}
else:
_cached_plugins = None
async def save_setting(key: str, value: str) -> None:
"""Save a setting to database and refresh caches."""
from core.database import get_database
db = get_database()
await db.set_setting(key, value)
if key == "ENABLED_PLUGINS":
await refresh_plugin_cache()
async def delete_setting_value(key: str) -> bool:
"""Delete a setting from database (revert to ENV/default)."""
from core.database import get_database
db = get_database()
deleted = await db.delete_setting(key)
if key == "ENABLED_PLUGINS":
await refresh_plugin_cache()
return deleted
async def get_all_managed_settings() -> list[dict[str, str]]:
"""Get all managed settings with their current values and sources.
Returns:
List of dicts with: key, value, source ("database"/"environment"/"default"),
label, hint.
"""
result = []
try:
from core.database import get_database
db = get_database()
db_settings = await db.get_all_settings()
except Exception:
db_settings = {}
for key, default_val in SETTING_DEFAULTS.items():
meta = SETTING_LABELS.get(key, {})
db_val = db_settings.get(key)
env_val = os.environ.get(key)
if db_val is not None:
value, source = db_val, "database"
elif env_val is not None:
value, source = env_val, "environment"
else:
value, source = default_val, "default"
result.append(
{
"key": key,
"value": value,
"source": source,
"default": default_val,
"label": meta.get("label", key),
"label_fa": meta.get("label_fa", key),
"hint": meta.get("hint", ""),
"hint_fa": meta.get("hint_fa", ""),
}
)
return result

900
core/site_api.py Normal file
View File

@@ -0,0 +1,900 @@
"""Site management logic for the Live Platform (Track E.3).
Provides site CRUD operations, connection validation, and credential
field definitions for all 9 plugin types. Coordinates between the
database, encryption, and plugin health check layers.
Usage:
from core.site_api import create_user_site, get_user_sites, validate_site_connection
ok, msg = await validate_site_connection("wordpress", "https://example.com", creds)
site = await create_user_site(user_id, "wordpress", "myblog", url, creds)
"""
import logging
import os
from typing import Any
import aiohttp
logger = logging.getLogger(__name__)
# Maximum sites per user (configurable via env var)
MAX_SITES_PER_USER = int(os.getenv("MAX_SITES_PER_USER", "10"))
# Plugin credential field definitions — drives the dynamic "Add Site" form
# and server-side validation. Each field has:
# name: form input name (matches credential JSON key)
# label: display label
# type: "text" or "password"
# required: whether the field is mandatory
PLUGIN_CREDENTIAL_FIELDS: dict[str, list[dict[str, Any]]] = {
"wordpress": [
{
"name": "username",
"label": "Username",
"type": "text",
"required": True,
"hint": "Your WordPress admin username (used as the HTTP Basic username for every API call).",
},
{
"name": "app_password",
"label": "Application Password",
"type": "password",
"required": True,
"hint": (
"WordPress Admin → Users → Profile → Application Passwords. "
"This IS the API credential — no separate API key is needed. "
"Paste the value WP shows once after creation (spaces can stay or be removed)."
),
},
],
"woocommerce": [
{
"name": "consumer_key",
"label": "Consumer Key",
"type": "text",
"required": True,
"hint": (
"WooCommerce → Settings → Advanced → REST API → Add Key. "
"Read/Write permission. This pair IS the API auth — no extra API key field exists."
),
},
{
"name": "consumer_secret",
"label": "Consumer Secret",
"type": "password",
"required": True,
"hint": (
"Shown once when the REST API key is created. "
"Starts with ``cs_``. Save it immediately — WooCommerce will not display it again."
),
},
# F.X.fix-pass4 — optional WP Application Password, only used by
# media tools (upload_and_attach_to_product / attach_media_to_
# product / set_featured_image). WooCommerce's Consumer Key +
# Secret authenticate ``/wc/v3/*`` but NOT ``/wp/v2/media``;
# WordPress core REST media uploads require an Application
# Password from the WP admin user. Leave blank if the store
# never needs MCP-driven image uploads.
{
"name": "wp_username",
"label": "WordPress Username (for media tools)",
"type": "text",
"required": False,
"advanced": True,
"hint": (
"Only required if you want the AI / media tools "
"(upload_and_attach_to_product, attach_media_to_product, "
"set_featured_image, generate_and_upload_image with attach_to_post). "
"Other WC tools work with Consumer Key / Secret alone."
),
},
{
"name": "wp_app_password",
"label": "WordPress Application Password (for media tools)",
"type": "password",
"required": False,
"advanced": True,
"hint": (
"WP Admin → Users → Profile → Application Passwords. "
"WC media uploads hit /wp/v2/media which only accepts "
"Application Passwords (not Consumer Key / Secret). "
"Leave empty if you don't use MCP for image uploads."
),
},
],
"wordpress_advanced": [
{
"name": "username",
"label": "Username",
"type": "text",
"required": True,
"hint": "Your WordPress admin username (HTTP Basic username for every API call).",
},
{
"name": "app_password",
"label": "Application Password",
"type": "password",
"required": True,
"hint": (
"WordPress Admin → Users → Profile → Application Passwords. "
"IS the API credential — no separate API key needed."
),
},
{
"name": "container",
"label": "Docker Container Name",
"type": "text",
"required": False,
"hint": "Docker container running WordPress (for WP-CLI access). Leave empty if unused.",
},
],
"gitea": [
{
"name": "token",
"label": "Access Token",
"type": "password",
"required": True,
"hint": "Gitea → Settings → Applications → Generate Token",
},
],
"n8n": [
{
"name": "api_key",
"label": "API Key",
"type": "password",
"required": True,
"hint": "n8n → Settings → API → Create API Key",
},
],
"supabase": [
{
"name": "service_role_key",
"label": "Service Role Key",
"type": "password",
"required": True,
"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 (Optional)",
"type": "password",
"required": False,
"advanced": True,
"hint": (
"Supabase Dashboard → Settings → API → anon key. "
"Optional — if omitted, service_role_key is used for all calls. "
"Only useful for testing RLS policies as a regular user."
),
},
{
"name": "meta_url",
"label": "postgres-meta URL (Optional)",
"type": "text",
"required": False,
"advanced": True,
"hint": (
"Only needed if your Supabase setup does not expose /pg/ through Kong. "
"Most self-hosted installs (including Coolify) work without this. "
"Example: http://supabase-meta:8080 or https://your-meta.example.com"
),
},
{
"name": "meta_auth",
"label": "postgres-meta Auth (Optional)",
"type": "password",
"required": False,
"advanced": True,
"hint": (
"Basic Auth for postgres-meta (format: username:password). "
"Only needed when postgres-meta is exposed via a public URL."
),
},
],
"openpanel": [
{
"name": "client_id",
"label": "Client ID",
"type": "text",
"required": True,
"hint": "Open your project on dashboard.openpanel.dev → Settings → Clients (URL: dashboard.openpanel.dev/{org}/{project-id}/settings/clients). Create a client in 'root' mode for full access.",
},
{
"name": "client_secret",
"label": "Client Secret",
"type": "password",
"required": True,
"hint": "Generated with your Client ID",
},
{
"name": "project_id",
"label": "Project ID",
"type": "text",
"required": False,
"hint": "From dashboard URL: dashboard.openpanel.dev/{org}/{project-id}/ — sets default for Export & Insights tools",
},
{
"name": "organization_id",
"label": "Organization ID",
"type": "text",
"required": False,
"hint": "From dashboard URL: dashboard.openpanel.dev/{org}/{project-id}/",
"advanced": True,
},
],
"appwrite": [
{
"name": "project_id",
"label": "Project ID",
"type": "text",
"required": True,
"hint": "Appwrite Console → Project Settings → Project ID",
},
{
"name": "api_key",
"label": "API Key",
"type": "password",
"required": True,
"hint": "Appwrite Console → Project Settings → API Keys → Create",
},
],
"directus": [
{
"name": "token",
"label": "Static Token",
"type": "password",
"required": True,
"hint": "Directus → Settings → User → Static Token",
},
],
"coolify": [
{
"name": "token",
"label": "API Token",
"type": "password",
"required": True,
"hint": "Coolify → Keys & Tokens → API tokens → Create",
},
],
}
# Plugin display names for UI
PLUGIN_DISPLAY_NAMES: dict[str, str] = {
"wordpress": "WordPress",
"woocommerce": "WooCommerce",
"wordpress_advanced": "WordPress Advanced",
"gitea": "Gitea",
"n8n": "n8n",
"supabase": "Supabase",
"openpanel": "OpenPanel",
"appwrite": "Appwrite",
"directus": "Directus",
"coolify": "Coolify",
}
# Health check endpoints per plugin type
_HEALTH_ENDPOINTS: dict[str, dict[str, Any]] = {
"wordpress": {"path": "/wp-json/wp/v2/users/me", "method": "GET"},
"woocommerce": {"path": "/wp-json/wc/v3/system_status", "method": "GET"},
"wordpress_advanced": {"path": "/wp-json/wp/v2/users/me", "method": "GET"},
"gitea": {"path": "/api/v1/user", "method": "GET"},
"n8n": {"path": "/healthz", "method": "GET"},
"supabase": {"path": "/rest/v1/", "method": "GET"},
"openpanel": {"path": "/healthcheck", "method": "GET"},
"appwrite": {"path": "/v1/health", "method": "GET"},
"directus": {"path": "/server/health", "method": "GET"},
"coolify": {"path": "/api/v1/version", "method": "GET"},
}
def get_credential_fields(plugin_type: str) -> list[dict[str, Any]]:
"""Get credential field definitions for a plugin type.
Args:
plugin_type: Plugin type name.
Returns:
List of field definition dicts.
Raises:
ValueError: If plugin_type is unknown.
"""
fields = PLUGIN_CREDENTIAL_FIELDS.get(plugin_type)
if fields is None:
raise ValueError(
f"Unknown plugin type '{plugin_type}'. "
f"Valid: {list(PLUGIN_CREDENTIAL_FIELDS.keys())}"
)
return fields
def get_user_credential_fields() -> dict[str, list[dict[str, Any]]]:
"""Get credential fields for public (non-admin) users.
Only includes plugins enabled via ENABLED_PLUGINS env var.
Returns:
Filtered dict of plugin_type -> field definitions.
"""
from core.plugin_visibility import get_public_plugin_types
public = get_public_plugin_types()
return {k: v for k, v in PLUGIN_CREDENTIAL_FIELDS.items() if k in public}
def get_user_plugin_names() -> dict[str, str]:
"""Get plugin display names for public (non-admin) users.
Only includes plugins enabled via ENABLED_PLUGINS env var.
Returns:
Filtered dict of plugin_type -> display name.
"""
from core.plugin_visibility import get_public_plugin_types
public = get_public_plugin_types()
return {k: v for k, v in PLUGIN_DISPLAY_NAMES.items() if k in public}
def validate_credentials(plugin_type: str, credentials: dict[str, str]) -> tuple[bool, list[str]]:
"""Validate that all required credential fields are present and non-empty.
Args:
plugin_type: Plugin type name.
credentials: Dict of credential key→value.
Returns:
Tuple of (is_valid, list_of_error_messages).
"""
fields = get_credential_fields(plugin_type)
errors: list[str] = []
for field in fields:
if field["required"]:
value = credentials.get(field["name"], "").strip()
if not value:
errors.append(f"'{field['label']}' is required")
return (len(errors) == 0, errors)
async def validate_site_connection(
plugin_type: str, url: str, credentials: dict[str, str]
) -> tuple[bool, str]:
"""Test connectivity to a site using HTTP health check.
Args:
plugin_type: Plugin type name.
url: Site URL.
credentials: Plaintext credential dict.
Returns:
Tuple of (success, message). Message is "OK" on success or
a human-readable error description.
"""
endpoint_info = _HEALTH_ENDPOINTS.get(plugin_type)
if endpoint_info is None:
return False, f"Unknown plugin type '{plugin_type}'"
check_url = url.rstrip("/") + endpoint_info["path"]
method = endpoint_info["method"]
# Build auth headers per plugin type
headers: dict[str, str] = {}
if plugin_type in ("wordpress", "wordpress_advanced"):
import base64
username = credentials.get("username", "")
app_password = credentials.get("app_password", "")
token = base64.b64encode(f"{username}:{app_password}".encode()).decode()
headers["Authorization"] = f"Basic {token}"
elif plugin_type == "woocommerce":
import base64
ck = credentials.get("consumer_key", "")
cs = credentials.get("consumer_secret", "")
token = base64.b64encode(f"{ck}:{cs}".encode()).decode()
headers["Authorization"] = f"Basic {token}"
elif plugin_type == "gitea":
headers["Authorization"] = f"token {credentials.get('token', '')}"
elif plugin_type == "n8n":
headers["X-N8N-API-KEY"] = credentials.get("api_key", "")
elif plugin_type == "supabase":
headers["apikey"] = credentials.get("service_role_key", "")
headers["Authorization"] = f"Bearer {credentials.get('service_role_key', '')}"
elif plugin_type == "appwrite":
headers["X-Appwrite-Project"] = credentials.get("project_id", "")
headers["X-Appwrite-Key"] = credentials.get("api_key", "")
elif plugin_type in ("directus", "coolify"):
headers["Authorization"] = f"Bearer {credentials.get('token', '')}"
elif plugin_type == "openpanel":
headers["openpanel-client-id"] = credentials.get("client_id", "")
headers["openpanel-client-secret"] = credentials.get("client_secret", "")
try:
timeout = aiohttp.ClientTimeout(total=15)
async with aiohttp.ClientSession(timeout=timeout) as session:
if method == "POST":
async with session.post(check_url, headers=headers) as resp:
status_code = resp.status
resp_text = await resp.text()
else:
async with session.get(check_url, headers=headers) as resp:
status_code = resp.status
resp_text = await resp.text()
if status_code < 400:
return True, "OK"
elif status_code == 401:
return False, "Authentication failed — check credentials"
elif status_code == 403:
return False, "Access forbidden — check permissions or API may be disabled"
elif status_code == 404:
return False, f"Endpoint not found at {check_url} — check URL"
else:
return False, f"HTTP {status_code}: {resp_text[:200]}"
except aiohttp.ClientConnectorError:
return False, "Connection failed — check URL and ensure the site is reachable"
except TimeoutError:
return False, "Connection timed out (15s) — site may be slow or unreachable"
except aiohttp.InvalidURL:
return False, "Invalid URL protocol — use https:// or http://"
except Exception as e:
return False, f"Connection error: {type(e).__name__}: {e}"
async def create_user_site(
user_id: str,
plugin_type: str,
alias: str,
url: str,
credentials: dict[str, str],
skip_validation: bool = False,
) -> dict[str, Any]:
"""Create a new site for a user.
Validates credentials, tests the connection, encrypts credentials,
and stores in the database.
Args:
user_id: Owner's UUID.
plugin_type: Plugin type name.
alias: User-chosen friendly name.
url: Site URL.
credentials: Plaintext credential dict.
skip_validation: If True, skip connection test (for testing).
Returns:
The created site dict (without decrypted credentials).
Raises:
ValueError: On validation errors (bad plugin type, missing fields,
alias taken, site limit reached, connection failed).
"""
from core.database import get_database
from core.encryption import get_credential_encryption
# Validate plugin type
if plugin_type not in PLUGIN_CREDENTIAL_FIELDS:
raise ValueError(
f"Unknown plugin type '{plugin_type}'. "
f"Valid: {list(PLUGIN_CREDENTIAL_FIELDS.keys())}"
)
# Validate alias format
alias = alias.strip().lower()
if not alias or len(alias) < 2 or len(alias) > 50:
raise ValueError("Alias must be 2-50 characters")
if not alias.replace("-", "").replace("_", "").isalnum():
raise ValueError("Alias may only contain letters, numbers, hyphens, and underscores")
# Validate required credential fields
valid, errors = validate_credentials(plugin_type, credentials)
if not valid:
raise ValueError(f"Missing credentials: {', '.join(errors)}")
db = get_database()
# Check site limit
count = await db.count_sites_by_user(user_id)
if count >= MAX_SITES_PER_USER:
raise ValueError(f"Site limit reached ({MAX_SITES_PER_USER} sites per user)")
# Check alias uniqueness (DB constraint will also catch this)
existing = await db.get_site_by_alias(user_id, alias)
if existing is not None:
raise ValueError(f"Alias '{alias}' is already in use")
# Test connection
status = "active"
status_msg = "Connection verified"
if not skip_validation:
ok, msg = await validate_site_connection(plugin_type, url, credentials)
if not ok:
raise ValueError(f"Connection test failed: {msg}")
# Encrypt credentials
encryptor = get_credential_encryption()
# We need the site_id for encryption, but we don't have it yet.
# Use a pre-generated UUID as the site_id.
import uuid
site_id = str(uuid.uuid4())
encrypted = encryptor.encrypt_credentials(credentials, site_id)
# Store in database — we bypass db.create_site() to use our pre-generated ID
from core.database import _utc_now
now = _utc_now()
await db.execute(
"INSERT INTO sites (id, user_id, plugin_type, alias, url, credentials, "
"status, status_msg, created_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)",
(site_id, user_id, plugin_type, alias, url, encrypted, status, status_msg, now),
)
# Return the created site (without credentials blob)
site = await db.get_site(site_id, user_id)
if site is None:
raise RuntimeError(f"Failed to read back created site {site_id}")
result = dict(site)
result.pop("credentials", None)
logger.info("Created site %s (%s) for user %s", alias, plugin_type, user_id)
return result
async def get_user_sites(user_id: str) -> list[dict[str, Any]]:
"""Get all sites for a user (without credentials).
Args:
user_id: Owner's UUID.
Returns:
List of site dicts.
"""
from core.database import get_database
db = get_database()
sites = await db.get_sites_by_user(user_id)
# Strip credentials blob from response
return [{k: v for k, v in site.items() if k != "credentials"} for site in sites]
async def get_user_site(site_id: str, user_id: str) -> dict[str, Any] | None:
"""Get a single site (without credentials).
Args:
site_id: Site UUID.
user_id: Owner's UUID.
Returns:
Site dict or None.
"""
from core.database import get_database
db = get_database()
site = await db.get_site(site_id, user_id)
if site is None:
return None
result = dict(site)
result.pop("credentials", None)
return result
async def delete_user_site(site_id: str, user_id: str) -> bool:
"""Delete a user's site.
Args:
site_id: Site UUID.
user_id: Owner's UUID.
Returns:
True if deleted, False if not found.
"""
from core.database import get_database
db = get_database()
deleted = await db.delete_site(site_id, user_id)
if deleted:
logger.info("Deleted site %s for user %s", site_id, user_id)
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
# ---------------------------------------------------------------------------
# F.5a.9.x: per-site AI provider keys (OpenAI / Stability / Replicate)
# ---------------------------------------------------------------------------
# Only WordPress / WooCommerce sites can host AI image provider keys — the
# only consumer is ``wordpress_generate_and_upload_image``, which uploads into
# a WordPress media library. Other plugin types never surface the section.
PROVIDER_KEYS_ALLOWED_PLUGIN_TYPES: frozenset[str] = frozenset({"wordpress", "woocommerce"})
# Providers supported for per-site keys. Must stay in sync with
# plugins/ai_image/registry.py ``_PROVIDERS`` (order is preserved to
# drive the UI).
SITE_PROVIDERS: tuple[str, ...] = ("openai", "stability", "replicate", "openrouter")
def site_provider_scope(site_id: str, provider: str) -> str:
"""Return the HKDF scope used for per-site provider-key encryption.
Format: ``site_provider:{site_id}:{provider}`` — distinct from the
per-site credentials scope (bare ``site_id``) so the same master key
never produces the same derived key for both credential types.
"""
return f"site_provider:{site_id}:{provider}"
def site_supports_provider_keys(plugin_type: str) -> bool:
"""Return True if the plugin type can hold AI provider keys."""
return plugin_type in PROVIDER_KEYS_ALLOWED_PLUGIN_TYPES
async def set_site_provider_key(
site_id: str,
user_id: str,
provider: str,
api_key: str,
) -> dict[str, Any]:
"""Encrypt and store an AI provider API key for a user-owned site.
Args:
site_id: Site UUID.
user_id: Owner's UUID (enforces tenant isolation).
provider: One of :data:`SITE_PROVIDERS`.
api_key: The plaintext API key to store.
Returns:
The stored row (without plaintext).
Raises:
ValueError: If the site is not found, the plugin type does not
support provider keys, the provider is unknown, or the key
is empty.
"""
from core.database import get_database
from core.encryption import get_credential_encryption
if provider not in SITE_PROVIDERS:
raise ValueError(
f"Unsupported provider '{provider}'. " f"Supported: {', '.join(SITE_PROVIDERS)}"
)
if not api_key or not api_key.strip():
raise ValueError("API key must not be empty")
db = get_database()
site = await db.get_site(site_id, user_id)
if site is None:
raise ValueError("Site not found")
if not site_supports_provider_keys(site["plugin_type"]):
raise ValueError(
f"Plugin type '{site['plugin_type']}' does not support AI "
f"provider keys (only WordPress/WooCommerce)."
)
encryptor = get_credential_encryption()
ciphertext = encryptor.encrypt_for_scope(
api_key.strip(), site_provider_scope(site_id, provider)
)
row = await db.upsert_site_provider_key(site_id, provider, ciphertext)
logger.info("Stored %s provider key for site %s (user %s)", provider, site_id, user_id)
return row
async def get_site_provider_key(
site_id: str,
provider: str,
) -> str | None:
"""Return the decrypted AI provider key for a site, or None.
Caller is responsible for verifying site ownership — this helper is
also called from the AI tool handler which already trusts the
resolved site_id.
Args:
site_id: Site UUID.
provider: Provider identifier.
Returns:
Decrypted API key string, or None if no key is stored.
"""
from core.database import get_database
from core.encryption import get_credential_encryption
if provider not in SITE_PROVIDERS:
return None
db = get_database()
row = await db.get_site_provider_key(site_id, provider)
if row is None:
return None
encryptor = get_credential_encryption()
try:
return encryptor.decrypt_for_scope(
row["key_ciphertext"], site_provider_scope(site_id, provider)
)
except Exception:
logger.exception(
"Failed to decrypt site provider key (site=%s provider=%s)",
site_id,
provider,
)
return None
async def list_site_providers_set(site_id: str) -> set[str]:
"""Return the set of providers that have a key stored for the site."""
from core.database import get_database
db = get_database()
rows = await db.list_site_provider_keys(site_id)
return {row["provider"] for row in rows}
async def delete_site_provider_key(
site_id: str,
user_id: str,
provider: str,
) -> bool:
"""Remove an AI provider key for a site. Returns True if a row was deleted.
Args:
site_id: Site UUID.
user_id: Owner's UUID — enforces tenant isolation before deletion.
provider: Provider identifier.
"""
from core.database import get_database
db = get_database()
site = await db.get_site(site_id, user_id)
if site is None:
return False
deleted = await db.delete_site_provider_key(site_id, provider)
if deleted:
logger.info(
"Deleted %s provider key for site %s (user %s)",
provider,
site_id,
user_id,
)
return deleted
async def test_site_connection(site_id: str, user_id: str) -> tuple[bool, str]:
"""Test connectivity to an existing site.
Decrypts credentials from the database, runs the health check,
and updates the site status.
Args:
site_id: Site UUID.
user_id: Owner's UUID.
Returns:
Tuple of (success, message).
Raises:
ValueError: If site not found.
"""
from core.database import get_database
from core.encryption import get_credential_encryption
db = get_database()
site = await db.get_site(site_id, user_id)
if site is None:
raise ValueError("Site not found")
encryptor = get_credential_encryption()
credentials = encryptor.decrypt_credentials(site["credentials"], site_id)
ok, msg = await validate_site_connection(site["plugin_type"], site["url"], credentials)
# Update status
new_status = "active" if ok else "error"
await db.update_site_status(site_id, new_status, msg, user_id=user_id)
return ok, msg

View File

@@ -4,20 +4,12 @@ Site Manager - Type-safe site configuration management
Manages site configurations with Pydantic validation.
Part of Option B clean architecture refactoring.
Discovers sites from environment variables:
- {PLUGIN_TYPE}_{SITE_ID}_{CONFIG_KEY}
- {PLUGIN_TYPE}_{SITE_ID}_ALIAS (optional)
Example:
WORDPRESS_SITE1_URL=https://example.com
WORDPRESS_SITE1_USERNAME=admin
WORDPRESS_SITE1_APP_PASSWORD=xxxx
WORDPRESS_SITE2_ALIAS=myblog
Sites are managed via the web dashboard and stored in SQLite (DB-based).
The SiteManager provides registration and lookup infrastructure for
plugin tool generation and endpoint routing.
"""
import logging
import os
import re
from typing import Any
from pydantic import BaseModel, ConfigDict, Field, ValidationInfo, field_validator
@@ -50,6 +42,7 @@ class SiteConfig(BaseModel):
site_id: str = Field(..., description="Unique site identifier")
plugin_type: str = Field(..., description="Plugin type (wordpress, gitea, etc)")
alias: str | None = Field(None, description="Friendly alias for the site")
user_id: str | None = Field(None, description="Owner user ID for the site")
# Common config fields (plugins may require additional fields)
url: str | None = Field(None, description="Site URL")
@@ -110,7 +103,8 @@ class SiteManager:
"""
Manage site configurations with type safety.
Discovers, validates, and provides access to site configurations.
Provides registration and lookup of site configurations.
Sites are registered programmatically (e.g., from database) via register_site().
Attributes:
sites: Dictionary mapping plugin_type to site configurations
@@ -119,7 +113,7 @@ class SiteManager:
Examples:
>>> manager = SiteManager()
>>> manager.discover_sites(['wordpress', 'gitea'])
>>> manager.register_site(config)
>>> config = manager.get_site_config('wordpress', 'myblog')
>>> sites = manager.list_sites('wordpress')
"""
@@ -135,167 +129,6 @@ class SiteManager:
self.logger = logging.getLogger("SiteManager")
self.logger.info("SiteManager initialized")
def discover_sites(self, plugin_types: list[str]) -> int:
"""
Discover sites from environment variables.
Scans environment for site configurations and registers them.
Args:
plugin_types: List of plugin types to discover (e.g., ['wordpress'])
Returns:
Number of sites discovered
Examples:
>>> count = manager.discover_sites(['wordpress', 'gitea'])
>>> print(f"Discovered {count} sites")
"""
self.logger.info(f"Starting site discovery for: {', '.join(plugin_types)}")
total_discovered = 0
for plugin_type in plugin_types:
count = self._discover_plugin_sites(plugin_type)
total_discovered += count
self.logger.info(
f"Discovery complete. Found {total_discovered} sites "
f"across {len(plugin_types)} plugin types."
)
return total_discovered
# Reserved words that should NOT be interpreted as site IDs
RESERVED_SITE_WORDS = {
"limit",
"rate",
"config",
"debug",
"log",
"level",
"mode",
"timeout",
"retry",
"max",
"min",
"default",
"global",
"enabled",
"disabled",
"host",
"port",
"path",
"key",
"secret",
"token",
"advanced",
"basic",
"simple",
"pro",
"premium",
"standard",
}
def _discover_plugin_sites(self, plugin_type: str) -> int:
"""
Discover all sites for a specific plugin type.
Args:
plugin_type: Type of plugin (e.g., 'wordpress')
Returns:
Number of sites discovered for this plugin type
Examples:
>>> count = manager._discover_plugin_sites('wordpress')
"""
prefix = plugin_type.upper() + "_"
# Build list of longer prefixes from other plugin types to avoid collisions.
# e.g. WORDPRESS_ must not match WORDPRESS_ADVANCED_ env vars.
from plugins import registry as plugin_registry
all_plugin_types = plugin_registry.get_registered_types()
longer_prefixes = [
pt.upper() + "_"
for pt in all_plugin_types
if pt != plugin_type and pt.upper().startswith(plugin_type.upper() + "_")
]
# Pattern to match: WORDPRESS_SITE1_URL, WORDPRESS_SITE2_USERNAME, etc.
env_pattern = re.compile(f"^{prefix}([A-Z0-9_]+?)_(.+)$")
# Find all unique site IDs
site_ids = set()
for env_key in os.environ.keys():
# Skip env vars that belong to a more specific plugin type
if any(env_key.startswith(lp) for lp in longer_prefixes):
continue
match = env_pattern.match(env_key)
if match:
site_id = match.group(1).lower()
# Skip reserved words that are not real site IDs
if site_id not in self.RESERVED_SITE_WORDS:
site_ids.add(site_id)
# Load configuration for each site
discovered_count = 0
for site_id in site_ids:
try:
config = self._load_site_config(plugin_type, site_id)
if config:
self.register_site(config)
discovered_count += 1
except Exception as e:
self.logger.error(
f"Failed to load {plugin_type} site '{site_id}': {e}", exc_info=True
)
return discovered_count
def _load_site_config(self, plugin_type: str, site_id: str) -> SiteConfig | None:
"""
Load configuration for a site from environment.
Args:
plugin_type: Plugin type
site_id: Site ID
Returns:
SiteConfig if successful, None if incomplete
Examples:
>>> config = manager._load_site_config('wordpress', 'site1')
"""
prefix = f"{plugin_type.upper()}_{site_id.upper()}_"
config_data = {"site_id": site_id, "plugin_type": plugin_type}
# Collect all config keys for this site
for env_key, env_value in os.environ.items():
if env_key.startswith(prefix):
# Extract config key (everything after prefix)
config_key = env_key[len(prefix) :].lower()
config_data[config_key] = env_value
# Must have at least some configuration beyond site_id and plugin_type
if len(config_data) <= 2:
return None
try:
# Create and validate SiteConfig
config = SiteConfig(**config_data)
self.logger.debug(
f"Loaded config for {plugin_type}/{site_id}: " f"{list(config_data.keys())}"
)
return config
except Exception as e:
self.logger.error(f"Validation failed for {plugin_type}/{site_id}: {e}", exc_info=True)
return None
def register_site(self, config: SiteConfig) -> None:
"""
Register a site configuration.
@@ -355,7 +188,7 @@ class SiteManager:
# SECURITY: Don't reveal available plugin types in multi-tenant environment
raise ValueError(
f"No sites configured for plugin type: {plugin_type}. "
f"Please check your environment variables."
f"Please add a site via the dashboard."
)
# Try direct lookup
@@ -372,7 +205,7 @@ class SiteManager:
)
raise ValueError(
f"Site '{site}' not configured for {plugin_type}. "
f"Please verify the site alias/ID and check environment variables."
f"Please verify the site alias/ID in the dashboard."
)
def list_sites(self, plugin_type: str) -> list[str]:
@@ -452,6 +285,7 @@ class SiteManager:
"site_id": config.site_id,
"alias": config.alias,
"full_id": config.get_full_id(),
"user_id": config.user_id,
}
)
@@ -501,22 +335,15 @@ class SiteManager:
>>> suffix = manager.get_effective_path_suffix('wordpress_site1')
>>> print(suffix) # 'myblog' or 'wordpress_site1'
"""
# Parse full_id to get plugin_type and site_id
parts = full_id.split("_", 1)
if len(parts) != 2:
return full_id
# Look up by full_id from registered sites (handles multi-word plugin types)
for info in self.list_all_sites():
if info["full_id"] == full_id:
config = self.sites[info["plugin_type"]].get(info["site_id"])
if config and config.alias and config.alias != config.site_id:
return config.alias
return full_id
plugin_type, site_id = parts
# Try to get config
try:
config = self.get_site_config(plugin_type, site_id)
# If alias exists and is different from site_id, use alias
if config.alias and config.alias != config.site_id:
return config.alias
return full_id
except ValueError:
return full_id
return full_id
def get_alias_conflicts(self) -> dict[str, list[str]]:
"""
@@ -550,7 +377,7 @@ def get_site_manager() -> SiteManager:
Examples:
>>> manager = get_site_manager()
>>> manager.discover_sites(['wordpress'])
>>> manager.register_site(config)
"""
global _site_manager
if _site_manager is None:

View File

@@ -1,371 +0,0 @@
"""
Site Registry
Manages site configurations discovered from environment variables.
Supports multi-site setups with aliases and dynamic discovery.
"""
import logging
import os
import re
from typing import Any
logger = logging.getLogger(__name__)
class SiteInfo:
"""Information about a single site."""
def __init__(
self, plugin_type: str, site_id: str, config: dict[str, Any], alias: str | None = None
):
"""
Initialize site information.
Args:
plugin_type: Type of plugin (e.g., 'wordpress')
site_id: Site identifier (e.g., 'site1')
config: Site configuration from environment
alias: Optional friendly alias for the site
"""
self.plugin_type = plugin_type
self.site_id = site_id
self.config = config
self.alias = alias or site_id
def get_full_id(self) -> str:
"""Get full site identifier: plugin_type_site_id"""
return f"{self.plugin_type}_{self.site_id}"
def get_display_name(self) -> str:
"""Get display name (alias if available, otherwise site_id)"""
return self.alias
def to_dict(self) -> dict[str, Any]:
"""Convert to dictionary for serialization."""
return {
"plugin_type": self.plugin_type,
"site_id": self.site_id,
"alias": self.alias,
"full_id": self.get_full_id(),
"config_keys": list(self.config.keys()),
}
class SiteRegistry:
"""
Registry for managing site configurations across plugin types.
Discovers sites from environment variables:
- {PLUGIN_TYPE}_{SITE_ID}_{CONFIG_KEY}
- {PLUGIN_TYPE}_{SITE_ID}_ALIAS (optional)
Example:
WORDPRESS_SITE1_URL=https://example.com
WORDPRESS_SITE1_USERNAME=admin
WORDPRESS_SITE1_APP_PASSWORD=xxxx
WORDPRESS_SITE2_URL=https://myblog.com
WORDPRESS_SITE2_ALIAS=myblog
"""
def __init__(self):
"""Initialize site registry."""
self.sites: dict[str, SiteInfo] = {} # full_id -> SiteInfo
self.aliases: dict[str, str] = {} # alias -> full_id
self.alias_conflicts: dict[str, list[str]] = {} # alias -> [full_ids that wanted it]
self.logger = logging.getLogger("SiteRegistry")
def discover_sites(self, plugin_types: list[str]) -> None:
"""
Discover sites from environment variables.
Args:
plugin_types: List of plugin types to discover (e.g., ['wordpress'])
"""
self.logger.info("Starting site discovery...")
for plugin_type in plugin_types:
self._discover_plugin_sites(plugin_type)
self.logger.info(
f"Discovery complete. Found {len(self.sites)} sites "
f"with {len(self.aliases)} aliases."
)
# Log alias conflicts if any
if self.alias_conflicts:
self.logger.info("Duplicate alias conflicts detected:")
for alias, full_ids in self.alias_conflicts.items():
winner = self.aliases.get(alias)
losers = [fid for fid in full_ids if fid != winner]
self.logger.info(f" Alias '{alias}': {winner} (winner), {losers} (using full_id)")
# Reserved words that should NOT be interpreted as site IDs
RESERVED_SITE_WORDS = {
"limit",
"rate",
"config",
"debug",
"log",
"level",
"mode",
"timeout",
"retry",
"max",
"min",
"default",
"global",
"enabled",
"disabled",
"host",
"port",
"path",
"key",
"secret",
"token",
"advanced",
"basic",
"simple",
"pro",
"premium",
"standard",
}
def _discover_plugin_sites(self, plugin_type: str) -> None:
"""
Discover all sites for a specific plugin type.
Args:
plugin_type: Type of plugin (e.g., 'wordpress')
"""
prefix = plugin_type.upper() + "_"
# Find all site IDs for this plugin type
site_ids = set()
env_pattern = re.compile(f"^{prefix}([A-Z0-9_]+?)_(.+)$")
for env_key in os.environ.keys():
match = env_pattern.match(env_key)
if match:
site_id = match.group(1).lower()
# Skip reserved words that are not real site IDs
if site_id not in self.RESERVED_SITE_WORDS:
site_ids.add(site_id)
# Create SiteInfo for each discovered site
for site_id in site_ids:
try:
config = self._load_site_config(plugin_type, site_id)
if config:
alias = config.pop("alias", None) # Extract alias if present
site_info = SiteInfo(plugin_type, site_id, config, alias)
full_id = site_info.get_full_id()
self.sites[full_id] = site_info
# Register alias with duplicate detection
if alias:
self._register_alias_safe(alias, full_id)
# Also register with prefix
prefixed_alias = f"{plugin_type}_{alias}"
self._register_alias_safe(prefixed_alias, full_id)
# Always register site_id as alias too (with safe check)
self._register_alias_safe(site_id, full_id)
self.aliases[full_id] = (
full_id # full_id can reference itself (no conflict possible)
)
# Log with alias status
effective_alias = self._get_effective_alias(alias, full_id)
self.logger.info(f"Discovered site: {full_id} " f"(path: {effective_alias})")
except Exception as e:
self.logger.error(
f"Failed to load {plugin_type} site '{site_id}': {e}", exc_info=True
)
def _register_alias_safe(self, alias: str, full_id: str) -> bool:
"""
Register an alias with duplicate detection.
Args:
alias: The alias to register
full_id: The full_id to map the alias to
Returns:
True if alias was registered, False if it was a duplicate
"""
if alias in self.aliases:
existing_full_id = self.aliases[alias]
if existing_full_id != full_id:
# Duplicate alias detected
if alias not in self.alias_conflicts:
self.alias_conflicts[alias] = [existing_full_id]
self.alias_conflicts[alias].append(full_id)
self.logger.info(
f"Duplicate alias '{alias}': {full_id} conflicts with {existing_full_id}. "
f"{full_id} will use full_id for endpoint path."
)
return False
else:
self.aliases[alias] = full_id
return True
def _get_effective_alias(self, alias: str | None, full_id: str) -> str:
"""
Get the effective path suffix for a site.
If the alias was taken by another site, returns full_id.
Otherwise returns the alias (or full_id if no alias).
Args:
alias: The desired alias
full_id: The full site ID
Returns:
The effective path suffix
"""
if alias:
# Check if this site owns the alias
if self.aliases.get(alias) == full_id:
return alias
else:
# Alias was taken, use full_id
return full_id
return full_id
def get_alias_conflicts(self) -> dict[str, list[str]]:
"""
Get all alias conflicts.
Returns:
Dict mapping conflicted aliases to list of full_ids that wanted them
"""
return self.alias_conflicts.copy()
def get_effective_path_suffix(self, full_id: str) -> str:
"""
Get the effective path suffix for a site's endpoint.
Uses alias if available and not conflicted, otherwise full_id.
Args:
full_id: The full site ID
Returns:
Path suffix to use in endpoint URL
"""
site_info = self.sites.get(full_id)
if not site_info:
return full_id
alias = site_info.alias
return self._get_effective_alias(alias, full_id)
def _load_site_config(self, plugin_type: str, site_id: str) -> dict[str, Any] | None:
"""
Load configuration for a site from environment.
Args:
plugin_type: Plugin type
site_id: Site ID
Returns:
Dict with configuration or None if incomplete
"""
prefix = f"{plugin_type.upper()}_{site_id.upper()}_"
config = {}
# Collect all config keys for this site
for env_key, env_value in os.environ.items():
if env_key.startswith(prefix):
# Extract config key (everything after prefix)
config_key = env_key[len(prefix) :].lower()
config[config_key] = env_value
if not config:
return None
self.logger.debug(f"Loaded config for {plugin_type}/{site_id}: {list(config.keys())}")
return config
def get_site(self, plugin_type: str, site_identifier: str) -> SiteInfo | None:
"""
Get site information by ID or alias.
Args:
plugin_type: Plugin type (e.g., 'wordpress')
site_identifier: Site ID, alias, or full_id
Returns:
SiteInfo if found, None otherwise
"""
# Try direct lookup first
full_id = f"{plugin_type}_{site_identifier}"
if full_id in self.sites:
return self.sites[full_id]
# Try alias lookup
if site_identifier in self.aliases:
resolved_full_id = self.aliases[site_identifier]
return self.sites.get(resolved_full_id)
# Try prefixed alias
prefixed = f"{plugin_type}_{site_identifier}"
if prefixed in self.aliases:
resolved_full_id = self.aliases[prefixed]
return self.sites.get(resolved_full_id)
return None
def get_sites_by_type(self, plugin_type: str) -> list[SiteInfo]:
"""
Get all sites of a specific plugin type.
Args:
plugin_type: Plugin type to filter by
Returns:
List of SiteInfo objects
"""
return [
site_info for site_info in self.sites.values() if site_info.plugin_type == plugin_type
]
def list_all_sites(self) -> list[dict[str, Any]]:
"""
List all discovered sites.
Returns:
List of site info dictionaries
"""
return [site_info.to_dict() for site_info in self.sites.values()]
def get_site_options(self, plugin_type: str) -> list[str]:
"""
Get available site options for a plugin type (for schema enum).
Args:
plugin_type: Plugin type
Returns:
List of valid site identifiers (IDs and aliases)
"""
options = set()
for site_info in self.get_sites_by_type(plugin_type):
options.add(site_info.site_id)
if site_info.alias and site_info.alias != site_info.site_id:
options.add(site_info.alias)
return sorted(options)
# Global site registry instance
_site_registry: SiteRegistry | None = None
def get_site_registry() -> SiteRegistry:
"""Get the global site registry instance."""
global _site_registry
if _site_registry is None:
_site_registry = SiteRegistry()
return _site_registry

View File

@@ -0,0 +1,59 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>404 — Page Not Found | MCP Hub</title>
<script src="https://cdn.tailwindcss.com"></script>
<script>
tailwind.config = {
darkMode: 'class',
theme: {
extend: {
colors: {
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-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-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-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">
<svg class="w-4 h-4 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 12l2-2m0 0l7-7 7 7M5 10v10a1 1 0 001 1h3m10-11l2 2m-2-2v10a1 1 0 01-1 1h-3m-6 0a1 1 0 001-1v-4a1 1 0 011-1h2a1 1 0 011 1v4a1 1 0 001 1m-6 0h6"/>
</svg>
Dashboard
</a>
<a href="/health"
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-400 dark:text-gray-600">MCP Hub</p>
</div>
</body>
</html>

View File

@@ -8,7 +8,7 @@
<!-- Header with Create Button -->
<div class="flex flex-wrap items-center justify-between gap-4">
<div>
<p class="text-gray-400 text-sm">
<p class="text-gray-500 dark:text-gray-400 text-sm">
{% if lang == 'fa' %}مدیریت کلیدهای API برای دسترسی به ابزارها{% else %}Manage API keys for tool access{% endif %}
</p>
</div>
@@ -24,16 +24,16 @@
</div>
<!-- Filters -->
<div class="bg-gray-800 rounded-xl border border-gray-700 p-4">
<div class="bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700 p-4">
<form method="GET" class="flex flex-wrap items-center gap-4">
{% if lang and lang != 'en' %}<input type="hidden" name="lang" value="{{ lang }}">{% endif %}
<!-- Project Filter -->
<div class="flex items-center gap-2">
<label class="text-sm text-gray-400">{{ t.filter }}:</label>
<label class="text-sm text-gray-500 dark:text-gray-400">{{ t.filter }}:</label>
<select
name="project"
class="bg-gray-700 border border-gray-600 rounded-lg px-3 py-2 text-white text-sm focus:ring-2 focus:ring-primary-500 focus:border-transparent"
class="bg-gray-50 dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-lg px-3 py-2 text-gray-900 dark:text-white text-sm focus:ring-2 focus:ring-primary-500 focus:border-transparent"
onchange="this.form.submit()"
>
<option value="">{% if lang == 'fa' %}همه پروژه‌ها{% else %}All Projects{% endif %}</option>
@@ -48,10 +48,10 @@
<!-- Status Filter -->
<div class="flex items-center gap-2">
<label class="text-sm text-gray-400">{% if lang == 'fa' %}وضعیت:{% else %}Status:{% endif %}</label>
<label class="text-sm text-gray-500 dark:text-gray-400">{% if lang == 'fa' %}وضعیت:{% else %}Status:{% endif %}</label>
<select
name="status"
class="bg-gray-700 border border-gray-600 rounded-lg px-3 py-2 text-white text-sm focus:ring-2 focus:ring-primary-500 focus:border-transparent"
class="bg-gray-50 dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-lg px-3 py-2 text-gray-900 dark:text-white text-sm focus:ring-2 focus:ring-primary-500 focus:border-transparent"
onchange="this.form.submit()"
>
<option value="active" {% if selected_status == 'active' %}selected{% endif %}>{% if lang == 'fa' %}فعال{% else %}Active{% endif %}</option>
@@ -67,7 +67,7 @@
name="search"
value="{{ search_query }}"
placeholder="{% if lang == 'fa' %}جستجو در توضیحات...{% else %}Search description...{% endif %}"
class="w-full bg-gray-700 border border-gray-600 rounded-lg px-4 py-2 text-white text-sm placeholder-gray-400 focus:ring-2 focus:ring-primary-500 focus:border-transparent"
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 text-sm placeholder-gray-400 focus:ring-2 focus:ring-primary-500 focus:border-transparent"
>
</div>
@@ -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-700 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 %}
@@ -84,42 +84,42 @@
</div>
<!-- API Keys Table -->
<div class="bg-gray-800 rounded-xl border border-gray-700 overflow-hidden">
<div class="bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700 overflow-hidden">
<div class="overflow-x-auto">
<table class="w-full">
<thead class="bg-gray-700/50">
<thead class="bg-gray-50 dark:bg-gray-700/50">
<tr>
<th class="px-6 py-4 text-{% if lang == 'fa' %}right{% else %}left{% endif %} text-sm font-medium text-gray-300">
<th class="px-6 py-4 text-{% if lang == 'fa' %}right{% else %}left{% endif %} text-sm font-medium text-gray-700 dark:text-gray-300">
{% if lang == 'fa' %}شناسه کلید{% else %}Key ID{% endif %}
</th>
<th class="px-6 py-4 text-{% if lang == 'fa' %}right{% else %}left{% endif %} text-sm font-medium text-gray-300">
<th class="px-6 py-4 text-{% if lang == 'fa' %}right{% else %}left{% endif %} text-sm font-medium text-gray-700 dark:text-gray-300">
{% if lang == 'fa' %}پروژه{% else %}Project{% endif %}
</th>
<th class="px-6 py-4 text-{% if lang == 'fa' %}right{% else %}left{% endif %} text-sm font-medium text-gray-300">
<th class="px-6 py-4 text-{% if lang == 'fa' %}right{% else %}left{% endif %} text-sm font-medium text-gray-700 dark:text-gray-300">
{% if lang == 'fa' %}دسترسی{% else %}Scope{% endif %}
</th>
<th class="px-6 py-4 text-{% if lang == 'fa' %}right{% else %}left{% endif %} text-sm font-medium text-gray-300">
<th class="px-6 py-4 text-{% if lang == 'fa' %}right{% else %}left{% endif %} text-sm font-medium text-gray-700 dark:text-gray-300">
{% if lang == 'fa' %}توضیحات{% else %}Description{% endif %}
</th>
<th class="px-6 py-4 text-{% if lang == 'fa' %}right{% else %}left{% endif %} text-sm font-medium text-gray-300">
<th class="px-6 py-4 text-{% if lang == 'fa' %}right{% else %}left{% endif %} text-sm font-medium text-gray-700 dark:text-gray-300">
{% if lang == 'fa' %}وضعیت{% else %}Status{% endif %}
</th>
<th class="px-6 py-4 text-{% if lang == 'fa' %}right{% else %}left{% endif %} text-sm font-medium text-gray-300">
<th class="px-6 py-4 text-{% if lang == 'fa' %}right{% else %}left{% endif %} text-sm font-medium text-gray-700 dark:text-gray-300">
{% if lang == 'fa' %}استفاده{% else %}Usage{% endif %}
</th>
<th class="px-6 py-4 text-{% if lang == 'fa' %}right{% else %}left{% endif %} text-sm font-medium text-gray-300">
<th class="px-6 py-4 text-{% if lang == 'fa' %}right{% else %}left{% endif %} text-sm font-medium text-gray-700 dark:text-gray-300">
{% if lang == 'fa' %}عملیات{% else %}Actions{% endif %}
</th>
</tr>
</thead>
<tbody class="divide-y divide-gray-700">
<tbody class="divide-y divide-gray-200 dark:divide-gray-700">
{% if api_keys %}
{% for key in api_keys %}
<tr class="hover:bg-gray-700/30 transition-colors {% if key.revoked %}opacity-50{% endif %}">
<tr class="hover:bg-gray-50 dark:hover:bg-gray-700/30 transition-colors {% if key.revoked %}opacity-50{% endif %}">
<!-- Key ID -->
<td class="px-6 py-4">
<div class="flex items-center">
<span class="text-sm text-gray-300 font-mono">{{ key.key_id[:12] }}...</span>
<span class="text-sm text-gray-700 dark:text-gray-300 font-mono">{{ key.key_id[:12] }}...</span>
<button
onclick="copyToClipboard('{{ key.key_id }}')"
class="{% if lang == 'fa' %}mr-2{% else %}ml-2{% endif %} text-gray-400 hover:text-white transition-colors"
@@ -139,7 +139,7 @@
{% if lang == 'fa' %}همه پروژه‌ها{% else %}All Projects{% endif %}
</span>
{% else %}
<span class="text-sm text-gray-300">{{ key.project_id }}</span>
<span class="text-sm text-gray-700 dark:text-gray-300">{{ key.project_id }}</span>
{% endif %}
</td>
@@ -156,7 +156,7 @@
<!-- Description -->
<td class="px-6 py-4">
<span class="text-sm text-gray-400">{{ key.description or '-' }}</span>
<span class="text-sm text-gray-500 dark:text-gray-400">{{ key.description or '-' }}</span>
</td>
<!-- Status -->
@@ -181,7 +181,7 @@
<!-- Usage -->
<td class="px-6 py-4">
<span class="text-sm text-gray-300">{{ key.usage_count }}</span>
<span class="text-sm text-gray-700 dark:text-gray-300">{{ key.usage_count }}</span>
<span class="text-xs text-gray-500">{% if lang == 'fa' %}بار{% else %}calls{% endif %}</span>
</td>
@@ -201,7 +201,7 @@
{% endif %}
<button
onclick="openDeleteModal('{{ key.key_id }}', '{{ key.description or key.key_id[:12] }}')"
class="p-2 bg-gray-700 hover:bg-gray-600 rounded-lg text-gray-400 hover:text-white transition-colors"
class="p-2 bg-gray-200 dark:bg-gray-700 hover:bg-gray-300 dark:hover:bg-gray-600 rounded-lg text-gray-400 hover:text-white transition-colors"
title="{% if lang == 'fa' %}حذف کلید{% else %}Delete Key{% endif %}"
>
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
@@ -218,7 +218,7 @@
<svg class="w-12 h-12 mx-auto mb-4 text-gray-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 7a2 2 0 012 2m4 0a6 6 0 01-7.743 5.743L11 17H9v2H7v2H4a1 1 0 01-1-1v-2.586a1 1 0 01.293-.707l5.964-5.964A6 6 0 1121 9z"/>
</svg>
<p class="text-gray-400">
<p class="text-gray-500 dark:text-gray-400">
{% if lang == 'fa' %}کلید API یافت نشد{% else %}No API keys found{% endif %}
</p>
<button
@@ -236,8 +236,8 @@
<!-- Pagination -->
{% if total_pages > 1 %}
<div class="px-6 py-4 border-t border-gray-700 flex items-center justify-between">
<p class="text-sm text-gray-400">
<div class="px-6 py-4 border-t border-gray-200 dark:border-gray-700 flex items-center justify-between">
<p class="text-sm text-gray-500 dark:text-gray-400">
{% if lang == 'fa' %}
نمایش {{ ((page_number - 1) * per_page) + 1 }} تا {{ [page_number * per_page, total_count]|min }} از {{ total_count }} کلید
{% else %}
@@ -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-700 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-700 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-700 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 %}
@@ -280,9 +280,9 @@
<!-- Create Key Modal -->
<div id="createModal" class="fixed inset-0 bg-black/50 hidden items-center justify-center z-50">
<div class="bg-gray-800 rounded-xl border border-gray-700 w-full max-w-md mx-4">
<div class="p-6 border-b border-gray-700">
<h3 class="text-lg font-semibold text-white">
<div class="bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700 w-full max-w-md 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' %}ایجاد کلید API جدید{% else %}Create New API Key{% endif %}
</h3>
</div>
@@ -291,13 +291,13 @@
<!-- Project -->
<div>
<label class="block text-sm font-medium text-gray-300 mb-2">
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
{% if lang == 'fa' %}پروژه{% else %}Project{% endif %}
</label>
<select
name="project_id"
required
class="w-full bg-gray-700 border border-gray-600 rounded-lg px-4 py-2 text-white focus:ring-2 focus:ring-primary-500 focus:border-transparent"
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 focus:border-transparent"
>
<option value="*">{% if lang == 'fa' %}همه پروژه‌ها (*){% else %}All Projects (*){% endif %}</option>
{% for project in available_projects %}
@@ -308,46 +308,46 @@
<!-- Scope -->
<div>
<label class="block text-sm font-medium text-gray-300 mb-2">
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
{% if lang == 'fa' %}سطح دسترسی{% else %}Scope{% endif %}
</label>
<div class="space-y-2">
<label class="flex items-center">
<input type="checkbox" name="scope_read" value="read" checked class="bg-gray-700 border-gray-600 rounded text-primary-600 focus:ring-primary-500">
<span class="{% if lang == 'fa' %}mr-2{% else %}ml-2{% endif %} text-sm text-gray-300">read - {% if lang == 'fa' %}خواندن داده‌ها{% else %}Read data{% endif %}</span>
<span class="{% if lang == 'fa' %}mr-2{% else %}ml-2{% endif %} text-sm text-gray-700 dark:text-gray-300">read - {% if lang == 'fa' %}خواندن داده‌ها{% else %}Read data{% endif %}</span>
</label>
<label class="flex items-center">
<input type="checkbox" name="scope_write" value="write" class="bg-gray-700 border-gray-600 rounded text-primary-600 focus:ring-primary-500">
<span class="{% if lang == 'fa' %}mr-2{% else %}ml-2{% endif %} text-sm text-gray-300">write - {% if lang == 'fa' %}نوشتن داده‌ها{% else %}Write data{% endif %}</span>
<span class="{% if lang == 'fa' %}mr-2{% else %}ml-2{% endif %} text-sm text-gray-700 dark:text-gray-300">write - {% if lang == 'fa' %}نوشتن داده‌ها{% else %}Write data{% endif %}</span>
</label>
<label class="flex items-center">
<input type="checkbox" name="scope_admin" value="admin" class="bg-gray-700 border-gray-600 rounded text-primary-600 focus:ring-primary-500">
<span class="{% if lang == 'fa' %}mr-2{% else %}ml-2{% endif %} text-sm text-gray-300">admin - {% if lang == 'fa' %}مدیریت کامل{% else %}Full admin{% endif %}</span>
<span class="{% if lang == 'fa' %}mr-2{% else %}ml-2{% endif %} text-sm text-gray-700 dark:text-gray-300">admin - {% if lang == 'fa' %}مدیریت کامل{% else %}Full admin{% endif %}</span>
</label>
</div>
</div>
<!-- Description -->
<div>
<label class="block text-sm font-medium text-gray-300 mb-2">
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
{% if lang == 'fa' %}توضیحات (اختیاری){% else %}Description (optional){% endif %}
</label>
<input
type="text"
name="description"
placeholder="{% if lang == 'fa' %}مثال: کلید برای CI/CD{% else %}e.g., Key for CI/CD{% endif %}"
class="w-full bg-gray-700 border border-gray-600 rounded-lg px-4 py-2 text-white placeholder-gray-400 focus:ring-2 focus:ring-primary-500 focus:border-transparent"
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 placeholder-gray-400 focus:ring-2 focus:ring-primary-500 focus:border-transparent"
>
</div>
<!-- Expiration -->
<div>
<label class="block text-sm font-medium text-gray-300 mb-2">
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
{% if lang == 'fa' %}انقضا (اختیاری){% else %}Expiration (optional){% endif %}
</label>
<select
name="expires_in_days"
class="w-full bg-gray-700 border border-gray-600 rounded-lg px-4 py-2 text-white focus:ring-2 focus:ring-primary-500 focus:border-transparent"
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 focus:border-transparent"
>
<option value="">{% if lang == 'fa' %}بدون انقضا{% else %}Never expires{% endif %}</option>
<option value="7">{% if lang == 'fa' %}7 روز{% else %}7 days{% endif %}</option>
@@ -357,10 +357,10 @@
</select>
</div>
</form>
<div class="p-6 border-t border-gray-700 flex justify-end gap-3">
<div class="p-6 border-t border-gray-200 dark:border-gray-700 flex justify-end gap-3">
<button
onclick="closeCreateModal()"
class="px-4 py-2 bg-gray-700 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>
@@ -376,9 +376,9 @@
<!-- New Key Modal (shows the actual key) -->
<div id="newKeyModal" class="fixed inset-0 bg-black/50 hidden items-center justify-center z-50">
<div class="bg-gray-800 rounded-xl border border-gray-700 w-full max-w-lg mx-4">
<div class="p-6 border-b border-gray-700">
<h3 class="text-lg font-semibold text-white flex items-center">
<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">
<svg class="w-6 h-6 text-green-400 {% 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="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"/>
</svg>
@@ -403,7 +403,7 @@
type="text"
id="newKeyValue"
readonly
class="flex-1 bg-gray-700 border border-gray-600 rounded-lg px-4 py-2 text-white font-mono text-sm"
class="flex-1 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 font-mono text-sm"
>
<button
onclick="copyNewKey()"
@@ -414,7 +414,7 @@
</div>
</div>
</div>
<div class="p-6 border-t border-gray-700 flex justify-end">
<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-primary-600 hover:bg-primary-700 rounded-lg text-white text-sm transition-colors"
@@ -427,14 +427,14 @@
<!-- Revoke Modal -->
<div id="revokeModal" class="fixed inset-0 bg-black/50 hidden items-center justify-center z-50">
<div class="bg-gray-800 rounded-xl border border-gray-700 w-full max-w-md mx-4">
<div class="p-6 border-b border-gray-700">
<h3 class="text-lg font-semibold text-white">
<div class="bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700 w-full max-w-md 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' %}لغو کلید API{% else %}Revoke API Key{% endif %}
</h3>
</div>
<div class="p-6">
<p class="text-gray-300">
<p class="text-gray-700 dark:text-gray-300">
{% if lang == 'fa' %}
آیا مطمئن هستید که می‌خواهید کلید <span id="revokeKeyName" class="font-mono text-white"></span> را لغو کنید؟
{% else %}
@@ -445,10 +445,10 @@
{% if lang == 'fa' %}این عمل غیرقابل برگشت است و کلید دیگر کار نخواهد کرد.{% else %}This action cannot be undone and the key will stop working immediately.{% endif %}
</p>
</div>
<div class="p-6 border-t border-gray-700 flex justify-end gap-3">
<div class="p-6 border-t border-gray-200 dark:border-gray-700 flex justify-end gap-3">
<button
onclick="closeRevokeModal()"
class="px-4 py-2 bg-gray-700 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>
@@ -464,14 +464,14 @@
<!-- Delete Modal -->
<div id="deleteModal" class="fixed inset-0 bg-black/50 hidden items-center justify-center z-50">
<div class="bg-gray-800 rounded-xl border border-gray-700 w-full max-w-md mx-4">
<div class="p-6 border-b border-gray-700">
<h3 class="text-lg font-semibold text-white">
<div class="bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700 w-full max-w-md 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' %}حذف کلید API{% else %}Delete API Key{% endif %}
</h3>
</div>
<div class="p-6">
<p class="text-gray-300">
<p class="text-gray-700 dark:text-gray-300">
{% if lang == 'fa' %}
آیا مطمئن هستید که می‌خواهید کلید <span id="deleteKeyName" class="font-mono text-white"></span> را حذف کنید؟
{% else %}
@@ -482,10 +482,10 @@
{% if lang == 'fa' %}این عمل غیرقابل برگشت است و تمام سوابق استفاده از این کلید حذف خواهد شد.{% else %}This action cannot be undone and all usage records will be deleted.{% endif %}
</p>
</div>
<div class="p-6 border-t border-gray-700 flex justify-end gap-3">
<div class="p-6 border-t border-gray-200 dark:border-gray-700 flex justify-end gap-3">
<button
onclick="closeDeleteModal()"
class="px-4 py-2 bg-gray-700 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>

View File

@@ -6,16 +6,16 @@
{% block content %}
<div class="space-y-6">
<!-- Filters -->
<div class="bg-gray-800 rounded-xl border border-gray-700 p-4">
<div class="bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700 p-4">
<form method="GET" class="flex flex-wrap items-center gap-4">
{% if lang and lang != 'en' %}<input type="hidden" name="lang" value="{{ lang }}">{% endif %}
<!-- Project Filter -->
<div class="flex items-center gap-2">
<label class="text-sm text-gray-400">{% if lang == 'fa' %}پروژه:{% else %}Project:{% endif %}</label>
<label class="text-sm text-gray-500 dark:text-gray-400">{% if lang == 'fa' %}پروژه:{% else %}Project:{% endif %}</label>
<select
name="project"
class="bg-gray-700 border border-gray-600 rounded-lg px-3 py-2 text-white text-sm focus:ring-2 focus:ring-primary-500 focus:border-transparent"
class="bg-gray-50 dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-lg px-3 py-2 text-gray-900 dark:text-white text-sm focus:ring-2 focus:ring-primary-500 focus:border-transparent"
onchange="this.form.submit()"
>
<option value="">{% if lang == 'fa' %}همه پروژه‌ها{% else %}All Projects{% endif %}</option>
@@ -29,10 +29,10 @@
<!-- Event Type Filter -->
<div class="flex items-center gap-2">
<label class="text-sm text-gray-400">{{ t.filter }}:</label>
<label class="text-sm text-gray-500 dark:text-gray-400">{{ t.filter }}:</label>
<select
name="event_type"
class="bg-gray-700 border border-gray-600 rounded-lg px-3 py-2 text-white text-sm focus:ring-2 focus:ring-primary-500 focus:border-transparent"
class="bg-gray-50 dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-lg px-3 py-2 text-gray-900 dark:text-white text-sm focus:ring-2 focus:ring-primary-500 focus:border-transparent"
onchange="this.form.submit()"
>
<option value="">{% if lang == 'fa' %}همه رویدادها{% else %}All Events{% endif %}</option>
@@ -45,10 +45,10 @@
<!-- Level Filter -->
<div class="flex items-center gap-2">
<label class="text-sm text-gray-400">{% if lang == 'fa' %}سطح:{% else %}Level:{% endif %}</label>
<label class="text-sm text-gray-500 dark:text-gray-400">{% if lang == 'fa' %}سطح:{% else %}Level:{% endif %}</label>
<select
name="level"
class="bg-gray-700 border border-gray-600 rounded-lg px-3 py-2 text-white text-sm focus:ring-2 focus:ring-primary-500 focus:border-transparent"
class="bg-gray-50 dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-lg px-3 py-2 text-gray-900 dark:text-white text-sm focus:ring-2 focus:ring-primary-500 focus:border-transparent"
onchange="this.form.submit()"
>
<option value="">{% if lang == 'fa' %}همه{% else %}All{% endif %}</option>
@@ -60,12 +60,12 @@
<!-- Date Filter -->
<div class="flex items-center gap-2">
<label class="text-sm text-gray-400">{% if lang == 'fa' %}تاریخ:{% else %}Date:{% endif %}</label>
<label class="text-sm text-gray-500 dark:text-gray-400">{% if lang == 'fa' %}تاریخ:{% else %}Date:{% endif %}</label>
<input
type="date"
name="date"
value="{{ selected_date }}"
class="bg-gray-700 border border-gray-600 rounded-lg px-3 py-2 text-white text-sm focus:ring-2 focus:ring-primary-500 focus:border-transparent"
class="bg-gray-50 dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-lg px-3 py-2 text-gray-900 dark:text-white text-sm focus:ring-2 focus:ring-primary-500 focus:border-transparent"
onchange="this.form.submit()"
>
</div>
@@ -77,7 +77,7 @@
name="search"
value="{{ search_query }}"
placeholder="{% if lang == 'fa' %}جستجو...{% else %}Search...{% endif %}"
class="w-full bg-gray-700 border border-gray-600 rounded-lg px-4 py-2 text-white text-sm placeholder-gray-400 focus:ring-2 focus:ring-primary-500 focus:border-transparent"
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 text-sm placeholder-gray-400 focus:ring-2 focus:ring-primary-500 focus:border-transparent"
>
</div>
@@ -86,7 +86,7 @@
</button>
{% if search_query or selected_event_type or selected_level or selected_date or selected_project %}
<a href="/dashboard/audit-logs{% if lang and lang != 'en' %}?lang={{ lang }}{% endif %}" class="px-4 py-2 bg-gray-700 hover:bg-gray-600 rounded-lg text-white text-sm transition-colors">
<a href="/dashboard/audit-logs{% 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">
{{ t['clear'] }}
</a>
{% endif %}
@@ -95,11 +95,11 @@
<!-- Stats Summary -->
<div class="grid grid-cols-1 md:grid-cols-4 gap-4">
<div class="bg-gray-800 rounded-xl border border-gray-700 p-4">
<div class="bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700 p-4">
<div class="flex items-center justify-between">
<div>
<p class="text-sm text-gray-400">{% if lang == 'fa' %}کل رویدادها{% else %}Total Events{% endif %}</p>
<p class="text-2xl font-bold text-white">{{ stats.total }}</p>
<p class="text-sm text-gray-500 dark:text-gray-400">{% if lang == 'fa' %}کل رویدادها{% else %}Total Events{% endif %}</p>
<p class="text-2xl font-bold text-gray-900 dark:text-white">{{ stats.total }}</p>
</div>
<div class="w-10 h-10 bg-blue-500/20 rounded-lg flex items-center justify-center">
<svg class="w-5 h-5 text-blue-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
@@ -108,11 +108,11 @@
</div>
</div>
</div>
<div class="bg-gray-800 rounded-xl border border-gray-700 p-4">
<div class="bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700 p-4">
<div class="flex items-center justify-between">
<div>
<p class="text-sm text-gray-400">{% if lang == 'fa' %}فراخوانی ابزار{% else %}Tool Calls{% endif %}</p>
<p class="text-2xl font-bold text-white">{{ stats.tool_calls }}</p>
<p class="text-sm text-gray-500 dark:text-gray-400">{% if lang == 'fa' %}فراخوانی ابزار{% else %}Tool Calls{% endif %}</p>
<p class="text-2xl font-bold text-gray-900 dark:text-white">{{ stats.tool_calls }}</p>
</div>
<div class="w-10 h-10 bg-purple-500/20 rounded-lg flex items-center justify-center">
<svg class="w-5 h-5 text-purple-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
@@ -121,11 +121,11 @@
</div>
</div>
</div>
<div class="bg-gray-800 rounded-xl border border-gray-700 p-4">
<div class="bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700 p-4">
<div class="flex items-center justify-between">
<div>
<p class="text-sm text-gray-400">{% if lang == 'fa' %}احراز هویت{% else %}Auth Events{% endif %}</p>
<p class="text-2xl font-bold text-white">{{ stats.auth_events }}</p>
<p class="text-sm text-gray-500 dark:text-gray-400">{% if lang == 'fa' %}احراز هویت{% else %}Auth Events{% endif %}</p>
<p class="text-2xl font-bold text-gray-900 dark:text-white">{{ stats.auth_events }}</p>
</div>
<div class="w-10 h-10 bg-green-500/20 rounded-lg flex items-center justify-center">
<svg class="w-5 h-5 text-green-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
@@ -134,11 +134,11 @@
</div>
</div>
</div>
<div class="bg-gray-800 rounded-xl border border-gray-700 p-4">
<div class="bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700 p-4">
<div class="flex items-center justify-between">
<div>
<p class="text-sm text-gray-400">{% if lang == 'fa' %}خطاها{% else %}Errors{% endif %}</p>
<p class="text-2xl font-bold text-white">{{ stats.errors }}</p>
<p class="text-sm text-gray-500 dark:text-gray-400">{% if lang == 'fa' %}خطاها{% else %}Errors{% endif %}</p>
<p class="text-2xl font-bold text-gray-900 dark:text-white">{{ stats.errors }}</p>
</div>
<div class="w-10 h-10 bg-red-500/20 rounded-lg flex items-center justify-center">
<svg class="w-5 h-5 text-red-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
@@ -150,35 +150,35 @@
</div>
<!-- Logs Table -->
<div class="bg-gray-800 rounded-xl border border-gray-700 overflow-hidden">
<div class="bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700 overflow-hidden">
<div class="overflow-x-auto">
<table class="w-full">
<thead class="bg-gray-700/50">
<thead class="bg-gray-50 dark:bg-gray-700/50">
<tr>
<th class="px-6 py-4 text-{% if lang == 'fa' %}right{% else %}left{% endif %} text-sm font-medium text-gray-300">
<th class="px-6 py-4 text-{% if lang == 'fa' %}right{% else %}left{% endif %} text-sm font-medium text-gray-700 dark:text-gray-300">
{% if lang == 'fa' %}زمان{% else %}Timestamp{% endif %}
</th>
<th class="px-6 py-4 text-{% if lang == 'fa' %}right{% else %}left{% endif %} text-sm font-medium text-gray-300">
<th class="px-6 py-4 text-{% if lang == 'fa' %}right{% else %}left{% endif %} text-sm font-medium text-gray-700 dark:text-gray-300">
{% if lang == 'fa' %}نوع{% else %}Type{% endif %}
</th>
<th class="px-6 py-4 text-{% if lang == 'fa' %}right{% else %}left{% endif %} text-sm font-medium text-gray-300">
<th class="px-6 py-4 text-{% if lang == 'fa' %}right{% else %}left{% endif %} text-sm font-medium text-gray-700 dark:text-gray-300">
{% if lang == 'fa' %}سطح{% else %}Level{% endif %}
</th>
<th class="px-6 py-4 text-{% if lang == 'fa' %}right{% else %}left{% endif %} text-sm font-medium text-gray-300">
<th class="px-6 py-4 text-{% if lang == 'fa' %}right{% else %}left{% endif %} text-sm font-medium text-gray-700 dark:text-gray-300">
{% if lang == 'fa' %}رویداد{% else %}Event{% endif %}
</th>
<th class="px-6 py-4 text-{% if lang == 'fa' %}right{% else %}left{% endif %} text-sm font-medium text-gray-300">
<th class="px-6 py-4 text-{% if lang == 'fa' %}right{% else %}left{% endif %} text-sm font-medium text-gray-700 dark:text-gray-300">
{% if lang == 'fa' %}جزئیات{% else %}Details{% endif %}
</th>
</tr>
</thead>
<tbody class="divide-y divide-gray-700">
<tbody class="divide-y divide-gray-200 dark:divide-gray-700">
{% if logs %}
{% for log in logs %}
<tr class="hover:bg-gray-700/30 transition-colors">
<tr class="hover:bg-gray-50 dark:hover:bg-gray-700/30 transition-colors">
<!-- Timestamp -->
<td class="px-6 py-4">
<span class="text-sm text-gray-300 font-mono">{{ log.timestamp[:19] }}</span>
<span class="text-sm text-gray-700 dark:text-gray-300 font-mono">{{ log.timestamp[:19] if log.timestamp else '-' }}</span>
</td>
<!-- Event Type -->
@@ -207,7 +207,7 @@
<!-- Event/Message -->
<td class="px-6 py-4">
<span class="text-sm text-white">{{ log.event or log.message or log.tool_name or '-' }}</span>
<span class="text-sm text-gray-900 dark:text-white">{{ log.event or log.message or log.tool_name or '-' }}</span>
{% if log.project_id %}
<span class="text-xs text-gray-500 block">{{ log.project_id }}</span>
{% endif %}
@@ -237,7 +237,7 @@
<svg class="w-12 h-12 mx-auto mb-4 text-gray-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2"/>
</svg>
<p class="text-gray-400">
<p class="text-gray-500 dark:text-gray-400">
{% if lang == 'fa' %}لاگی یافت نشد{% else %}No logs found{% endif %}
</p>
</td>
@@ -249,8 +249,8 @@
<!-- Pagination -->
{% if total_pages > 1 %}
<div class="px-6 py-4 border-t border-gray-700 flex items-center justify-between">
<p class="text-sm text-gray-400">
<div class="px-6 py-4 border-t border-gray-200 dark:border-gray-700 flex items-center justify-between">
<p class="text-sm text-gray-500 dark:text-gray-400">
{% if lang == 'fa' %}
نمایش {{ ((page_number - 1) * per_page) + 1 }} تا {{ [page_number * per_page, total_count]|min }} از {{ total_count }} لاگ
{% else %}
@@ -261,17 +261,17 @@
<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_event_type %}&event_type={{ selected_event_type }}{% endif %}{% if selected_level %}&level={{ selected_level }}{% endif %}{% if selected_date %}&date={{ selected_date }}{% endif %}{% if search_query %}&search={{ search_query }}{% endif %}{% if lang and lang != 'en' %}&lang={{ lang }}{% endif %}"
class="px-3 py-1.5 bg-gray-700 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-900 dark:text-white transition-colors">
{% if lang == 'fa' %}قبلی{% else %}Previous{% endif %}
</a>
{% endif %}
{% for page_num in range(1, total_pages + 1) %}
{% if page_num == page_number %}
<span class="px-3 py-1.5 bg-primary-600 rounded-lg text-sm text-white">{{ page_num }}</span>
<span class="px-3 py-1.5 bg-primary-600 rounded-lg text-sm text-gray-900 dark: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_event_type %}&event_type={{ selected_event_type }}{% endif %}{% if selected_level %}&level={{ selected_level }}{% endif %}{% if selected_date %}&date={{ selected_date }}{% endif %}{% if search_query %}&search={{ search_query }}{% endif %}{% if lang and lang != 'en' %}&lang={{ lang }}{% endif %}"
class="px-3 py-1.5 bg-gray-700 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-900 dark:text-white transition-colors">
{{ page_num }}
</a>
{% elif page_num == page_number - 3 or page_num == page_number + 3 %}
@@ -281,7 +281,7 @@
{% if page_number < total_pages %}
<a href="?page={{ page_number + 1 }}{% if selected_project %}&project={{ selected_project }}{% endif %}{% if selected_event_type %}&event_type={{ selected_event_type }}{% endif %}{% if selected_level %}&level={{ selected_level }}{% endif %}{% if selected_date %}&date={{ selected_date }}{% endif %}{% if search_query %}&search={{ search_query }}{% endif %}{% if lang and lang != 'en' %}&lang={{ lang }}{% endif %}"
class="px-3 py-1.5 bg-gray-700 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-900 dark:text-white transition-colors">
{% if lang == 'fa' %}بعدی{% else %}Next{% endif %}
</a>
{% endif %}

View File

@@ -0,0 +1,209 @@
<!DOCTYPE html>
<html lang="{{ lang|default('en') }}" {% if lang=='fa' %}dir="rtl" {% endif %}>
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Login - MCP Hub</title>
<link rel="icon" type="image/svg+xml" href="/static/logo.svg">
{% include "dashboard/partials/head_assets.html" %}
<style>
.login-gradient {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
}
.card-shadow {
box-shadow: 0 25px 50px -12px rgba(0, 0, 0, 0.5);
}
.btn-github {
background: #24292e;
}
.btn-github:hover {
background: #2f363d;
transform: translateY(-2px);
box-shadow: 0 10px 20px rgba(36, 41, 46, 0.4);
}
.btn-google {
background: #4285f4;
}
.btn-google:hover {
background: #3367d6;
transform: translateY(-2px);
box-shadow: 0 10px 20px rgba(66, 133, 244, 0.4);
}
.btn-admin {
border: 1px solid #4b5563;
}
.btn-admin:hover {
background: #374151;
transform: translateY(-1px);
}
.animate-float {
animation: float 6s ease-in-out infinite;
}
@keyframes float {
0%,
100% {
transform: translateY(0px);
}
50% {
transform: translateY(-20px);
}
}
.transition-all {
transition: all 0.3s ease;
}
</style>
</head>
<body class="bg-gray-900 min-h-screen flex items-center justify-center p-4">
<!-- Background decoration -->
<div class="absolute inset-0 overflow-hidden pointer-events-none">
<div class="absolute -top-40 -right-40 w-80 h-80 bg-purple-500/20 rounded-full blur-3xl animate-float"></div>
<div class="absolute -bottom-40 -left-40 w-80 h-80 bg-blue-500/20 rounded-full blur-3xl animate-float"
style="animation-delay: -3s;"></div>
</div>
<div class="relative w-full max-w-md">
<div class="card-shadow bg-gray-800 rounded-2xl p-8">
<!-- Logo -->
<div class="text-center mb-8">
<div class="mx-auto w-16 h-16 login-gradient rounded-2xl flex items-center justify-center mb-4">
<img src="/static/logo.svg" alt="MCP Hub Logo"
class="w-10 h-10 object-contain drop-shadow-[0_0_8px_rgba(255,255,255,0.8)]">
</div>
<h1 class="text-2xl font-bold text-white">MCP Hub</h1>
<p class="text-gray-400 mt-2" {% if lang=='fa' %}dir="rtl" {% endif %}>
{% if lang == 'fa' %}برای مدیریت سایت‌هایتان وارد شوید{% else %}Sign in to manage your sites{% endif
%}
</p>
</div>
<!-- Error Message -->
{% if error %}
<div class="mb-6 p-4 bg-red-500/20 border border-red-500/50 rounded-lg">
<div class="flex items-center">
<svg class="w-5 h-5 text-red-400 {% if lang == 'fa' %}ml-2{% else %}mr-2{% endif %} flex-shrink-0"
fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
<span class="text-red-400 text-sm">
{% if error == 'rate_limit' %}
{% if lang == 'fa' %}تلاش‌های ثبت‌نام زیاد. لطفاً بعداً تلاش کنید.{% else %}Too many
registration attempts. Please try again later.{% endif %}
{% elif error == 'oauth_denied' %}
{% if lang == 'fa' %}احراز هویت لغو یا رد شد.{% else %}Authentication was cancelled or denied.{%
endif %}
{% elif error == 'no_email' %}
{% if lang == 'fa' %}آدرس ایمیل شما قابل دریافت نبود.{% else %}Could not retrieve your email
address. Please check your provider settings.{% endif %}
{% elif error == 'invalid_state' %}
{% if lang == 'fa' %}نشست منقضی شده. لطفاً دوباره تلاش کنید.{% else %}Session expired. Please
try again.{% endif %}
{% elif error == 'exchange_failed' %}
{% if lang == 'fa' %}احراز هویت ناموفق. لطفاً دوباره تلاش کنید.{% else %}Authentication failed.
Please try again.{% endif %}
{% elif error == 'provider_unavailable' %}
{% if lang == 'fa' %}این ارائه‌دهنده ورود در دسترس نیست.{% else %}This login provider is not
available.{% endif %}
{% else %}
{% if lang == 'fa' %}خطایی رخ داد. لطفاً دوباره تلاش کنید.{% else %}An error occurred. Please
try again.{% endif %}
{% endif %}
</span>
</div>
</div>
{% endif %}
<!-- OAuth Buttons -->
<div class="space-y-3">
{% if "github" in providers %}
<a href="/auth/github"
class="btn-github transition-all flex items-center justify-center w-full py-3 px-4 text-white font-semibold rounded-lg">
<svg class="w-5 h-5 {% if lang == 'fa' %}ml-3{% else %}mr-3{% endif %}" fill="currentColor"
viewBox="0 0 24 24">
<path
d="M12 0c-6.626 0-12 5.373-12 12 0 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.23.957-.266 1.983-.399 3.003-.404 1.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.576 4.765-1.589 8.199-6.086 8.199-11.386 0-6.627-5.373-12-12-12z" />
</svg>
{% if lang == 'fa' %}ورود با GitHub{% else %}Continue with GitHub{% endif %}
</a>
{% endif %}
{% if "google" in providers %}
<a href="/auth/google"
class="btn-google transition-all flex items-center justify-center w-full py-3 px-4 text-white font-semibold rounded-lg">
<svg class="w-5 h-5 {% if lang == 'fa' %}ml-3{% else %}mr-3{% endif %}" viewBox="0 0 24 24">
<path
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.09z"
fill="#fff" />
<path
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"
fill="#fff" fill-opacity="0.8" />
<path
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"
fill="#fff" fill-opacity="0.6" />
<path
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"
fill="#fff" fill-opacity="0.9" />
</svg>
{% if lang == 'fa' %}ورود با Google{% else %}Continue with Google{% endif %}
</a>
{% endif %}
{% if not providers %}
<div class="text-center p-4 bg-yellow-500/10 border border-yellow-500/30 rounded-lg">
<p class="text-yellow-400 text-sm">
{% if lang == 'fa' %}هیچ ارائه‌دهنده OAuth پیکربندی نشده.{% else %}No OAuth providers
configured. Set GITHUB_CLIENT_ID/SECRET or GOOGLE_CLIENT_ID/SECRET in your environment.{% endif
%}
</p>
</div>
{% endif %}
</div>
<!-- Divider + Admin Login Link (hidden when DISABLE_MASTER_KEY_LOGIN=true) -->
{% if not master_login_disabled %}
<div class="my-6 flex items-center">
<div class="flex-1 border-t border-gray-600"></div>
<span class="px-4 text-sm text-gray-400">
{% if lang == 'fa' %}یا{% else %}or{% endif %}
</span>
<div class="flex-1 border-t border-gray-600"></div>
</div>
<a href="/dashboard/login{% if lang and lang != 'en' %}?lang={{ lang }}{% endif %}"
class="btn-admin transition-all flex items-center justify-center w-full py-3 px-4 text-gray-300 font-medium rounded-lg text-sm">
<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="M15 7a2 2 0 012 2m4 0a6 6 0 01-7.743 5.743L11 17H9v2H7v2H4a1 1 0 01-1-1v-2.586a1 1 0 01.293-.707l5.964-5.964A6 6 0 1121 9z" />
</svg>
{% if lang == 'fa' %}ورود مدیر با کلید API{% else %}Admin Login with API Key{% endif %}
</a>
{% endif %}
<!-- Language Toggle -->
<div class="mt-6 text-center">
<a href="?lang={% if lang == 'fa' %}en{% else %}fa{% endif %}"
class="text-sm text-gray-400 hover:text-white transition-colors">
{% if lang == 'fa' %}English{% else %}فارسی{% endif %}
</a>
</div>
</div>
<p class="text-center text-gray-500 text-sm mt-6">MCP Hub v{{ version }}</p>
</div>
</body>
</html>

View File

@@ -1,69 +1,44 @@
<!DOCTYPE html>
<html lang="{{ lang|default('en') }}" {% if lang == 'fa' %}dir="rtl"{% endif %}>
<html lang="{{ lang|default('en') }}" {% if lang=='fa' %}dir="rtl" {% endif %}>
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{% block title %}{{ t.dashboard }}{% endblock %} - MCP Hub</title>
<link rel="icon" type="image/svg+xml" href="/static/logo.svg">
<!-- Vazirmatn Font for Persian -->
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Vazirmatn:wght@100;200;300;400;500;600;700;800;900&display=swap" rel="stylesheet">
<!-- Tailwind CSS -->
<script src="https://cdn.tailwindcss.com"></script>
<!-- HTMX for dynamic updates -->
<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>
<!-- Custom Tailwind Config -->
<script>
tailwind.config = {
darkMode: 'class',
theme: {
extend: {
fontFamily: {
'vazirmatn': ['Vazirmatn', 'system-ui', 'sans-serif'],
},
colors: {
primary: {
50: '#f5f3ff',
100: '#ede9fe',
200: '#ddd6fe',
300: '#c4b5fd',
400: '#a78bfa',
500: '#8b5cf6',
600: '#7c3aed',
700: '#6d28d9',
800: '#5b21b6',
900: '#4c1d95',
}
}
}
}
}
</script>
{% include "dashboard/partials/head_assets.html" %}
<style>
[x-cloak] { display: none !important; }
/* Custom scrollbar */
/* Custom scrollbar — light mode */
::-webkit-scrollbar {
width: 8px;
height: 8px;
}
::-webkit-scrollbar-track {
background: #1f2937;
background: #f3f4f6;
}
::-webkit-scrollbar-thumb {
background: #4b5563;
background: #d1d5db;
border-radius: 4px;
}
::-webkit-scrollbar-thumb:hover {
background: #9ca3af;
}
/* Custom scrollbar — dark mode */
.dark ::-webkit-scrollbar-track {
background: #1f2937;
}
.dark ::-webkit-scrollbar-thumb {
background: #4b5563;
}
.dark ::-webkit-scrollbar-thumb:hover {
background: #6b7280;
}
@@ -76,6 +51,7 @@
.card-hover {
transition: transform 0.2s ease, box-shadow 0.2s ease;
}
.card-hover:hover {
transform: translateY(-2px);
box-shadow: 0 10px 25px -5px rgba(0, 0, 0, 0.3);
@@ -85,9 +61,17 @@
.status-pulse {
animation: pulse 2s cubic-bezier(0.4, 0, 0.6, 1) infinite;
}
@keyframes pulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.5; }
0%,
100% {
opacity: 1;
}
50% {
opacity: 0.5;
}
}
/* RTL adjustments */
@@ -95,80 +79,171 @@
margin-left: 0.75rem;
margin-right: 0;
}
[dir="ltr"] .sidebar-icon {
margin-right: 0.75rem;
margin-left: 0;
}
/* Vazirmatn font for Persian */
[dir="rtl"],
[dir="rtl"] * {
font-family: 'Vazirmatn', system-ui, sans-serif !important;
}
</style>
<meta name="csrf-token" content="{{ request.state.csrf_token }}">
{% block extra_head %}{% endblock %}
</head>
<body class="bg-gray-900 text-gray-100 min-h-screen" x-data="{ sidebarOpen: true, darkMode: true }">
{# ── Derive RBAC info from session ── #}
{% set _is_admin = is_admin_session(session) %}
{% set _is_oauth_admin = _is_admin and session is mapping %}
{% set _display = get_session_display_info(session) %}
<body class="bg-gray-50 text-gray-900 dark:bg-gray-900 dark:text-gray-100 min-h-screen transition-colors duration-200"
x-data="{ sidebarOpen: true, darkMode: localStorage.theme === 'dark' || (!('theme' in localStorage) && window.matchMedia('(prefers-color-scheme: dark)').matches) }"
x-init="$watch('darkMode', val => {
if(val) { document.documentElement.classList.add('dark'); localStorage.theme = 'dark'; }
else { document.documentElement.classList.remove('dark'); localStorage.theme = 'light'; }
})">
<div class="flex h-screen overflow-hidden">
<!-- Sidebar -->
<aside
class="sidebar-transition bg-gray-800 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-700">
<div class="flex items-center justify-between h-16 px-4 border-b border-gray-200 dark:border-gray-700">
<div class="flex items-center" x-show="sidebarOpen">
<img src="/static/logo.svg" alt="MCP Hub" class="w-8 h-8">
<span class="{% if lang == 'fa' %}mr-3{% else %}ml-3{% endif %} font-bold text-lg">MCP Hub</span>
<span class="{% if lang == 'fa' %}mr-3{% else %}ml-3{% endif %} font-bold text-lg text-gray-900 dark:text-white">MCP Hub</span>
</div>
<div x-show="!sidebarOpen" class="flex items-center justify-center">
<img src="/static/logo.svg" alt="MCP Hub" class="w-8 h-8">
</div>
<button
@click="sidebarOpen = !sidebarOpen"
class="p-2 rounded-lg hover:bg-gray-700 transition-colors"
>
<button @click="sidebarOpen = !sidebarOpen" class="p-2 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors text-gray-600 dark:text-gray-300">
<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="M4 6h16M4 12h16M4 18h16"/>
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M4 6h16M4 12h16M4 18h16" />
</svg>
</button>
</div>
<!-- Navigation -->
<nav class="flex-1 px-2 py-4 space-y-1 overflow-y-auto">
{% set nav_items = [
('dashboard', t.dashboard, 'M3 12l2-2m0 0l7-7 7 7M5 10v10a1 1 0 001 1h3m10-11l2 2m-2-2v10a1 1 0 01-1 1h-3m-6 0a1 1 0 001-1v-4a1 1 0 011-1h2a1 1 0 011 1v4a1 1 0 001 1m-6 0h6', '/dashboard'),
('projects', t.projects, 'M3 7v10a2 2 0 002 2h14a2 2 0 002-2V9a2 2 0 00-2-2h-6l-2-2H5a2 2 0 00-2 2z', '/dashboard/projects'),
('api_keys', t.api_keys, 'M15 7a2 2 0 012 2m4 0a6 6 0 01-7.743 5.743L11 17H9v2H7v2H4a1 1 0 01-1-1v-2.586a1 1 0 01.293-.707l5.964-5.964A6 6 0 1121 9z', '/dashboard/api-keys'),
('oauth_clients', t.oauth_clients, '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', '/dashboard/oauth-clients'),
('audit_logs', t.audit_logs, 'M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2m-3 7h3m-3 4h3m-6-4h.01M9 16h.01', '/dashboard/audit-logs'),
('health', t.health, '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', '/dashboard/health'),
('settings', t.settings, 'M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z M15 12a3 3 0 11-6 0 3 3 0 016 0z', '/dashboard/settings'),
{# ── Common nav items (all users) ── #}
{% set common_nav = [
('dashboard', t.dashboard, 'M3 12l2-2m0 0l7-7 7 7M5 10v10a1 1 0 001 1h3m10-11l2 2m-2-2v10a1 1 0 01-1
1h-3m-6 0a1 1 0 001-1v-4a1 1 0 011-1h2a1 1 0 011 1v4a1 1 0 001 1m-6 0h6', '/dashboard'),
] %}
{% for item_id, label, icon_path, url in nav_items %}
<a
href="{{ url }}{% if lang and lang != 'en' %}?lang={{ lang }}{% endif %}"
class="flex items-center px-3 py-2 rounded-lg transition-colors {% if current_page == item_id %}bg-primary-600 text-white{% else %}text-gray-300 hover:bg-gray-700 hover:text-white{% endif %}"
>
<svg class="w-5 h-5 sidebar-icon flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="{{ icon_path }}"/>
{# ── User-only nav items ── #}
{% set user_nav = [
('my_sites', t.get('my_sites', 'My Sites'), 'M21 12a9 9 0 01-9 9m9-9a9 9 0 00-9-9m9 9H3m9 9a9 9 0
01-9-9m9 9c1.657 0 3-4.03 3-9s-1.343-9-3-9m0 18c-1.657 0-3-4.03-3-9s1.343-9 3-9m-9 9a9 9 0 019-9',
'/dashboard/sites'),
('services', t.get('services', 'Services'), 'M19 11H5m14 0a2 2 0 012 2v6a2 2 0 01-2 2H5a2 2 0 01-2-2v-6a2 2 0 012-2m14 0V9a2 2 0 00-2-2M5 11V9a2 2 0 012-2m0 0V5a2 2 0 012-2h6a2 2 0 012 2v2M7 7h10', '/dashboard/services'),
('keys', t.get('keys', 'API Keys'), 'M15 7a2 2 0 012 2m4 0a6 6 0 01-7.743 5.743L11 17H9v2H7v2H4a1 1 0
01-1-1v-2.586a1 1 0 01.293-.707l5.964-5.964A6 6 0 1121 9z', '/dashboard/keys'),
('oauth_clients', t.oauth_clients, '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', '/dashboard/oauth-clients'),
] %}
{# ── Admin-only nav items ── #}
{% set admin_nav = [
('projects', t.projects, 'M3 7v10a2 2 0 002 2h14a2 2 0 002-2V9a2 2 0 00-2-2h-6l-2-2H5a2 2 0 00-2 2z',
'/dashboard/projects'),
('api_keys', t.api_keys, 'M15 7a2 2 0 012 2m4 0a6 6 0 01-7.743 5.743L11 17H9v2H7v2H4a1 1 0
01-1-1v-2.586a1 1 0 01.293-.707l5.964-5.964A6 6 0 1121 9z', '/dashboard/api-keys'),
('audit_logs', t.audit_logs, 'M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2
2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2m-3 7h3m-3 4h3m-6-4h.01M9 16h.01',
'/dashboard/audit-logs'),
('health', t.health, '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', '/dashboard/health'),
('settings', t.settings, 'M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573
1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724
0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35
0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0
00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31
2.37-2.37.996.608 2.296.07 2.572-1.065z M15 12a3 3 0 11-6 0 3 3 0 016 0z', '/dashboard/settings'),
] %}
{# Render common nav #}
{% for item_id, label, icon_path, url in common_nav %}
<a href="{{ url }}{% if lang and lang != 'en' %}?lang={{ lang }}{% endif %}"
class="flex items-center px-3 py-2 rounded-lg transition-colors {% if current_page == item_id %}bg-primary-600 text-white{% else %}text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700 hover:text-gray-900 dark:hover:text-white{% endif %}">
<svg class="w-5 h-5 sidebar-icon flex-shrink-0" fill="none" stroke="currentColor"
viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="{{ icon_path }}" />
</svg>
<span x-show="sidebarOpen" class="truncate">{{ label }}</span>
</a>
{% endfor %}
{# Render user nav (non-admin users + OAuth admins) #}
{% if not _is_admin or _is_oauth_admin %}
{% for item_id, label, icon_path, url in user_nav %}
<a href="{{ url }}{% if lang and lang != 'en' %}?lang={{ lang }}{% endif %}"
class="flex items-center px-3 py-2 rounded-lg transition-colors {% if current_page == item_id %}bg-primary-600 text-white{% else %}text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700 hover:text-gray-900 dark:hover:text-white{% endif %}">
<svg class="w-5 h-5 sidebar-icon flex-shrink-0" fill="none" stroke="currentColor"
viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="{{ icon_path }}" />
</svg>
<span x-show="sidebarOpen" class="truncate">{{ label }}</span>
</a>
{% endfor %}
{% endif %}
{# Render admin nav (admin only) #}
{% if _is_admin %}
<div class="pt-3 mt-3 border-t border-gray-200 dark:border-gray-700">
<p x-show="sidebarOpen" class="px-3 mb-2 text-xs font-semibold uppercase tracking-wider text-gray-400 dark:text-gray-500">
{% if lang == 'fa' %}مدیریت{% else %}Administration{% endif %}
</p>
</div>
{% for item_id, label, icon_path, url in admin_nav %}
<a href="{{ url }}{% if lang and lang != 'en' %}?lang={{ lang }}{% endif %}"
class="flex items-center px-3 py-2 rounded-lg transition-colors {% if current_page == item_id %}bg-primary-600 text-white{% else %}text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700 hover:text-gray-900 dark:hover:text-white{% endif %}">
<svg class="w-5 h-5 sidebar-icon flex-shrink-0" fill="none" stroke="currentColor"
viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="{{ icon_path }}" />
</svg>
<span x-show="sidebarOpen" class="truncate">{{ label }}</span>
</a>
{% endfor %}
{% endif %}
</nav>
<!-- Support link -->
<div x-show="sidebarOpen" class="px-4 py-2">
<a href="https://nowpayments.io/donation/airano" target="_blank" rel="noopener noreferrer"
class="flex items-center text-xs text-gray-400 dark:text-gray-500 hover:text-pink-400 dark:hover:text-pink-400 transition-colors">
<svg class="w-4 h-4 mr-1.5 flex-shrink-0" 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>
{% if lang == 'fa' %}حمایت از MCP Hub{% else %}Support MCP Hub{% endif %}
</a>
</div>
<!-- Version footer -->
{% if project_version %}
<div x-show="sidebarOpen" class="px-4 py-2 text-xs text-gray-400 dark:text-gray-600">
v{{ project_version }}
</div>
{% endif %}
<!-- User Section -->
<div class="p-4 border-t border-gray-700">
<a
href="/dashboard/logout"
class="flex items-center px-3 py-2 text-gray-300 hover:bg-gray-700 hover:text-white rounded-lg transition-colors"
>
<svg class="w-5 h-5 sidebar-icon flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M17 16l4-4m0 0l-4-4m4 4H7m6 4v1a3 3 0 01-3 3H6a3 3 0 01-3-3V7a3 3 0 013-3h4a3 3 0 013 3v1"/>
<div class="p-4 border-t border-gray-200 dark:border-gray-700">
{# Profile link for OAuth users (including OAuth admins) #}
{% if not _is_admin or _is_oauth_admin %}
<a href="/dashboard/profile{% if lang and lang != 'en' %}?lang={{ lang }}{% endif %}"
class="flex items-center px-3 py-2 text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700 hover:text-gray-900 dark:hover:text-white rounded-lg transition-colors mb-1 {% if current_page == 'profile' %}bg-primary-600 text-white{% endif %}">
<svg class="w-5 h-5 sidebar-icon flex-shrink-0" fill="none" stroke="currentColor"
viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z" />
</svg>
<span x-show="sidebarOpen">{{ t.get('profile', 'Profile') }}</span>
</a>
{% endif %}
<a href="/dashboard/logout"
class="flex items-center px-3 py-2 text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700 hover:text-gray-900 dark:hover:text-white rounded-lg transition-colors">
<svg class="w-5 h-5 sidebar-icon flex-shrink-0" fill="none" stroke="currentColor"
viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M17 16l4-4m0 0l-4-4m4 4H7m6 4v1a3 3 0 01-3 3H6a3 3 0 01-3-3V7a3 3 0 013-3h4a3 3 0 013 3v1" />
</svg>
<span x-show="sidebarOpen">{{ t.logout }}</span>
</a>
@@ -176,39 +251,57 @@
</aside>
<!-- Main Content -->
<main class="flex-1 overflow-y-auto bg-gray-900">
<main class="flex-1 overflow-y-auto bg-gray-100 dark:bg-gray-900">
<!-- Top Header -->
<header class="sticky top-0 z-10 bg-gray-800/95 backdrop-blur border-b border-gray-700">
<header class="sticky top-0 z-10 bg-white/95 dark:bg-gray-800/95 backdrop-blur border-b border-gray-200 dark:border-gray-700">
<div class="flex items-center justify-between h-16 px-6">
<h1 class="text-xl font-semibold">{% block page_title %}{{ t.dashboard }}{% endblock %}</h1>
<h1 class="text-xl font-semibold text-gray-900 dark:text-white">{% block page_title %}{{ t.dashboard }}{% endblock %}</h1>
<div class="flex items-center space-x-4 {% if lang == 'fa' %}space-x-reverse{% endif %}">
<!-- Dark Mode Toggle -->
<button @click="darkMode = !darkMode"
class="p-2 text-gray-500 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white hover:bg-gray-200 dark:hover:bg-gray-700 rounded-lg transition-colors"
title="Toggle Dark Mode">
<svg x-show="darkMode" 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="M12 3v1m0 16v1m9-9h-1M4 12H3m15.364 6.364l-.707-.707M6.343 6.343l-.707-.707m12.728 0l-.707.707M6.343 17.657l-.707.707M16 12a4 4 0 11-8 0 4 4 0 018 0z" />
</svg>
<svg x-show="!darkMode" 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="M20.354 15.354A9 9 0 018.646 3.646 9.003 9.003 0 0012 21a9.003 9.003 0 008.354-5.646z" />
</svg>
</button>
<!-- Language Toggle -->
<a
href="?lang={% if lang == 'fa' %}en{% else %}fa{% endif %}"
class="px-3 py-1 text-sm bg-gray-700 hover:bg-gray-600 rounded-lg transition-colors"
>
<a href="?lang={% if lang == 'fa' %}en{% else %}fa{% endif %}"
class="px-3 py-1 text-sm bg-gray-200 dark:bg-gray-700 hover:bg-gray-300 dark:hover:bg-gray-600 rounded-lg transition-colors text-gray-800 dark:text-gray-200">
{% if lang == 'fa' %}EN{% else %}FA{% endif %}
</a>
<!-- Refresh Button -->
<button
hx-get="{{ request.url.path }}{% if lang and lang != 'en' %}?lang={{ lang }}{% endif %}"
hx-target="body"
hx-swap="outerHTML"
class="p-2 text-gray-400 hover:text-white hover:bg-gray-700 rounded-lg transition-colors"
title="{{ t.refresh }}"
>
<button hx-get="{{ request.url.path }}{% if lang and lang != 'en' %}?lang={{ lang }}{% endif %}"
hx-target="body" hx-swap="outerHTML"
class="p-2 text-gray-500 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white hover:bg-gray-200 dark:hover:bg-gray-700 rounded-lg transition-colors"
title="{{ t.refresh }}">
<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="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"/>
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
</svg>
</button>
<!-- User Info -->
{% if session %}
<div class="flex items-center text-sm text-gray-400">
<div class="flex items-center text-sm">
{% if _is_admin %}
<span class="px-2 py-1 bg-red-100 dark:bg-red-500/20 text-red-700 dark:text-red-400 rounded text-xs font-medium">
{{ t.get('admin_badge', 'Admin') }}
</span>
{% else %}
<span class="w-2 h-2 bg-green-500 rounded-full {% if lang == 'fa' %}ml-2{% else %}mr-2{% endif %}"></span>
{{ session.user_type }}
<span class="text-gray-600 dark:text-gray-400">{{ _display.name }}</span>
{% endif %}
</div>
{% endif %}
</div>
@@ -224,4 +317,5 @@
{% block scripts %}{% endblock %}
</body>
</html>

View File

@@ -0,0 +1,299 @@
{% extends "dashboard/base.html" %}
{% block title %}{{ t.connect }} - MCP Hub{% endblock %}
{% block page_title %}{{ t.connect }}{% endblock %}
{% block content %}
<div class="space-y-8">
<!-- API Keys Section -->
<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">{{ t.api_keys }}</h3>
<button onclick="createKey()" id="create-key-btn"
class="btn-primary px-4 py-2 rounded-lg text-white text-sm font-medium">
+ {{ t.generate_key }}
</button>
</div>
<!-- New key alert -->
{% if new_key %}
<div class="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 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">{{ new_key }}</code>
<button onclick="copyText('{{ new_key }}')" class="text-sm text-yellow-700 dark:text-yellow-300 hover:text-yellow-600 dark:hover:text-yellow-200">{{ t['copy'] }}</button>
</div>
<p class="text-xs text-yellow-600 dark:text-yellow-400 mt-1">{{ t.key_shown_once }}</p>
</div>
{% endif %}
<!-- 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>
</div>
{% if api_keys %}
<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-{{ 'right' if lang == 'fa' else 'left' }} text-sm font-medium text-gray-500 dark:text-gray-300">{{ t.api_key_name }}</th>
<th class="px-4 py-3 text-{{ 'right' if lang == 'fa' else 'left' }} text-sm font-medium text-gray-500 dark:text-gray-300">Prefix</th>
<th class="px-4 py-3 text-{{ 'right' if lang == 'fa' else 'left' }} text-sm font-medium text-gray-500 dark:text-gray-300">
{% if lang == 'fa' %}دسترسی{% else %}Access{% endif %}
</th>
<th class="px-4 py-3 text-{{ 'right' if lang == 'fa' else 'left' }} text-sm font-medium text-gray-500 dark:text-gray-300">Uses</th>
<th class="px-4 py-3 text-{{ 'right' if lang == 'fa' else 'left' }} text-sm font-medium text-gray-500 dark:text-gray-300">{{ t.actions }}</th>
</tr>
</thead>
<tbody class="divide-y divide-gray-100 dark:divide-gray-700">
{% for key in api_keys %}
<tr id="key-{{ key.id }}">
<td class="px-4 py-3 text-gray-900 dark:text-white">{{ key.name }}</td>
<td class="px-4 py-3 text-gray-500 dark:text-gray-400 font-mono text-sm">mhu_{{ key.key_prefix }}...</td>
<td class="px-4 py-3">
<span class="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-green-100 dark:bg-green-500/20 text-green-700 dark:text-green-400">
{% if lang == 'fa' %}دسترسی کامل{% else %}Full Access{% endif %}
</span>
</td>
<td class="px-4 py-3 text-gray-500 dark:text-gray-400 text-sm">{{ key.use_count }}</td>
<td class="px-4 py-3">
<button onclick="deleteKey('{{ key.id }}')" class="text-sm text-red-600 dark:text-red-400 hover:text-red-500 dark:hover:text-red-300">{{ t.delete }}</button>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% else %}
<p class="text-gray-500 dark:text-gray-400 text-sm">{{ t.no_api_keys }}</p>
{% endif %}
</div>
<!-- Config Snippets + Connection Guide (unified) -->
<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">{{ t.config_snippets }}</h3>
{% if sites %}
<div class="grid grid-cols-1 md:grid-cols-2 gap-4 mb-4">
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">{{ t.select_site }}</label>
<select id="config-site" onchange="updateConfig()"
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 focus:ring-2 focus:ring-blue-500">
{% for site in sites %}
<option value="{{ site.alias }}" data-plugin="{{ site.plugin_type }}">{{ site.alias }} ({{ site.plugin_type }})</option>
{% endfor %}
</select>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">{{ t.select_client }}</label>
<select id="config-client" onchange="updateConfig()"
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 focus:ring-2 focus:ring-blue-500">
{% for client in clients %}
<option value="{{ client.id }}">{{ client.label }}</option>
{% endfor %}
</select>
</div>
</div>
<div class="relative">
<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 (hidden for web-based clients via JS) -->
<div id="transport-note" 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 SEO plugin note (shown/hidden dynamically by JS based on selected site) -->
<div id="wp-seo-note" style="display:none" 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://github.com/airano-ir/mcphub/raw/main/wordpress-plugin/airano-mcp-bridge.zip" target="_blank" class="underline">Airano MCP Bridge</a>
را دانلود و روی سایت وردپرسی خود نصب کنید.
{% else %}
<strong>WordPress:</strong> For SEO, media, and other helper tools, download and install the
<a href="https://github.com/airano-ir/mcphub/raw/main/wordpress-plugin/airano-mcp-bridge.zip" target="_blank" class="underline">Airano MCP Bridge</a>
companion plugin on your WordPress site.
{% endif %}
</p>
</div>
<!-- Connection tip (shown only for web-based clients via JS) -->
<div id="connection-tip" style="display:none" class="mt-3 bg-green-50 dark:bg-green-500/10 border border-green-200 dark:border-green-500/30 rounded-lg p-4">
<p class="text-sm text-green-700 dark:text-green-400">
{% if lang == 'fa' %}
<strong>نکته:</strong> فقط آدرس URL کافی است. هنگام اتصال می‌توانید با <strong>API Key</strong> یا <strong>GitHub/Google</strong> احراز هویت کنید.
ساخت <a href="/dashboard/oauth-clients{% if lang and lang != 'en' %}?lang={{ lang }}{% endif %}" class="underline">OAuth Client</a> اختیاری است.
{% else %}
<strong>Tip:</strong> You only need the URL above. When connecting, you can authenticate with an <strong>API Key</strong> or <strong>GitHub/Google</strong>.
Creating an <a href="/dashboard/oauth-clients{% if lang and lang != 'en' %}?lang={{ lang }}{% endif %}" class="underline">OAuth Client</a> is optional.
{% endif %}
</p>
</div>
<!-- API Key hint for desktop/CLI clients (hidden for web clients via JS) -->
<div id="bearer-hint" class="mt-3 bg-gray-50 dark:bg-gray-700/50 border border-gray-200 dark:border-gray-600 rounded-lg p-4">
<p class="text-sm text-gray-600 dark:text-gray-400 mb-2">
{% if lang == 'fa' %}
<strong>API Key:</strong> از بخش API Keys بالا بسازید و مقدار آن را در تنظیمات وارد کنید:
{% else %}
<strong>API Key:</strong> Create one above and use it in your config:
{% endif %}
</p>
<code class="block bg-gray-100 dark:bg-gray-900 px-3 py-1.5 rounded text-xs font-mono text-gray-800 dark:text-gray-200">"Authorization": "Bearer mhu_YOUR_API_KEY_HERE"</code>
</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>
</div>
{% endblock %}
{% block scripts %}
<script>
async function createKey() {
const name = prompt('Key name (e.g., "Claude Desktop"):');
if (!name) return;
const btn = document.getElementById('create-key-btn');
btn.disabled = true;
try {
const resp = await fetch('/api/keys', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name: name }),
});
const data = await resp.json();
if (resp.ok && data.key) {
document.getElementById('new-key-value').textContent = data.key.key;
document.getElementById('new-key-display').classList.remove('hidden');
document.getElementById('new-key-display').classList.add('flex');
// Modal stays open — user clicks "Done" to dismiss
} else {
alert(data.error || 'Failed to create key');
}
} catch (e) {
alert('Error: ' + e.message);
}
btn.disabled = false;
}
async function deleteKey(keyId) {
if (!confirm('Delete this API key?')) return;
try {
const resp = await fetch('/api/keys/' + keyId, { method: 'DELETE' });
if (resp.ok) {
document.getElementById('key-' + keyId).remove();
}
} catch (e) {
alert('Failed to delete key');
}
}
const WEB_CLIENTS = ['claude_connectors', 'chatgpt'];
const WP_TYPES = ['wordpress', 'woocommerce'];
async function updateConfig() {
const siteSelect = document.getElementById('config-site');
const alias = siteSelect?.value;
const client = document.getElementById('config-client')?.value;
if (!alias || !client) return;
const isWebClient = WEB_CLIENTS.includes(client);
// Show/hide transport note
const transportNote = document.getElementById('transport-note');
if (transportNote) transportNote.style.display = isWebClient ? 'none' : '';
// Show/hide connection tip (only for web clients)
const connTip = document.getElementById('connection-tip');
if (connTip) connTip.style.display = isWebClient ? '' : 'none';
// Show/hide bearer hint (only for desktop/CLI clients)
const bearerHint = document.getElementById('bearer-hint');
if (bearerHint) bearerHint.style.display = isWebClient ? 'none' : '';
// Show/hide WordPress SEO note based on selected site's plugin type
const selectedOption = siteSelect.options[siteSelect.selectedIndex];
const pluginType = selectedOption?.dataset?.plugin || '';
const wpNote = document.getElementById('wp-seo-note');
if (wpNote) wpNote.style.display = WP_TYPES.includes(pluginType) ? '' : 'none';
try {
const resp = await fetch('/api/config/' + alias + '?client=' + client);
const data = await resp.json();
if (resp.ok) {
document.getElementById('config-output').textContent = data.config;
}
} catch (e) {
document.getElementById('config-output').textContent = 'Error loading config';
}
}
function copyText(text) {
navigator.clipboard.writeText(text);
}
function copyNewKey() {
const key = document.getElementById('new-key-value').textContent;
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);
const btn = document.getElementById('copy-config-btn');
btn.textContent = '{{ t.copied }}';
setTimeout(() => btn.textContent = '{{ t['copy'] }}', 2000);
}
// Load config on page load
{% if sites %}
document.addEventListener('DOMContentLoaded', updateConfig);
{% endif %}
</script>
{% endblock %}

View File

@@ -8,10 +8,10 @@
<!-- System Status Overview -->
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
<!-- Overall Status -->
<div class="bg-gray-800 rounded-xl border border-gray-700 p-4">
<div class="bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700 p-4">
<div class="flex items-center justify-between">
<div>
<p class="text-sm text-gray-400">{% if lang == 'fa' %}وضعیت کلی{% else %}Overall Status{% endif %}</p>
<p class="text-sm text-gray-500 dark:text-gray-400">{% if lang == 'fa' %}وضعیت کلی{% else %}Overall Status{% endif %}</p>
{% if async_load %}
<p id="system-status-badge" class="text-2xl font-bold text-blue-400">
{% if lang == 'fa' %}در حال بررسی...{% else %}Checking...{% endif %}
@@ -44,11 +44,11 @@
</div>
<!-- Uptime -->
<div class="bg-gray-800 rounded-xl border border-gray-700 p-4">
<div class="bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700 p-4">
<div class="flex items-center justify-between">
<div>
<p class="text-sm text-gray-400">{{ t.system_uptime }}</p>
<p class="text-2xl font-bold text-white">{{ uptime.formatted }}</p>
<p class="text-sm text-gray-500 dark:text-gray-400">{{ t.system_uptime }}</p>
<p class="text-2xl font-bold text-gray-900 dark:text-white">{{ uptime.formatted }}</p>
</div>
<div class="w-12 h-12 bg-blue-500/20 rounded-lg flex items-center justify-center">
<svg class="w-6 h-6 text-blue-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
@@ -59,11 +59,11 @@
</div>
<!-- Total Requests -->
<div class="bg-gray-800 rounded-xl border border-gray-700 p-4">
<div class="bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700 p-4">
<div class="flex items-center justify-between">
<div>
<p class="text-sm text-gray-400">{% if lang == 'fa' %}کل درخواست‌ها{% else %}Total Requests{% endif %}</p>
<p class="text-2xl font-bold text-white">{{ metrics.total_requests|default(0) }}</p>
<p class="text-sm text-gray-500 dark:text-gray-400">{% if lang == 'fa' %}کل درخواست‌ها{% else %}Total Requests{% endif %}</p>
<p class="text-2xl font-bold text-gray-900 dark:text-white">{{ metrics.total_requests|default(0) }}</p>
<p class="text-xs text-gray-500">{{ metrics.requests_per_minute|default(0)|round(1) }} req/min</p>
</div>
<div class="w-12 h-12 bg-purple-500/20 rounded-lg flex items-center justify-center">
@@ -75,10 +75,10 @@
</div>
<!-- Error Rate -->
<div class="bg-gray-800 rounded-xl border border-gray-700 p-4">
<div class="bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700 p-4">
<div class="flex items-center justify-between">
<div>
<p class="text-sm text-gray-400">{% if lang == 'fa' %}نرخ خطا{% else %}Error Rate{% endif %}</p>
<p class="text-sm text-gray-500 dark:text-gray-400">{% if lang == 'fa' %}نرخ خطا{% else %}Error Rate{% endif %}</p>
<p class="text-2xl font-bold {% if (metrics.error_rate_percent|default(0)) > 10 %}text-red-400{% elif (metrics.error_rate_percent|default(0)) > 5 %}text-yellow-400{% else %}text-green-400{% endif %}">
{{ metrics.error_rate_percent|default(0)|round(2) }}%
</p>
@@ -113,8 +113,8 @@
<!-- Response Time & Throughput -->
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6">
<!-- Response Time -->
<div class="bg-gray-800 rounded-xl border border-gray-700 p-6">
<h3 class="text-lg font-semibold text-white mb-4">
<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' %}زمان پاسخ{% else %}Response Time{% endif %}
</h3>
<div class="grid grid-cols-3 gap-4">
@@ -134,22 +134,22 @@
</div>
<!-- Request Summary -->
<div class="bg-gray-800 rounded-xl border border-gray-700 p-6">
<h3 class="text-lg font-semibold text-white mb-4">
<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' %}خلاصه درخواست‌ها{% else %}Request Summary{% endif %}
</h3>
<div class="grid grid-cols-2 gap-4">
<div>
<p class="text-sm text-gray-400">{% if lang == 'fa' %}موفق{% else %}Successful{% endif %}</p>
<p class="text-sm text-gray-500 dark:text-gray-400">{% if lang == 'fa' %}موفق{% else %}Successful{% endif %}</p>
<p class="text-2xl font-bold text-green-400">{{ metrics.successful_requests|default(0) }}</p>
</div>
<div>
<p class="text-sm text-gray-400">{% if lang == 'fa' %}ناموفق{% else %}Failed{% endif %}</p>
<p class="text-sm text-gray-500 dark:text-gray-400">{% if lang == 'fa' %}ناموفق{% else %}Failed{% endif %}</p>
<p class="text-2xl font-bold text-red-400">{{ metrics.failed_requests|default(0) }}</p>
</div>
</div>
<div class="mt-4">
<div class="w-full bg-gray-700 rounded-full h-2">
<div class="w-full bg-gray-200 dark:bg-gray-700 rounded-full h-2">
{% set total = metrics.total_requests|default(0) %}
{% set successful = metrics.successful_requests|default(0) %}
{% set success_rate = ((successful / total) * 100) if total > 0 else 100 %}
@@ -164,12 +164,12 @@
<div id="alerts-container"></div>
<!-- Projects Health -->
<div class="bg-gray-800 rounded-xl border border-gray-700 overflow-hidden">
<div class="px-6 py-4 border-b border-gray-700 flex items-center justify-between">
<h3 class="text-lg font-semibold text-white">
<div class="bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700 overflow-hidden">
<div class="px-6 py-4 border-b border-gray-200 dark:border-gray-700 flex items-center justify-between">
<h3 class="text-lg font-semibold text-gray-900 dark:text-white">
{% if lang == 'fa' %}سلامت پروژه‌ها{% else %}Project Health{% endif %}
</h3>
<span id="projects-summary" class="text-sm text-gray-400">
<span id="projects-summary" class="text-sm text-gray-500 dark:text-gray-400">
{% if async_load %}
{% if lang == 'fa' %}در حال بارگذاری...{% else %}Loading...{% endif %}
{% else %}
@@ -180,21 +180,21 @@
<div class="overflow-x-auto">
<table class="w-full">
<thead class="bg-gray-700/50">
<thead class="bg-gray-50 dark:bg-gray-700/50">
<tr>
<th class="px-6 py-4 text-{% if lang == 'fa' %}right{% else %}left{% endif %} text-sm font-medium text-gray-300">
<th class="px-6 py-4 text-{% if lang == 'fa' %}right{% else %}left{% endif %} text-sm font-medium text-gray-700 dark:text-gray-300">
{% if lang == 'fa' %}پروژه{% else %}Project{% endif %}
</th>
<th class="px-6 py-4 text-{% if lang == 'fa' %}right{% else %}left{% endif %} text-sm font-medium text-gray-300">
<th class="px-6 py-4 text-{% if lang == 'fa' %}right{% else %}left{% endif %} text-sm font-medium text-gray-700 dark:text-gray-300">
{% if lang == 'fa' %}وضعیت{% else %}Status{% endif %}
</th>
<th class="px-6 py-4 text-{% if lang == 'fa' %}right{% else %}left{% endif %} text-sm font-medium text-gray-300">
<th class="px-6 py-4 text-{% if lang == 'fa' %}right{% else %}left{% endif %} text-sm font-medium text-gray-700 dark:text-gray-300">
{% if lang == 'fa' %}زمان پاسخ{% else %}Response Time{% endif %}
</th>
<th class="px-6 py-4 text-{% if lang == 'fa' %}right{% else %}left{% endif %} text-sm font-medium text-gray-300">
<th class="px-6 py-4 text-{% if lang == 'fa' %}right{% else %}left{% endif %} text-sm font-medium text-gray-700 dark:text-gray-300">
{% if lang == 'fa' %}نرخ خطا{% else %}Error Rate{% endif %}
</th>
<th class="px-6 py-4 text-{% if lang == 'fa' %}right{% else %}left{% endif %} text-sm font-medium text-gray-300">
<th class="px-6 py-4 text-{% if lang == 'fa' %}right{% else %}left{% endif %} text-sm font-medium text-gray-700 dark:text-gray-300">
{% if lang == 'fa' %}آخرین بررسی{% else %}Last Check{% endif %}
</th>
</tr>
@@ -203,7 +203,7 @@
<!-- HTMX async loading - wraps tbody for proper swap -->
<tbody
id="projects-health-body"
class="divide-y divide-gray-700"
class="divide-y divide-gray-200 dark:divide-gray-700"
hx-get="/api/dashboard/health/projects{% if lang and lang != 'en' %}?lang={{ lang }}{% endif %}"
hx-trigger="load"
hx-swap="innerHTML"
@@ -215,7 +215,7 @@
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
</svg>
<p class="text-gray-400">
<p class="text-gray-500 dark:text-gray-400">
{% if lang == 'fa' %}در حال بررسی سلامت پروژه‌ها...{% else %}Checking projects health...{% endif %}
</p>
</div>
@@ -223,12 +223,12 @@
</tr>
</tbody>
{% else %}
<tbody class="divide-y divide-gray-700">
<tbody class="divide-y divide-gray-200 dark:divide-gray-700">
{% if projects_health %}
{% for project_id, project in projects_health.items() %}
<tr class="hover:bg-gray-700/30 transition-colors">
<tr class="hover:bg-gray-50 dark:hover:bg-gray-700/30 transition-colors">
<td class="px-6 py-4">
<span class="text-white font-medium">{{ project_id }}</span>
<span class="text-gray-900 dark:text-white font-medium">{{ project_id }}</span>
</td>
<td class="px-6 py-4">
{% if project.healthy or project.status == 'healthy' %}
@@ -244,7 +244,7 @@
{% endif %}
</td>
<td class="px-6 py-4">
<span class="text-gray-300">{{ project.response_time_ms|round(2) }} ms</span>
<span class="text-gray-700 dark:text-gray-300">{{ project.response_time_ms|round(2) }} ms</span>
</td>
<td class="px-6 py-4">
<span class="{% if project.error_rate_percent > 10 %}text-red-400{% elif project.error_rate_percent > 5 %}text-yellow-400{% else %}text-green-400{% endif %}">
@@ -252,14 +252,14 @@
</span>
</td>
<td class="px-6 py-4">
<span class="text-sm text-gray-400">{{ project.last_check[:19] if project.last_check else '-' }}</span>
<span class="text-sm text-gray-500 dark:text-gray-400">{{ project.last_check[:19] if project.last_check else '-' }}</span>
</td>
</tr>
{% endfor %}
{% else %}
<tr>
<td colspan="5" class="px-6 py-12 text-center">
<p class="text-gray-400">
<p class="text-gray-500 dark:text-gray-400">
{% if lang == 'fa' %}هیچ پروژه‌ای برای بررسی یافت نشد{% else %}No projects found for health check{% endif %}
</p>
</td>
@@ -272,26 +272,26 @@
</div>
<!-- System Information -->
<div class="bg-gray-800 rounded-xl border border-gray-700 p-6">
<h3 class="text-lg font-semibold text-white mb-4">
<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' %}اطلاعات سیستم{% else %}System Information{% endif %}
</h3>
<div class="grid grid-cols-2 md:grid-cols-4 gap-4">
<div>
<p class="text-sm text-gray-400">{% if lang == 'fa' %}زمان شروع{% else %}Start Time{% endif %}</p>
<p class="text-white font-mono text-sm">{{ uptime.start_time[:19] if uptime.start_time else '-' }}</p>
<p class="text-sm text-gray-500 dark:text-gray-400">{% if lang == 'fa' %}زمان شروع{% else %}Start Time{% endif %}</p>
<p class="text-gray-900 dark:text-white font-mono text-sm">{{ uptime.start_time[:19] if uptime.start_time else '-' }}</p>
</div>
<div>
<p class="text-sm text-gray-400">{% if lang == 'fa' %}آپتایم (روز){% else %}Uptime (Days){% endif %}</p>
<p class="text-white font-mono text-sm">{{ uptime.days|default(0)|round(2) }}</p>
<p class="text-sm text-gray-500 dark:text-gray-400">{% if lang == 'fa' %}آپتایم (روز){% else %}Uptime (Days){% endif %}</p>
<p class="text-gray-900 dark:text-white font-mono text-sm">{{ uptime.days|default(0)|round(2) }}</p>
</div>
<div>
<p class="text-sm text-gray-400">{% if lang == 'fa' %}آپتایم (ساعت){% else %}Uptime (Hours){% endif %}</p>
<p class="text-white font-mono text-sm">{{ uptime.hours|default(0)|round(2) }}</p>
<p class="text-sm text-gray-500 dark:text-gray-400">{% if lang == 'fa' %}آپتایم (ساعت){% else %}Uptime (Hours){% endif %}</p>
<p class="text-gray-900 dark:text-white font-mono text-sm">{{ uptime.hours|default(0)|round(2) }}</p>
</div>
<div>
<p class="text-sm text-gray-400">{% if lang == 'fa' %}زمان فعلی{% else %}Current Time{% endif %}</p>
<p class="text-white font-mono text-sm">{{ uptime.current_time[:19] if uptime.current_time else '-' }}</p>
<p class="text-sm text-gray-500 dark:text-gray-400">{% if lang == 'fa' %}زمان فعلی{% else %}Current Time{% endif %}</p>
<p class="text-gray-900 dark:text-white font-mono text-sm">{{ uptime.current_time[:19] if uptime.current_time else '-' }}</p>
</div>
</div>
</div>
@@ -299,7 +299,7 @@
<!-- Refresh Buttons -->
<div class="flex justify-between items-center">
{% if is_cached %}
<p class="text-sm text-gray-400">
<p class="text-sm text-gray-500 dark:text-gray-400">
<svg class="w-4 h-4 inline-block {% if lang == 'fa' %}ml-1{% else %}mr-1{% endif %}" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z"/>
</svg>
@@ -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-700 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 %}"

View File

@@ -1,9 +1,9 @@
<!-- Projects Table Rows -->
{% if projects_health %}
{% for project_id, project in projects_health.items() %}
<tr class="hover:bg-gray-700/30 transition-colors">
<tr class="hover:bg-gray-50 dark:hover:bg-gray-700/30 transition-colors">
<td class="px-6 py-4">
<span class="text-white font-medium">{{ project_id }}</span>
<span class="text-gray-900 dark:text-white font-medium">{{ project_id }}</span>
</td>
<td class="px-6 py-4">
{% if project.healthy or project.status == 'healthy' %}
@@ -17,7 +17,7 @@
{% endif %}
</td>
<td class="px-6 py-4">
<span class="text-gray-300">{{ project.response_time_ms|round(2) }} ms</span>
<span class="text-gray-700 dark:text-gray-300">{{ project.response_time_ms|round(2) }} ms</span>
</td>
<td class="px-6 py-4">
<span class="{% if project.error_rate_percent > 10 %}text-red-400{% elif project.error_rate_percent > 5 %}text-yellow-400{% else %}text-green-400{% endif %}">
@@ -25,14 +25,14 @@
</span>
</td>
<td class="px-6 py-4">
<span class="text-sm text-gray-400">{{ project.last_check[:19] if project.last_check else '-' }}</span>
<span class="text-sm text-gray-500 dark:text-gray-400">{{ project.last_check[:19] if project.last_check else '-' }}</span>
</td>
</tr>
{% endfor %}
{% else %}
<tr>
<td colspan="5" class="px-6 py-12 text-center">
<p class="text-gray-400">
<p class="text-gray-500 dark:text-gray-400">
{% if lang == 'fa' %}هیچ پروژه‌ای برای بررسی یافت نشد{% else %}No projects found for health check{% endif %}
</p>
</td>

View File

@@ -5,14 +5,20 @@
{% block content %}
<div class="space-y-6">
<!-- Stats Cards -->
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6">
{% if is_admin is defined and is_admin %}
{# ══════════════════════════════════════════════════════ #}
{# ADMIN DASHBOARD #}
{# ══════════════════════════════════════════════════════ #}
<!-- Admin Stats Cards -->
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
<!-- Projects Card -->
<div class="card-hover bg-gray-800 rounded-xl p-6 border border-gray-700">
<div class="card-hover bg-white dark:bg-gray-800 rounded-xl p-6 border border-gray-200 dark:border-gray-700">
<div class="flex items-center justify-between">
<div>
<p class="text-sm font-medium text-gray-400">{{ t.total_projects }}</p>
<p class="text-3xl font-bold text-white mt-1">{{ stats.projects_count }}</p>
<p class="text-sm font-medium text-gray-500 dark:text-gray-400">{{ t.total_projects }}</p>
<p class="text-3xl font-bold text-gray-900 dark:text-white mt-1">{{ stats.projects_count }}</p>
</div>
<div class="w-12 h-12 bg-blue-500/20 rounded-lg flex items-center justify-center">
<svg class="w-6 h-6 text-blue-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
@@ -29,11 +35,11 @@
</div>
<!-- API Keys Card -->
<div class="card-hover bg-gray-800 rounded-xl p-6 border border-gray-700">
<div class="card-hover bg-white dark:bg-gray-800 rounded-xl p-6 border border-gray-200 dark:border-gray-700">
<div class="flex items-center justify-between">
<div>
<p class="text-sm font-medium text-gray-400">{{ t.active_api_keys }}</p>
<p class="text-3xl font-bold text-white mt-1">{{ stats.api_keys_count }}</p>
<p class="text-sm font-medium text-gray-500 dark:text-gray-400">{{ t.active_api_keys }}</p>
<p class="text-3xl font-bold text-gray-900 dark:text-white mt-1">{{ stats.api_keys_count }}</p>
</div>
<div class="w-12 h-12 bg-green-500/20 rounded-lg flex items-center justify-center">
<svg class="w-6 h-6 text-green-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
@@ -50,11 +56,11 @@
</div>
<!-- Tools Card -->
<div class="card-hover bg-gray-800 rounded-xl p-6 border border-gray-700">
<div class="card-hover bg-white dark:bg-gray-800 rounded-xl p-6 border border-gray-200 dark:border-gray-700">
<div class="flex items-center justify-between">
<div>
<p class="text-sm font-medium text-gray-400">{{ t.total_tools }}</p>
<p class="text-3xl font-bold text-white mt-1">{{ stats.tools_count }}</p>
<p class="text-sm font-medium text-gray-500 dark:text-gray-400">{{ t.total_tools }}</p>
<p class="text-3xl font-bold text-gray-900 dark:text-white mt-1">{{ stats.tools_count }}</p>
</div>
<div class="w-12 h-12 bg-purple-500/20 rounded-lg flex items-center justify-center">
<svg class="w-6 h-6 text-purple-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
@@ -63,17 +69,17 @@
</svg>
</div>
</div>
<p class="mt-4 text-sm text-gray-400">
<p class="mt-4 text-sm text-gray-500 dark:text-gray-400">
{% if lang == 'fa' %}ابزارهای موجود{% else %}Available tools{% endif %}
</p>
</div>
<!-- Uptime Card -->
<div class="card-hover bg-gray-800 rounded-xl p-6 border border-gray-700">
<div class="card-hover bg-white dark:bg-gray-800 rounded-xl p-6 border border-gray-200 dark:border-gray-700">
<div class="flex items-center justify-between">
<div>
<p class="text-sm font-medium text-gray-400">{{ t.system_uptime }}</p>
<p class="text-3xl font-bold text-white mt-1">{{ "%.1f"|format(stats.uptime_days) }}d</p>
<p class="text-sm font-medium text-gray-500 dark:text-gray-400">{{ t.system_uptime }}</p>
<p class="text-3xl font-bold text-gray-900 dark:text-white mt-1">{{ "%.1f"|format(stats.uptime_days) }}d</p>
</div>
<div class="w-12 h-12 bg-orange-500/20 rounded-lg flex items-center justify-center">
<svg class="w-6 h-6 text-orange-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
@@ -88,22 +94,57 @@
</svg>
</a>
</div>
<!-- Users Card -->
<div class="card-hover bg-white dark:bg-gray-800 rounded-xl p-6 border border-gray-200 dark:border-gray-700">
<div class="flex items-center justify-between">
<div>
<p class="text-sm font-medium text-gray-500 dark:text-gray-400">{% if lang == 'fa' %}کاربران{% else %}Total Users{% endif %}</p>
<p class="text-3xl font-bold text-gray-900 dark:text-white mt-1">{{ stats.users_count|default(0) }}</p>
</div>
<div class="w-12 h-12 bg-cyan-500/20 rounded-lg flex items-center justify-center">
<svg class="w-6 h-6 text-cyan-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4.354a4 4 0 110 5.292M15 21H3v-1a6 6 0 0112 0v1zm0 0h6v-1a6 6 0 00-9-5.197M13 7a4 4 0 11-8 0 4 4 0 018 0z"/>
</svg>
</div>
</div>
<p class="mt-4 text-sm text-gray-500 dark:text-gray-400">
{% if lang == 'fa' %}کاربران ثبت‌نام شده{% else %}Registered users{% endif %}
</p>
</div>
<!-- User Sites Card -->
<div class="card-hover bg-white dark:bg-gray-800 rounded-xl p-6 border border-gray-200 dark:border-gray-700">
<div class="flex items-center justify-between">
<div>
<p class="text-sm font-medium text-gray-500 dark:text-gray-400">{% if lang == 'fa' %}سایت‌های کاربران{% else %}User Sites{% endif %}</p>
<p class="text-3xl font-bold text-gray-900 dark:text-white mt-1">{{ stats.user_sites_count|default(0) }}</p>
</div>
<div class="w-12 h-12 bg-teal-500/20 rounded-lg flex items-center justify-center">
<svg class="w-6 h-6 text-teal-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 12a9 9 0 01-9 9m9-9a9 9 0 00-9-9m9 9H3m9 9a9 9 0 01-9-9m9 9c1.657 0 3-4.03 3-9s-1.343-9-3-9m0 18c-1.657 0-3-4.03-3-9s1.343-9 3-9m-9 9a9 9 0 019-9"/>
</svg>
</div>
</div>
<p class="mt-4 text-sm text-gray-500 dark:text-gray-400">
{% if lang == 'fa' %}سایت‌های متصل شده{% else %}Connected sites{% endif %}
</p>
</div>
</div>
<!-- Main Content Grid -->
<!-- Admin Main Content Grid -->
<div class="grid grid-cols-1 lg:grid-cols-3 gap-6">
<!-- Recent Activity -->
<div class="lg:col-span-2 bg-gray-800 rounded-xl border border-gray-700">
<div class="p-6 border-b border-gray-700">
<h2 class="text-lg font-semibold text-white">{{ t.recent_activity }}</h2>
<div class="lg:col-span-2 bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700">
<div class="p-6 border-b border-gray-200 dark:border-gray-700">
<h2 class="text-lg font-semibold text-gray-900 dark:text-white">{{ t.recent_activity }}</h2>
</div>
<div class="divide-y divide-gray-700">
<div class="divide-y divide-gray-200 dark:divide-gray-700">
{% if recent_activity %}
{% for activity in recent_activity %}
<div class="p-4 hover:bg-gray-700/50 transition-colors">
<div class="p-4 hover:bg-gray-50 dark:hover:bg-gray-700/50 transition-colors">
<div class="flex items-center justify-between">
<div class="flex items-center">
<!-- Icon based on type -->
{% if activity.type == 'tool_call' %}
<div class="w-8 h-8 bg-blue-500/20 rounded-lg flex items-center justify-center {% if lang == 'fa' %}ml-3{% else %}mr-3{% endif %}">
<svg class="w-4 h-4 text-blue-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
@@ -131,16 +172,16 @@
{% endif %}
<div>
<p class="text-sm font-medium text-white">{{ activity.message }}</p>
<p class="text-xs text-gray-400">{{ activity.project }}</p>
<p class="text-sm font-medium text-gray-900 dark:text-white">{{ activity.message }}</p>
<p class="text-xs text-gray-500 dark:text-gray-400">{{ activity.project if activity.project and activity.project != 'None' else '-' }}</p>
</div>
</div>
<span class="text-xs text-gray-500">{{ activity.timestamp[:19] }}</span>
<span class="text-xs text-gray-400 dark:text-gray-500">{{ activity.timestamp[:19] if activity.timestamp else '-' }}</span>
</div>
</div>
{% endfor %}
{% else %}
<div class="p-8 text-center text-gray-400">
<div class="p-8 text-center text-gray-500 dark:text-gray-400">
<svg class="w-12 h-12 mx-auto mb-4 opacity-50" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2"/>
</svg>
@@ -149,9 +190,9 @@
{% endif %}
</div>
{% if recent_activity %}
<div class="p-4 border-t border-gray-700">
<div class="p-4 border-t border-gray-200 dark:border-gray-700">
<a href="/dashboard/audit-logs{% if lang and lang != 'en' %}?lang={{ lang }}{% endif %}" class="text-sm text-primary-400 hover:text-primary-300">
{{ t.view_all }}
{{ t.view_all }} &rarr;
</a>
</div>
{% endif %}
@@ -160,9 +201,9 @@
<!-- Right Column -->
<div class="space-y-6">
<!-- Projects by Type -->
<div class="bg-gray-800 rounded-xl border border-gray-700">
<div class="p-6 border-b border-gray-700">
<h2 class="text-lg font-semibold text-white">{{ t.projects_by_type }}</h2>
<div class="bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700">
<div class="p-6 border-b border-gray-200 dark:border-gray-700">
<h2 class="text-lg font-semibold text-gray-900 dark:text-white">{{ t.projects_by_type }}</h2>
</div>
<div class="p-6 space-y-4">
{% set plugin_colors = {
@@ -196,13 +237,13 @@
<div class="w-8 h-8 {{ plugin_colors.get(plugin_type, 'bg-gray-500') }} rounded-lg flex items-center justify-center {% if lang == 'fa' %}ml-3{% else %}mr-3{% endif %}">
<span class="text-xs font-bold text-white">{{ plugin_icons.get(plugin_type, plugin_type[:2]|upper) }}</span>
</div>
<span class="text-sm text-gray-300">{{ plugin_type|plugin_name }}</span>
<span class="text-sm text-gray-700 dark:text-gray-300">{{ plugin_type|plugin_name }}</span>
</div>
<span class="text-sm font-medium text-white">{{ count }}</span>
<span class="text-sm font-medium text-gray-900 dark:text-white">{{ count }}</span>
</div>
{% endfor %}
{% else %}
<p class="text-center text-gray-400 text-sm">
<p class="text-center text-gray-500 dark:text-gray-400 text-sm">
{% if lang == 'fa' %}پروژه‌ای یافت نشد{% else %}No projects found{% endif %}
</p>
{% endif %}
@@ -210,83 +251,189 @@
</div>
<!-- Health Status -->
<div class="bg-gray-800 rounded-xl border border-gray-700">
<div class="p-6 border-b border-gray-700">
<h2 class="text-lg font-semibold text-white">{{ t.health_status }}</h2>
<div class="bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700">
<div class="p-6 border-b border-gray-200 dark:border-gray-700">
<h2 class="text-lg font-semibold text-gray-900 dark:text-white">{{ t.health_status }}</h2>
</div>
<div class="p-6 space-y-4">
{% for component, status in health_summary.components.items() %}
<div class="flex items-center justify-between">
<span class="text-sm text-gray-300 capitalize">{{ component }}</span>
<span class="text-sm text-gray-700 dark:text-gray-300 capitalize">{{ component }}</span>
<div class="flex items-center">
{% if status == 'healthy' %}
<span class="w-2 h-2 bg-green-500 rounded-full status-pulse {% if lang == 'fa' %}ml-2{% else %}mr-2{% endif %}"></span>
<span class="text-sm text-green-400">{{ t.healthy }}</span>
<span class="text-sm text-green-600 dark:text-green-400">{{ t.healthy }}</span>
{% elif status == 'warning' %}
<span class="w-2 h-2 bg-yellow-500 rounded-full status-pulse {% if lang == 'fa' %}ml-2{% else %}mr-2{% endif %}"></span>
<span class="text-sm text-yellow-400">{{ t.warning }}</span>
<span class="text-sm text-yellow-600 dark:text-yellow-400">{{ t.warning }}</span>
{% else %}
<span class="w-2 h-2 bg-red-500 rounded-full status-pulse {% if lang == 'fa' %}ml-2{% else %}mr-2{% endif %}"></span>
<span class="text-sm text-red-400">{{ t.error }}</span>
<span class="text-sm text-red-600 dark:text-red-400">{{ t.error }}</span>
{% endif %}
</div>
</div>
{% endfor %}
<div class="pt-4 border-t border-gray-700">
<p class="text-xs text-gray-500">
<div class="pt-4 border-t border-gray-200 dark:border-gray-700">
<p class="text-xs text-gray-400 dark:text-gray-500">
{% if lang == 'fa' %}آخرین بررسی:{% else %}Last check:{% endif %}
{{ health_summary.last_check[:19] }}
</p>
</div>
</div>
<div class="p-4 border-t border-gray-700">
<div class="p-4 border-t border-gray-200 dark:border-gray-700">
<a href="/dashboard/health{% if lang and lang != 'en' %}?lang={{ lang }}{% endif %}" class="text-sm text-primary-400 hover:text-primary-300">
{{ t.view_all }}
{{ t.view_all }} &rarr;
</a>
</div>
</div>
<!-- Support / Donate -->
<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>
</div>
</div>
{% else %}
{# ══════════════════════════════════════════════════════ #}
{# USER DASHBOARD #}
{# ══════════════════════════════════════════════════════ #}
<!-- User Stats Cards -->
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
<!-- My Sites Card -->
<div class="card-hover bg-white dark:bg-gray-800 rounded-xl p-6 border border-gray-200 dark:border-gray-700">
<div class="flex items-center justify-between">
<div>
<p class="text-sm font-medium text-gray-500 dark:text-gray-400">{{ t.get('my_services', 'My Services') }}</p>
<p class="text-3xl font-bold text-gray-900 dark:text-white mt-1">{{ stats.sites_count|default(0) }}</p>
</div>
<div class="w-12 h-12 bg-blue-500/20 rounded-lg flex items-center justify-center">
<svg class="w-6 h-6 text-blue-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 12a9 9 0 01-9 9m9-9a9 9 0 00-9-9m9 9H3m9 9a9 9 0 01-9-9m9 9c1.657 0 3-4.03 3-9s-1.343-9-3-9m0 18c-1.657 0-3-4.03-3-9s1.343-9 3-9m-9 9a9 9 0 019-9"/>
</svg>
</div>
</div>
<a href="/dashboard/sites{% if lang and lang != 'en' %}?lang={{ lang }}{% endif %}" class="mt-4 text-sm text-blue-400 hover:text-blue-300 flex items-center">
{{ t.view_all }}
<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">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7"/>
</svg>
</a>
</div>
<!-- Active Connections Card -->
<div class="card-hover bg-white dark:bg-gray-800 rounded-xl p-6 border border-gray-200 dark:border-gray-700">
<div class="flex items-center justify-between">
<div>
<p class="text-sm font-medium text-gray-500 dark:text-gray-400">{{ t.get('active_connections', 'Active Connections') }}</p>
<p class="text-3xl font-bold text-gray-900 dark:text-white mt-1">{{ stats.active_sites_count|default(0) }}</p>
</div>
<div class="w-12 h-12 bg-green-500/20 rounded-lg flex items-center justify-center">
<svg class="w-6 h-6 text-green-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"/>
</svg>
</div>
</div>
<p class="mt-4 text-sm text-gray-500 dark:text-gray-400">
{% if lang == 'fa' %}اتصالات فعال{% else %}Active connections{% endif %}
</p>
</div>
<!-- API Keys Card -->
<div class="card-hover bg-white dark:bg-gray-800 rounded-xl p-6 border border-gray-200 dark:border-gray-700">
<div class="flex items-center justify-between">
<div>
<p class="text-sm font-medium text-gray-500 dark:text-gray-400">{{ t.get('user_api_keys', 'API Keys') }}</p>
<p class="text-3xl font-bold text-gray-900 dark:text-white mt-1">{{ stats.api_keys_count|default(0) }}</p>
</div>
<div class="w-12 h-12 bg-purple-500/20 rounded-lg flex items-center justify-center">
<svg class="w-6 h-6 text-purple-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 7a2 2 0 012 2m4 0a6 6 0 01-7.743 5.743L11 17H9v2H7v2H4a1 1 0 01-1-1v-2.586a1 1 0 01.293-.707l5.964-5.964A6 6 0 1121 9z"/>
</svg>
</div>
</div>
<a href="/dashboard/connect{% if lang and lang != 'en' %}?lang={{ lang }}{% endif %}" class="mt-4 text-sm text-purple-400 hover:text-purple-300 flex items-center">
{{ t.view_all }}
<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">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7"/>
</svg>
</a>
</div>
</div>
<!-- User Site Status -->
<div class="bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700">
<div class="p-6 border-b border-gray-200 dark:border-gray-700 flex items-center justify-between">
<h2 class="text-lg font-semibold text-gray-900 dark:text-white">{{ t.get('site_status', 'Site Status') }}</h2>
<a href="/dashboard/sites/add{% if lang and lang != 'en' %}?lang={{ lang }}{% endif %}"
class="inline-flex items-center px-3 py-1.5 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-1{% else %}mr-1{% endif %}" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 6v6m0 0v6m0-6h6m-6 0H6"/>
</svg>
{{ t.get('add_site', 'Add Service') }}
</a>
</div>
<div>
{% if user_sites %}
{% set plugin_colors = {
'wordpress': 'bg-blue-500',
'woocommerce': 'bg-purple-500',
'wordpress_advanced': 'bg-indigo-500',
'gitea': 'bg-green-500',
'n8n': 'bg-orange-500',
'supabase': 'bg-emerald-500',
'openpanel': 'bg-cyan-500',
'appwrite': 'bg-pink-500',
'directus': 'bg-violet-500',
} %}
{% for site in user_sites %}
<div class="flex items-center justify-between p-4 border-b border-gray-100 dark:border-gray-700 last:border-0 hover:bg-gray-50 dark:hover:bg-gray-700/50 transition-colors">
<div class="flex items-center">
<div class="w-8 h-8 {{ plugin_colors.get(site.plugin_type, 'bg-gray-500') }} rounded-lg flex items-center justify-center {% if lang == 'fa' %}ml-3{% else %}mr-3{% endif %}">
<span class="text-xs font-bold text-white">{{ site.plugin_type[:2]|upper }}</span>
</div>
<div>
<p class="text-sm font-medium text-gray-900 dark:text-white">{{ site.alias }}</p>
<p class="text-xs text-gray-500 dark:text-gray-400">{{ site.plugin_type|plugin_name }} &middot; {{ site.url[:40] }}{% if site.url|length > 40 %}...{% endif %}</p>
</div>
</div>
<div class="flex items-center">
{% if site.status == 'active' %}
<span class="w-2 h-2 bg-green-500 rounded-full status-pulse {% if lang == 'fa' %}ml-2{% else %}mr-2{% endif %}"></span>
<span class="text-sm text-green-600 dark:text-green-400">{{ t.get('active', 'Active') }}</span>
{% elif site.status == 'error' %}
<span class="w-2 h-2 bg-red-500 rounded-full {% if lang == 'fa' %}ml-2{% else %}mr-2{% endif %}"></span>
<span class="text-sm text-red-600 dark:text-red-400">{{ t.get('error', 'Error') }}</span>
{% else %}
<span class="w-2 h-2 bg-yellow-500 rounded-full {% if lang == 'fa' %}ml-2{% else %}mr-2{% endif %}"></span>
<span class="text-sm text-yellow-600 dark:text-yellow-400">Pending</span>
{% endif %}
</div>
</div>
{% endfor %}
{% else %}
<div class="p-12 text-center">
<svg class="w-16 h-16 mx-auto mb-4 text-gray-300 dark:text-gray-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M21 12a9 9 0 01-9 9m9-9a9 9 0 00-9-9m9 9H3m9 9a9 9 0 01-9-9m9 9c1.657 0 3-4.03 3-9s-1.343-9-3-9m0 18c-1.657 0-3-4.03-3-9s1.343-9 3-9m-9 9a9 9 0 019-9"/>
</svg>
<p class="text-gray-500 dark:text-gray-400 mb-2">{{ t.get('no_services_yet', "You haven't connected any services yet.") }}</p>
<p class="text-sm text-gray-400 dark:text-gray-500 mb-6">{{ t.get('add_first_service', 'Add your first service to start using MCP tools.') }}</p>
<a href="/dashboard/sites/add{% if lang and lang != 'en' %}?lang={{ lang }}{% endif %}"
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 6v6m0 0v6m0-6h6m-6 0H6"/>
</svg>
{{ t.get('add_site', 'Add Service') }}
</a>
</div>
{% endif %}
</div>
</div>
{% endif %}
</div>
{% endblock %}
{% block scripts %}
<script>
// Auto-refresh dashboard every 30 seconds
htmx.config.defaultSwapStyle = 'outerHTML';
// Refresh stats periodically
setInterval(function() {
htmx.ajax('GET', '/api/dashboard/stats', {target: '#stats-container', swap: 'none'});
}, 30000);
</script>
{% endblock %}

View File

@@ -0,0 +1,676 @@
{% extends "dashboard/base.html" %}
{% block title %}{% if lang == 'fa' %}کلیدهای API{% else %}API Keys{% endif %} - MCP Hub{% endblock %}
{% block page_title %}{% if lang == 'fa' %}کلیدهای API{% else %}API Keys{% endif %}{% endblock %}
{% block content %}
<div class="space-y-6">
{% if is_admin %}
{# ── Admin view: full filters, all project keys ── #}
<div class="flex flex-wrap items-center justify-between gap-4">
<p class="text-gray-500 dark:text-gray-400 text-sm">
{% if lang == 'fa' %}مدیریت کلیدهای API (Admin){% else %}Manage project API keys (admin){% endif %}
</p>
<button onclick="openCreateModal()"
class="px-4 py-2 bg-primary-600 hover:bg-primary-700 rounded-lg text-white text-sm font-medium transition-colors flex items-center">
<svg class="w-5 h-5 {% 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 4v16m8-8H4"/>
</svg>
{% if lang == 'fa' %}ایجاد کلید جدید{% else %}Create New Key{% endif %}
</button>
</div>
<!-- Admin Filters -->
<div class="bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700 p-4">
<form method="GET" class="flex flex-wrap items-center gap-4">
{% if lang and lang != 'en' %}<input type="hidden" name="lang" value="{{ lang }}">{% endif %}
<div class="flex items-center gap-2">
<label class="text-sm text-gray-500 dark:text-gray-400">{{ t.filter }}:</label>
<select name="project" onchange="this.form.submit()"
class="bg-gray-50 dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-lg px-3 py-2 text-gray-900 dark:text-white text-sm focus:ring-2 focus:ring-primary-500">
<option value="">{% if lang == 'fa' %}همه پروژه‌ها{% else %}All Projects{% endif %}</option>
<option value="*" {% if selected_project == '*' %}selected{% endif %}>{% if lang == 'fa' %}کلیدهای عمومی (*){% else %}Global Keys (*){% endif %}</option>
{% for project in available_projects %}
<option value="{{ project.full_id }}" {% if selected_project == project.full_id %}selected{% endif %}>
{{ project.plugin_type }}: {{ project.alias or project.site_id }}
</option>
{% endfor %}
</select>
</div>
<div class="flex items-center gap-2">
<label class="text-sm text-gray-500 dark:text-gray-400">{% if lang == 'fa' %}وضعیت:{% else %}Status:{% endif %}</label>
<select name="status" onchange="this.form.submit()"
class="bg-gray-50 dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-lg px-3 py-2 text-gray-900 dark:text-white text-sm focus:ring-2 focus:ring-primary-500">
<option value="active" {% if selected_status == 'active' %}selected{% endif %}>{% if lang == 'fa' %}فعال{% else %}Active{% endif %}</option>
<option value="all" {% if selected_status == 'all' %}selected{% endif %}>{% if lang == 'fa' %}همه{% else %}All{% endif %}</option>
<option value="revoked" {% if selected_status == 'revoked' %}selected{% endif %}>{% if lang == 'fa' %}لغو شده{% else %}Revoked{% endif %}</option>
</select>
</div>
<div class="flex-1 min-w-[200px]">
<input type="text" name="search" value="{{ search_query }}"
placeholder="{% if lang == 'fa' %}جستجو...{% else %}Search description...{% 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 text-sm focus:ring-2 focus:ring-primary-500">
</div>
<button type="submit" class="px-4 py-2 bg-primary-600 hover:bg-primary-700 rounded-lg text-white text-sm transition-colors">{{ t.search }}</button>
{% if search_query or selected_project or selected_status != 'active' %}
<a href="/dashboard/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 %}
</form>
</div>
<!-- Admin Keys Table -->
<div class="bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700 overflow-hidden">
<div class="overflow-x-auto">
<table class="w-full">
<thead class="bg-gray-50 dark:bg-gray-700/50">
<tr>
<th class="px-6 py-4 text-{{ 'right' if lang == 'fa' else 'left' }} text-sm font-medium text-gray-700 dark:text-gray-300">{% if lang == 'fa' %}شناسه{% else %}Key ID{% endif %}</th>
<th class="px-6 py-4 text-{{ 'right' if lang == 'fa' else 'left' }} text-sm font-medium text-gray-700 dark:text-gray-300">{% if lang == 'fa' %}پروژه{% else %}Project{% endif %}</th>
<th class="px-6 py-4 text-{{ 'right' if lang == 'fa' else 'left' }} text-sm font-medium text-gray-700 dark:text-gray-300">{% if lang == 'fa' %}دسترسی{% else %}Scope{% endif %}</th>
<th class="px-6 py-4 text-{{ 'right' if lang == 'fa' else 'left' }} text-sm font-medium text-gray-700 dark:text-gray-300">{% if lang == 'fa' %}توضیحات{% else %}Description{% endif %}</th>
<th class="px-6 py-4 text-{{ 'right' if lang == 'fa' else 'left' }} text-sm font-medium text-gray-700 dark:text-gray-300">{% if lang == 'fa' %}وضعیت{% else %}Status{% endif %}</th>
<th class="px-6 py-4 text-{{ 'right' if lang == 'fa' else 'left' }} text-sm font-medium text-gray-700 dark:text-gray-300">{% if lang == 'fa' %}استفاده{% else %}Usage{% endif %}</th>
<th class="px-6 py-4 text-{{ 'right' if lang == 'fa' else 'left' }} text-sm font-medium text-gray-700 dark:text-gray-300">{% if lang == 'fa' %}عملیات{% else %}Actions{% endif %}</th>
</tr>
</thead>
<tbody class="divide-y divide-gray-200 dark:divide-gray-700">
{% if api_keys %}
{% for key in api_keys %}
<tr class="hover:bg-gray-50 dark:hover:bg-gray-700/30 transition-colors {% if key.revoked %}opacity-50{% endif %}">
<td class="px-6 py-4">
<div class="flex items-center">
<span class="text-sm text-gray-700 dark:text-gray-300 font-mono">{{ key.key_id[:12] }}...</span>
<button onclick="copyToClipboard('{{ key.key_id }}')" class="{% if lang == 'fa' %}mr-2{% else %}ml-2{% endif %} text-gray-400 hover:text-white transition-colors" title="Copy ID">
<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="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z"/></svg>
</button>
</div>
</td>
<td class="px-6 py-4">
{% if key.project_id == '*' %}
<span class="px-2 py-1 bg-yellow-500/20 text-yellow-400 text-xs rounded-lg font-medium">{% if lang == 'fa' %}همه پروژه‌ها{% else %}All Projects{% endif %}</span>
{% else %}<span class="text-sm text-gray-700 dark:text-gray-300">{{ key.project_id }}</span>{% endif %}
</td>
<td class="px-6 py-4">
<div class="flex flex-wrap gap-1">
{% for scope in key.scope.split() %}
<span class="px-2 py-0.5 {% if scope == 'admin' %}bg-red-500/20 text-red-400{% elif scope == 'write' %}bg-orange-500/20 text-orange-400{% else %}bg-blue-500/20 text-blue-400{% endif %} text-xs rounded font-medium">{{ scope }}</span>
{% endfor %}
</div>
</td>
<td class="px-6 py-4"><span class="text-sm text-gray-500 dark:text-gray-400">{{ key.description or '-' }}</span></td>
<td class="px-6 py-4">
{% if key.revoked %}<div class="flex items-center"><span class="w-2 h-2 bg-red-500 rounded-full {% if lang == 'fa' %}ml-2{% else %}mr-2{% endif %}"></span><span class="text-sm text-red-400">{% if lang == 'fa' %}لغو شده{% else %}Revoked{% endif %}</span></div>
{% else %}<div class="flex items-center"><span class="w-2 h-2 bg-green-500 rounded-full status-pulse {% if lang == 'fa' %}ml-2{% else %}mr-2{% endif %}"></span><span class="text-sm text-green-400">{% if lang == 'fa' %}فعال{% else %}Active{% endif %}</span></div>{% endif %}
</td>
<td class="px-6 py-4"><span class="text-sm text-gray-700 dark:text-gray-300">{{ key.usage_count }}</span></td>
<td class="px-6 py-4">
<div class="flex items-center gap-2">
{% if not key.revoked %}
<button onclick="openRevokeModal('{{ key.key_id }}', '{{ key.description or key.key_id[:12] }}')"
class="p-2 bg-red-500/20 hover:bg-red-500/30 rounded-lg text-red-400 transition-colors" title="{% if lang == 'fa' %}لغو{% else %}Revoke{% endif %}">
<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="M18.364 18.364A9 9 0 005.636 5.636m12.728 12.728A9 9 0 015.636 5.636m12.728 12.728L5.636 5.636"/></svg>
</button>
{% endif %}
<button onclick="openDeleteModal('{{ key.key_id }}', '{{ key.description or key.key_id[:12] }}')"
class="p-2 bg-gray-200 dark:bg-gray-700 hover:bg-gray-300 dark:hover:bg-gray-600 rounded-lg text-gray-400 hover:text-white transition-colors" title="{% if lang == 'fa' %}حذف{% else %}Delete{% endif %}">
<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="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"/></svg>
</button>
</div>
</td>
</tr>
{% endfor %}
{% else %}
<tr><td colspan="7" class="px-6 py-12 text-center">
<svg class="w-12 h-12 mx-auto mb-4 text-gray-500" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 7a2 2 0 012 2m4 0a6 6 0 01-7.743 5.743L11 17H9v2H7v2H4a1 1 0 01-1-1v-2.586a1 1 0 01.293-.707l5.964-5.964A6 6 0 1121 9z"/></svg>
<p class="text-gray-500 dark:text-gray-400">{% if lang == 'fa' %}کلید API یافت نشد{% else %}No API keys found{% endif %}</p>
<button onclick="openCreateModal()" class="mt-4 px-4 py-2 bg-primary-600 hover:bg-primary-700 rounded-lg text-white text-sm transition-colors">{% if lang == 'fa' %}ایجاد اولین کلید{% else %}Create First Key{% endif %}</button>
</td></tr>
{% endif %}
</tbody>
</table>
</div>
{% if total_pages > 1 %}
<div class="px-6 py-4 border-t border-gray-200 dark:border-gray-700 flex items-center justify-between">
<p class="text-sm text-gray-500 dark:text-gray-400">
{% if lang == 'fa' %}نمایش {{ ((page_number-1)*per_page)+1 }} تا {{ [page_number*per_page, total_count]|min }} از {{ total_count }}{% else %}Showing {{ ((page_number-1)*per_page)+1 }}{{ [page_number*per_page, total_count]|min }} of {{ total_count }}{% endif %}
</p>
<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-gray-800 dark:text-white">{% if lang == 'fa' %}قبلی{% else %}Previous{% endif %}</a>{% endif %}
{% for p in range(1, total_pages+1) %}{% if p == page_number %}<span class="px-3 py-1.5 bg-primary-600 rounded-lg text-sm text-white">{{ p }}</span>{% elif p == 1 or p == total_pages or (p >= page_number-2 and p <= page_number+2) %}<a href="?page={{ p }}{% 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-gray-800 dark:text-white">{{ p }}</a>{% endif %}{% endfor %}
{% 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-gray-800 dark:text-white">{% if lang == 'fa' %}بعدی{% else %}Next{% endif %}</a>{% endif %}
</div>
</div>
{% endif %}
</div>
{% else %}
{# ── User view: personal keys with scope selector ── #}
<div class="flex flex-wrap items-center justify-between gap-4">
<div>
<p class="text-gray-500 dark:text-gray-400 text-sm">
{% if lang == 'fa' %}کلیدهای API شخصی برای دسترسی به MCP{% else %}Your personal API keys for MCP access{% endif %}
</p>
<p class="text-gray-400 dark:text-gray-500 text-xs mt-1">
{% if lang == 'fa' %}فیلتر ابزارهای هر سایت در <a href="/dashboard/sites{% if lang and lang != 'en' %}?lang={{ lang }}{% endif %}" class="underline">تنظیمات سایت</a> انجام می‌شود.{% else %}Per-site tool filters are configured in <a href="/dashboard/sites{% if lang and lang != 'en' %}?lang={{ lang }}{% endif %}" class="underline">Site Settings</a>.{% endif %}
</p>
</div>
<button onclick="openCreateModal()"
class="px-4 py-2 bg-primary-600 hover:bg-primary-700 rounded-lg text-white text-sm font-medium transition-colors flex items-center">
<svg class="w-5 h-5 {% 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 4v16m8-8H4"/>
</svg>
{% if lang == 'fa' %}ایجاد کلید جدید{% else %}Create New Key{% endif %}
</button>
</div>
<!-- User Keys Table -->
<div class="bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700 overflow-hidden">
<div class="overflow-x-auto">
<table class="w-full">
<thead class="bg-gray-50 dark:bg-gray-700/50">
<tr>
<th class="px-6 py-4 text-{{ 'right' if lang == 'fa' else 'left' }} text-sm font-medium text-gray-500 dark:text-gray-300">{% if lang == 'fa' %}نام{% else %}Name{% endif %}</th>
<th class="px-6 py-4 text-{{ 'right' if lang == 'fa' else 'left' }} text-sm font-medium text-gray-500 dark:text-gray-300">Prefix</th>
<th class="px-6 py-4 text-{{ 'right' if lang == 'fa' else 'left' }} text-sm font-medium text-gray-500 dark:text-gray-300">{% if lang == 'fa' %}دسترسی{% else %}Scope{% endif %}</th>
<th class="px-6 py-4 text-{{ 'right' if lang == 'fa' else 'left' }} text-sm font-medium text-gray-500 dark:text-gray-300">Uses</th>
<th class="px-6 py-4 text-{{ 'right' if lang == 'fa' else 'left' }} text-sm font-medium text-gray-500 dark:text-gray-300">{{ t.actions }}</th>
</tr>
</thead>
<tbody class="divide-y divide-gray-100 dark:divide-gray-700">
{% if user_keys %}
{% for key in user_keys %}
<tr id="ukey-{{ key.id }}">
<td class="px-6 py-4 text-gray-900 dark:text-white">{{ key.name }}</td>
<td class="px-6 py-4 text-gray-500 dark:text-gray-400 font-mono text-sm">mhu_{{ key.key_prefix }}...</td>
<td class="px-6 py-4">
{% if key.site_id %}
<span class="px-2 py-0.5 bg-blue-100 dark:bg-blue-500/20 text-blue-700 dark:text-blue-400 text-xs rounded font-medium">
{% for site in sites %}{% if site.id == key.site_id %}{{ site.alias }}{% endif %}{% endfor %}
</span>
{% else %}
<span class="px-2 py-0.5 bg-green-100 dark:bg-green-500/20 text-green-700 dark:text-green-400 text-xs rounded font-medium">{% if lang == 'fa' %}همه سرویس‌ها{% else %}All Services{% endif %}</span>
{% endif %}
</td>
<td class="px-6 py-4 text-gray-500 dark:text-gray-400 text-sm">{{ key.use_count }}</td>
<td class="px-6 py-4">
<button onclick="deleteUserKey('{{ key.id }}')" class="text-sm text-red-600 dark:text-red-400 hover:text-red-500">{{ t.delete }}</button>
</td>
</tr>
{% endfor %}
{% else %}
<tr><td colspan="5" class="px-6 py-12 text-center">
<p class="text-gray-500 dark:text-gray-400 mb-4">{{ t.no_api_keys }}</p>
<button onclick="openCreateModal()" class="px-4 py-2 bg-primary-600 hover:bg-primary-700 rounded-lg text-white text-sm transition-colors">{% if lang == 'fa' %}ایجاد اولین کلید{% else %}Create First Key{% endif %}</button>
</td></tr>
{% endif %}
</tbody>
</table>
</div>
</div>
{% if not is_admin %}
<!-- Configuration Snippet -->
<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' %}نمونه کد پیکربندی{% else %}Configuration Snippet{% endif %}
</h3>
{% if sites %}
<div class="grid grid-cols-1 md:grid-cols-2 gap-4 mb-4">
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">{% if lang == 'fa' %}سایت{% else %}Site{% endif %}</label>
<select id="config-site" onchange="updateConfig()"
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 focus:ring-2 focus:ring-blue-500">
{% for site in sites %}
<option value="{{ site.alias }}" data-site-id="{{ site.id }}" data-plugin="{{ site.plugin_type }}">{{ site.alias }} ({{ site.plugin_type }})</option>
{% endfor %}
</select>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">{% if lang == 'fa' %}کلاینت{% else %}Client{% endif %}</label>
<select id="config-client" onchange="updateConfig()"
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 focus:ring-2 focus:ring-blue-500">
{% for client in clients %}
<option value="{{ client.id }}">{{ client.label }}</option>
{% endfor %}
</select>
</div>
</div>
<div class="relative">
<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]">{% if lang == 'fa' %}در حال بارگذاری...{% else %}Loading...{% endif %}</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">{% if lang == 'fa' %}کپی{% else %}Copy{% endif %}</button>
</div>
<!-- Transport type info -->
<div id="transport-note" 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 SEO plugin note -->
<div id="wp-seo-note" style="display:none" 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://github.com/airano-ir/mcphub/raw/main/wordpress-plugin/airano-mcp-bridge.zip" target="_blank" class="underline">Airano MCP Bridge</a>
را دانلود و روی سایت وردپرسی خود نصب کنید.
{% else %}
<strong>WordPress:</strong> For SEO, media, and other helper tools, download and install the
<a href="https://github.com/airano-ir/mcphub/raw/main/wordpress-plugin/airano-mcp-bridge.zip" target="_blank" class="underline">Airano MCP Bridge</a>
companion plugin on your WordPress site.
{% endif %}
</p>
</div>
<!-- Connection tip for web-based clients -->
<div id="connection-tip" style="display:none" class="mt-3 bg-green-50 dark:bg-green-500/10 border border-green-200 dark:border-green-500/30 rounded-lg p-4">
<p class="text-sm text-green-700 dark:text-green-400">
{% if lang == 'fa' %}
<strong>نکته:</strong> فقط آدرس URL کافی است. هنگام اتصال می‌توانید با <strong>API Key</strong> یا <strong>GitHub/Google</strong> احراز هویت کنید.
ساخت <a href="/dashboard/oauth-clients{% if lang and lang != 'en' %}?lang={{ lang }}{% endif %}" class="underline">OAuth Client</a> اختیاری است.
{% else %}
<strong>Tip:</strong> You only need the URL above. When connecting, you can authenticate with an <strong>API Key</strong> or <strong>GitHub/Google</strong>.
Creating an <a href="/dashboard/oauth-clients{% if lang and lang != 'en' %}?lang={{ lang }}{% endif %}" class="underline">OAuth Client</a> is optional.
{% endif %}
</p>
</div>
<!-- API Key hint for desktop/CLI clients -->
<div id="bearer-hint" class="mt-3 bg-gray-50 dark:bg-gray-700/50 border border-gray-200 dark:border-gray-600 rounded-lg p-4">
<p class="text-sm text-gray-600 dark:text-gray-400 mb-2">
{% if lang == 'fa' %}
<strong>API Key:</strong> از بخش API Keys بالا بسازید و مقدار آن را در تنظیمات وارد کنید:
{% else %}
<strong>API Key:</strong> Create one above and use it in your config:
{% endif %}
</p>
<code class="block bg-gray-100 dark:bg-gray-900 px-3 py-1.5 rounded text-xs font-mono text-gray-800 dark:text-gray-200">"Authorization": "Bearer mhu_YOUR_API_KEY_HERE"</code>
</div>
{% else %}
<p class="text-gray-500 dark:text-gray-400 text-sm">
{% if lang == 'fa' %}ابتدا یک سایت اضافه کنید.{% else %}Add a site first to see configuration snippets.{% endif %}
<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>
{% endif %}
{% endif %}
</div>
{# ── Modals ── #}
<!-- Create Key Modal -->
<div id="createModal" 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-md 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' %}ایجاد کلید API جدید{% else %}Create New API Key{% endif %}
</h3>
</div>
{% if is_admin %}
{# Admin create form — posts to existing admin endpoint #}
<form id="createKeyForm" method="POST" action="/api/dashboard/api-keys/create" class="p-6 space-y-4">
{% if lang and lang != 'en' %}<input type="hidden" name="lang" value="{{ lang }}">{% endif %}
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">{% if lang == 'fa' %}پروژه{% else %}Project{% endif %}</label>
<select name="project_id" required 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">
<option value="*">{% if lang == 'fa' %}همه پروژه‌ها (*){% else %}All Projects (*){% endif %}</option>
{% for project in available_projects %}
<option value="{{ project.full_id }}">{{ project.plugin_type }}: {{ project.alias or project.site_id }}</option>
{% endfor %}
</select>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">{% if lang == 'fa' %}سطح دسترسی{% else %}Scope{% endif %}</label>
<div class="space-y-2">
<label class="flex items-center"><input type="checkbox" name="scope_read" value="read" checked class="rounded text-primary-600"><span class="ml-2 text-sm text-gray-700 dark:text-gray-300">read</span></label>
<label class="flex items-center"><input type="checkbox" name="scope_write" value="write" class="rounded text-primary-600"><span class="ml-2 text-sm text-gray-700 dark:text-gray-300">write</span></label>
<label class="flex items-center"><input type="checkbox" name="scope_admin" value="admin" class="rounded text-primary-600"><span class="ml-2 text-sm text-gray-700 dark:text-gray-300">admin</span></label>
</div>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">{% if lang == 'fa' %}توضیحات (اختیاری){% else %}Description (optional){% endif %}</label>
<input type="text" name="description" placeholder="{% if lang == 'fa' %}مثال: CI/CD{% else %}e.g., CI/CD key{% 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-2">{% if lang == 'fa' %}انقضا{% else %}Expiration{% endif %}</label>
<select name="expires_in_days" 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">
<option value="">{% if lang == 'fa' %}بدون انقضا{% else %}Never expires{% endif %}</option>
<option value="7">7 {% if lang == 'fa' %}روز{% else %}days{% endif %}</option>
<option value="30">30 {% if lang == 'fa' %}روز{% else %}days{% endif %}</option>
<option value="90">90 {% if lang == 'fa' %}روز{% else %}days{% endif %}</option>
<option value="365">1 {% if lang == 'fa' %}سال{% else %}year{% endif %}</option>
</select>
</div>
</form>
{% else %}
{# User create form — uses /api/keys via JS #}
<div class="p-6 space-y-4">
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">{% if lang == 'fa' %}نام کلید{% else %}Key Name{% endif %}</label>
<input type="text" id="new-key-name" placeholder="{% if lang == 'fa' %}مثال: Claude Desktop{% else %}e.g., Claude 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.5 text-gray-900 dark:text-white placeholder-gray-400 focus:ring-2 focus:ring-blue-500">
</div>
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">{% if lang == 'fa' %}محدوده سرویس{% else %}Service Scope{% endif %}</label>
<select id="new-key-site"
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 focus:ring-2 focus:ring-blue-500">
<option value="">{% if lang == 'fa' %}همه سرویس‌ها (عمومی){% else %}All Services (global){% endif %}</option>
{% if sites %}
{% for site in sites %}
<option value="{{ site.id }}">{{ site.alias }} ({{ site.plugin_type }})</option>
{% endfor %}
{% endif %}
</select>
<p class="text-xs text-gray-400 dark:text-gray-500 mt-1">
{% if lang == 'fa' %}کلید اختصاصی فقط برای سرویس انتخاب‌شده قابل استفاده است.{% else %}A site-scoped key only works for the selected service.{% endif %}
</p>
</div>
<div class="flex items-center gap-3">
<span class="px-3 py-1 bg-green-100 dark:bg-green-500/20 text-green-700 dark:text-green-400 text-sm font-medium rounded-lg">
{% if lang == 'fa' %}دسترسی کامل{% else %}Full Access{% endif %}
</span>
<p class="text-xs text-gray-400 dark:text-gray-500">
{% if lang == 'fa' %}فیلتر ابزار هر سایت در <a href="/dashboard/sites{% if lang and lang != 'en' %}?lang={{ lang }}{% endif %}" class="underline hover:text-gray-300">تنظیمات سایت</a> انجام می‌شود.{% else %}Tool access is controlled per-site in <a href="/dashboard/sites{% if lang and lang != 'en' %}?lang={{ lang }}{% endif %}" class="underline hover:text-gray-300">Site Settings</a>.{% endif %}
</p>
</div>
</div>
{% endif %}
<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-gray-800 dark:text-white text-sm">{% if lang == 'fa' %}انصراف{% else %}Cancel{% endif %}</button>
<button onclick="submitCreate()" class="px-4 py-2 bg-primary-600 hover:bg-primary-700 rounded-lg text-white text-sm">{% if lang == 'fa' %}ایجاد کلید{% else %}Create Key{% endif %}</button>
</div>
</div>
</div>
<!-- New Key Display Modal -->
<div id="newKeyModal" 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-6 h-6 text-green-400" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"/></svg>
{% if lang == 'fa' %}کلید API ایجاد شد{% else %}API Key Created{% endif %}
</h3>
</div>
<div class="p-6 space-y-4">
<div class="bg-yellow-500/10 border border-yellow-500/30 rounded-lg p-4">
<p class="text-sm text-yellow-400">{% if lang == 'fa' %}این کلید فقط یکبار نمایش داده می‌شود.{% else %}This key will only be shown once. Save it now!{% endif %}</p>
</div>
<div>
<label class="block text-sm font-medium text-gray-400 mb-2">API Key</label>
<div class="flex items-center gap-2">
<input type="text" id="newKeyValue" readonly class="flex-1 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 font-mono text-sm">
<button onclick="copyNewKey()" class="px-4 py-2 bg-primary-600 hover:bg-primary-700 rounded-lg text-white text-sm">{% 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="closeNewKeyModal()" class="px-4 py-2 bg-primary-600 hover:bg-primary-700 rounded-lg text-white text-sm">{% if lang == 'fa' %}بستن{% else %}Done{% endif %}</button>
</div>
</div>
</div>
{% if is_admin %}
<!-- Revoke Modal -->
<div id="revokeModal" 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-md 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' %}لغو کلید API{% else %}Revoke API Key{% endif %}</h3></div>
<div class="p-6"><p class="text-gray-700 dark:text-gray-300">{% if lang == 'fa' %}آیا مطمئنید که می‌خواهید کلید <span id="revokeKeyName" class="font-mono text-white"></span> را لغو کنید؟{% else %}Are you sure you want to revoke <span id="revokeKeyName" class="font-mono text-white"></span>?{% endif %}</p></div>
<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-gray-800 dark:text-white text-sm">{% if lang == 'fa' %}انصراف{% else %}Cancel{% endif %}</button>
<button id="confirmRevokeBtn" class="px-4 py-2 bg-red-600 hover:bg-red-700 rounded-lg text-white text-sm">{% if lang == 'fa' %}لغو کلید{% else %}Revoke{% endif %}</button>
</div>
</div>
</div>
<!-- Delete Modal -->
<div id="deleteModal" 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-md 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' %}حذف کلید API{% else %}Delete API Key{% endif %}</h3></div>
<div class="p-6"><p class="text-gray-700 dark:text-gray-300">{% if lang == 'fa' %}آیا مطمئنید که می‌خواهید کلید <span id="deleteKeyName" class="font-mono text-white"></span> را حذف کنید؟{% else %}Are you sure you want to delete <span id="deleteKeyName" class="font-mono text-white"></span>?{% endif %}</p></div>
<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-gray-800 dark:text-white text-sm">{% if lang == 'fa' %}انصراف{% else %}Cancel{% endif %}</button>
<button id="confirmDeleteBtn" class="px-4 py-2 bg-red-600 hover:bg-red-700 rounded-lg text-white text-sm">{% if lang == 'fa' %}حذف{% else %}Delete{% endif %}</button>
</div>
</div>
</div>
{% endif %}
{% endblock %}
{% block scripts %}
<script>
(function() {
const isAdmin = {{ 'true' if is_admin else 'false' }};
const lang = '{{ lang }}';
let currentRevokeKeyId = null;
let currentDeleteKeyId = null;
function copyToClipboard(text) {
navigator.clipboard.writeText(text).then(() => showToast(lang === 'fa' ? 'کپی شد!' : 'Copied!'));
}
window.copyToClipboard = copyToClipboard;
function showToast(msg) {
const t = document.createElement('div');
t.className = 'fixed bottom-4 right-4 bg-gray-800 border border-gray-700 rounded-lg px-4 py-2 text-white text-sm z-50';
t.textContent = msg;
document.body.appendChild(t);
setTimeout(() => t.remove(), 2000);
}
function openCreateModal() {
document.getElementById('createModal').classList.remove('hidden');
document.getElementById('createModal').classList.add('flex');
// F.7d: pre-select site from ?site= query param when present
const params = new URLSearchParams(window.location.search);
const siteParam = params.get('site');
if (siteParam) {
const siteSelect = document.getElementById('new-key-site');
if (siteSelect) {
const match = Array.from(siteSelect.options).find(o => o.value === siteParam);
if (match) siteSelect.value = siteParam;
}
}
}
window.openCreateModal = openCreateModal;
function closeCreateModal() {
document.getElementById('createModal').classList.add('hidden');
document.getElementById('createModal').classList.remove('flex');
}
window.closeCreateModal = closeCreateModal;
function closeNewKeyModal() {
document.getElementById('newKeyModal').classList.add('hidden');
document.getElementById('newKeyModal').classList.remove('flex');
location.reload();
}
window.closeNewKeyModal = closeNewKeyModal;
function copyNewKey() {
copyToClipboard(document.getElementById('newKeyValue').value);
}
window.copyNewKey = copyNewKey;
function showNewKey(key) {
document.getElementById('newKeyValue').value = key;
document.getElementById('newKeyModal').classList.remove('hidden');
document.getElementById('newKeyModal').classList.add('flex');
}
async function submitCreate() {
if (isAdmin) {
// Admin: collect scopes + submit form via JS
const form = document.getElementById('createKeyForm');
const formData = new FormData(form);
const scopes = [];
if (form.querySelector('[name="scope_read"]')?.checked) scopes.push('read');
if (form.querySelector('[name="scope_write"]')?.checked) scopes.push('write');
if (form.querySelector('[name="scope_admin"]')?.checked) scopes.push('admin');
if (scopes.length === 0) { alert(lang === 'fa' ? 'حداقل یک دسترسی انتخاب کنید' : 'Select at least one scope'); return; }
const data = {
project_id: formData.get('project_id'),
scope: scopes.join(' '),
description: formData.get('description') || null,
expires_in_days: formData.get('expires_in_days') ? parseInt(formData.get('expires_in_days')) : null,
};
const r = await fetch('/api/dashboard/api-keys/create', {
method: 'POST', headers: { 'Content-Type': 'application/json' }, credentials: 'same-origin',
body: JSON.stringify(data),
});
const res = await r.json();
if (res.error) { alert(res.error); return; }
closeCreateModal();
showNewKey(res.key);
} else {
// User: POST to /api/keys
const name = document.getElementById('new-key-name').value.trim();
if (!name) { alert(lang === 'fa' ? 'نام کلید الزامی است' : 'Key name is required'); return; }
const siteId = document.getElementById('new-key-site')?.value || null;
const payload = { name, scopes: 'read write admin' };
if (siteId) payload.site_id = siteId;
const r = await fetch('/api/keys', {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
});
const res = await r.json();
if (!r.ok) { alert(res.error || 'Failed'); return; }
closeCreateModal();
showNewKey(res.key.key);
}
}
window.submitCreate = submitCreate;
async function deleteUserKey(keyId) {
if (!confirm(lang === 'fa' ? 'این کلید حذف شود؟' : 'Delete this key?')) return;
const r = await fetch('/api/keys/' + keyId, { method: 'DELETE' });
if (r.ok) {
const row = document.getElementById('ukey-' + keyId);
if (row) row.remove();
}
}
window.deleteUserKey = deleteUserKey;
{% if is_admin %}
function openRevokeModal(keyId, keyName) {
currentRevokeKeyId = keyId;
document.getElementById('revokeKeyName').textContent = keyName;
document.getElementById('revokeModal').classList.remove('hidden');
document.getElementById('revokeModal').classList.add('flex');
}
window.openRevokeModal = openRevokeModal;
function closeRevokeModal() {
document.getElementById('revokeModal').classList.add('hidden');
document.getElementById('revokeModal').classList.remove('flex');
}
window.closeRevokeModal = closeRevokeModal;
document.getElementById('confirmRevokeBtn').onclick = async function() {
if (!currentRevokeKeyId) return;
const r = await fetch('/api/dashboard/api-keys/' + currentRevokeKeyId + '/revoke', { method: 'POST', credentials: 'same-origin' });
if (r.ok) location.reload();
else { const d = await r.json(); alert(d.error || 'Failed'); }
};
function openDeleteModal(keyId, keyName) {
currentDeleteKeyId = keyId;
document.getElementById('deleteKeyName').textContent = keyName;
document.getElementById('deleteModal').classList.remove('hidden');
document.getElementById('deleteModal').classList.add('flex');
}
window.openDeleteModal = openDeleteModal;
function closeDeleteModal() {
document.getElementById('deleteModal').classList.add('hidden');
document.getElementById('deleteModal').classList.remove('flex');
}
window.closeDeleteModal = closeDeleteModal;
document.getElementById('confirmDeleteBtn').onclick = async function() {
if (!currentDeleteKeyId) return;
const r = await fetch('/api/dashboard/api-keys/' + currentDeleteKeyId, { method: 'DELETE', credentials: 'same-origin' });
if (r.ok) location.reload();
else { const d = await r.json(); alert(d.error || 'Failed'); }
};
{% endif %}
// ── Config Snippets (user view) ────────────────────────────────
{% if not is_admin %}
const WEB_CLIENTS = ['claude_connectors', 'chatgpt'];
const WP_TYPES = ['wordpress', 'woocommerce'];
async function updateConfig() {
const siteSelect = document.getElementById('config-site');
const alias = siteSelect?.value;
const client = document.getElementById('config-client')?.value;
if (!alias || !client) return;
const isWebClient = WEB_CLIENTS.includes(client);
const transportNote = document.getElementById('transport-note');
if (transportNote) transportNote.style.display = isWebClient ? 'none' : '';
const connTip = document.getElementById('connection-tip');
if (connTip) connTip.style.display = isWebClient ? '' : 'none';
const bearerHint = document.getElementById('bearer-hint');
if (bearerHint) bearerHint.style.display = isWebClient ? 'none' : '';
const selectedOption = siteSelect.options[siteSelect.selectedIndex];
const pluginType = selectedOption?.dataset?.plugin || '';
const wpNote = document.getElementById('wp-seo-note');
if (wpNote) wpNote.style.display = WP_TYPES.includes(pluginType) ? '' : 'none';
try {
const resp = await fetch('/api/config/' + alias + '?client=' + client);
const data = await resp.json();
if (resp.ok) {
document.getElementById('config-output').textContent = data.config;
}
} catch (e) {
document.getElementById('config-output').textContent = 'Error loading config';
}
}
window.updateConfig = updateConfig;
function copyConfig() {
const text = document.getElementById('config-output').textContent;
navigator.clipboard.writeText(text);
const btn = document.getElementById('copy-config-btn');
btn.textContent = lang === 'fa' ? 'کپی شد!' : 'Copied!';
setTimeout(() => btn.textContent = lang === 'fa' ? 'کپی' : 'Copy', 2000);
}
window.copyConfig = copyConfig;
document.addEventListener('DOMContentLoaded', () => {
// F.7d: support ?site=<id-or-alias> from /dashboard/sites/{id} link
const params = new URLSearchParams(window.location.search);
const siteParam = params.get('site');
const siteSelect = document.getElementById('config-site');
if (siteParam && siteSelect) {
// Match either the option value (alias) or its data-site-id attribute
const match = Array.from(siteSelect.options).find(
o => o.value === siteParam || o.dataset?.siteId === siteParam
);
if (match) siteSelect.value = match.value;
}
if (siteSelect) updateConfig();
// F.7d: auto-open create modal when ?create=1 (e.g. from Connect section)
if (params.get('create') === '1') openCreateModal();
});
{% endif %}
})();
</script>
{% endblock %}

View File

@@ -1,46 +1,61 @@
<!DOCTYPE html>
<html lang="{{ lang|default('en') }}" {% if lang == 'fa' %}dir="rtl"{% endif %}>
<html lang="{{ lang|default('en') }}" {% if lang=='fa' %}dir="rtl" {% endif %}>
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{{ t.login_title }} - MCP Hub</title>
<link rel="icon" type="image/svg+xml" href="/static/logo.svg">
<!-- Tailwind CSS -->
<script src="https://cdn.tailwindcss.com"></script>
{% include "dashboard/partials/head_assets.html" %}
<style>
.login-gradient {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
}
.card-shadow {
box-shadow: 0 25px 50px -12px rgba(0, 0, 0, 0.5);
}
.input-focus:focus {
box-shadow: 0 0 0 3px rgba(139, 92, 246, 0.3);
}
.btn-primary {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
transition: all 0.3s ease;
}
.btn-primary:hover {
transform: translateY(-2px);
box-shadow: 0 10px 20px rgba(102, 126, 234, 0.4);
}
.animate-float {
animation: float 6s ease-in-out infinite;
}
@keyframes float {
0%, 100% { transform: translateY(0px); }
50% { transform: translateY(-20px); }
0%,
100% {
transform: translateY(0px);
}
50% {
transform: translateY(-20px);
}
}
</style>
</head>
<body class="bg-gray-900 min-h-screen flex items-center justify-center p-4">
<!-- Background decoration -->
<div class="absolute inset-0 overflow-hidden pointer-events-none">
<div class="absolute -top-40 -right-40 w-80 h-80 bg-purple-500/20 rounded-full blur-3xl animate-float"></div>
<div class="absolute -bottom-40 -left-40 w-80 h-80 bg-blue-500/20 rounded-full blur-3xl animate-float" style="animation-delay: -3s;"></div>
<div class="absolute -bottom-40 -left-40 w-80 h-80 bg-blue-500/20 rounded-full blur-3xl animate-float"
style="animation-delay: -3s;"></div>
</div>
<!-- Login Card -->
@@ -49,20 +64,22 @@
<!-- Logo -->
<div class="text-center mb-8">
<div class="mx-auto w-16 h-16 login-gradient rounded-2xl flex items-center justify-center mb-4">
<svg class="w-10 h-10 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 10V3L4 14h7v7l9-11h-7z"/>
</svg>
<img src="/static/logo.svg" alt="MCP Hub Logo"
class="w-10 h-10 object-contain drop-shadow-[0_0_8px_rgba(255,255,255,0.8)]">
</div>
<h1 class="text-2xl font-bold text-white">MCP Hub</h1>
<p class="text-gray-400 mt-2" {% if lang == 'fa' %}dir="rtl"{% endif %}>{{ t.login_subtitle }}</p>
<p class="text-gray-400 mt-2" {% if lang=='fa' %}dir="rtl" {% endif %}>{{ t.login_subtitle }}</p>
</div>
<!-- Error Message -->
{% if error %}
<div class="mb-6 p-4 bg-red-500/20 border border-red-500/50 rounded-lg" {% if lang == 'fa' %}dir="rtl"{% endif %}>
<div class="mb-6 p-4 bg-red-500/20 border border-red-500/50 rounded-lg" {% if lang=='fa' %}dir="rtl" {%
endif %}>
<div class="flex items-center">
<svg class="w-5 h-5 text-red-400 {% 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 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"/>
<svg class="w-5 h-5 text-red-400 {% 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 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
<span class="text-red-400 text-sm">
{% if error == 'rate_limit' %}
@@ -77,48 +94,49 @@
<!-- Login Form -->
<form method="POST" action="/dashboard/login" class="space-y-6">
<input type="hidden" name="csrf_token" value="{{ request.state.csrf_token }}">
<input type="hidden" name="next" value="{{ next_url }}">
<div {% if lang == 'fa' %}dir="rtl"{% endif %}>
<div {% if lang=='fa' %}dir="rtl" {% endif %}>
<label for="api_key" class="block text-sm font-medium text-gray-300 mb-2">
{{ t.api_key_label }}
</label>
<input
type="password"
id="api_key"
name="api_key"
required
autocomplete="off"
<input type="password" id="api_key" name="api_key" required autocomplete="off"
placeholder="{{ t.api_key_placeholder }}"
class="input-focus w-full px-4 py-3 bg-gray-700 border border-gray-600 rounded-lg text-white placeholder-gray-400 focus:outline-none focus:border-purple-500 transition-colors"
{% if lang == 'fa' %}dir="ltr" style="text-align: right;"{% endif %}
>
{% if lang=='fa' %}dir="ltr" style="text-align: right;" {% endif %}>
</div>
<button
type="submit"
class="btn-primary w-full py-3 px-4 text-white font-semibold rounded-lg focus:outline-none focus:ring-2 focus:ring-purple-500 focus:ring-offset-2 focus:ring-offset-gray-800"
>
<button type="submit"
class="btn-primary w-full py-3 px-4 text-white font-semibold rounded-lg focus:outline-none focus:ring-2 focus:ring-purple-500 focus:ring-offset-2 focus:ring-offset-gray-800">
{{ t.login_button }}
</button>
</form>
<!-- Security Note -->
<div class="mt-6 p-4 bg-blue-500/10 border border-blue-500/30 rounded-lg" {% if lang == 'fa' %}dir="rtl"{% endif %}>
<div class="mt-6 p-4 bg-blue-500/10 border border-blue-500/30 rounded-lg" {% if lang=='fa' %}dir="rtl" {%
endif %}>
<div class="flex items-start">
<svg class="w-5 h-5 text-blue-400 {% if lang == 'fa' %}ml-2{% else %}mr-2{% endif %} mt-0.5 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"/>
<svg class="w-5 h-5 text-blue-400 {% if lang == 'fa' %}ml-2{% else %}mr-2{% endif %} mt-0.5 flex-shrink-0"
fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
<div class="text-sm text-blue-300">
{% if lang == 'fa' %}
<p>برای دسترسی به داشبورد به Master API Key نیاز دارید.</p>
<p class="mt-2 text-blue-400">کلید API خود را در متغیر محیطی <code class="bg-gray-700 px-1 rounded">MASTER_API_KEY</code> تنظیم کنید.</p>
<p class="mt-2 text-blue-400">کلید API خود را در متغیر محیطی <code
class="bg-gray-700 px-1 rounded">MASTER_API_KEY</code> تنظیم کنید.</p>
{% else %}
<p>Enter your <code class="bg-gray-700 px-1 rounded">MASTER_API_KEY</code> to access the dashboard.</p>
<p class="mt-2 text-blue-400">Don't have one? Set it in your <code class="bg-gray-700 px-1 rounded">.env</code> file, or check the server logs for the temporary key.</p>
<p>Enter your <code class="bg-gray-700 px-1 rounded">MASTER_API_KEY</code> to access the
dashboard.</p>
<p class="mt-2 text-blue-400">Don't have one? Set it in your <code
class="bg-gray-700 px-1 rounded">.env</code> file, or check the server logs for the
temporary key.</p>
{% endif %}
<p class="mt-2">
<a href="https://github.com/airano-ir/mcphub#quick-start" target="_blank" class="text-purple-400 hover:text-purple-300 underline">
<a href="https://github.com/airano-ir/mcphub#quick-start" target="_blank"
class="text-purple-400 hover:text-purple-300 underline">
{% if lang == 'fa' %}راهنمای شروع{% else %}Setup Guide{% endif %}
</a>
</p>
@@ -128,19 +146,18 @@
<!-- Language Toggle -->
<div class="mt-6 text-center">
<a
href="?lang={% if lang == 'fa' %}en{% else %}fa{% endif %}{% if next_url %}&next={{ next_url }}{% endif %}"
class="text-sm text-gray-400 hover:text-white transition-colors"
>
{% if lang == 'fa' %}English{% else %}فارسی{% endif %}
<a href="?lang={% if lang == 'fa' %}en{% else %}fa{% endif %}{% if next_url %}&next={{ next_url }}{% endif %}"
class="text-sm text-gray-400 hover:text-white transition-colors">
{% if lang == 'fa' %}English{% else %}<span lang="fa" dir="rtl">فارسی</span>{% endif %}
</a>
</div>
</div>
<!-- Footer -->
<p class="text-center text-gray-500 text-sm mt-6">
MCP Hub v{{ version|default('3.0.1') }}
MCP Hub v{{ version }}
</p>
</div>
</body>
</html>
</html>

View File

@@ -8,7 +8,7 @@
<!-- Header with Create Button -->
<div class="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
<div>
<p class="text-gray-400">
<p class="text-gray-500 dark:text-gray-400">
{% if lang == 'fa' %}
مدیریت کلاینت‌های OAuth برای دسترسی امن به API
{% else %}
@@ -29,11 +29,11 @@
<!-- Stats -->
<div class="grid grid-cols-1 md:grid-cols-3 gap-4">
<div class="bg-gray-800 rounded-xl border border-gray-700 p-4">
<div class="bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700 p-4">
<div class="flex items-center justify-between">
<div>
<p class="text-sm text-gray-400">{% if lang == 'fa' %}کل کلاینت‌ها{% else %}Total Clients{% endif %}</p>
<p class="text-2xl font-bold text-white">{{ total_count }}</p>
<p class="text-sm text-gray-500 dark:text-gray-400">{% if lang == 'fa' %}کل کلاینت‌ها{% else %}Total Clients{% endif %}</p>
<p class="text-2xl font-bold text-gray-900 dark:text-white">{{ total_count }}</p>
</div>
<div class="w-10 h-10 bg-blue-500/20 rounded-lg flex items-center justify-center">
<svg class="w-5 h-5 text-blue-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
@@ -42,10 +42,10 @@
</div>
</div>
</div>
<div class="bg-gray-800 rounded-xl border border-gray-700 p-4">
<div class="bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700 p-4">
<div class="flex items-center justify-between">
<div>
<p class="text-sm text-gray-400">{% if lang == 'fa' %}کلاینت‌های فعال{% else %}Active Clients{% endif %}</p>
<p class="text-sm text-gray-500 dark:text-gray-400">{% if lang == 'fa' %}کلاینت‌های فعال{% else %}Active Clients{% endif %}</p>
<p class="text-2xl font-bold text-green-400">{{ total_count }}</p>
</div>
<div class="w-10 h-10 bg-green-500/20 rounded-lg flex items-center justify-center">
@@ -55,11 +55,11 @@
</div>
</div>
</div>
<div class="bg-gray-800 rounded-xl border border-gray-700 p-4">
<div class="bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700 p-4">
<div class="flex items-center justify-between">
<div>
<p class="text-sm text-gray-400">{% if lang == 'fa' %}پروتکل{% else %}Protocol{% endif %}</p>
<p class="text-2xl font-bold text-white">OAuth 2.1</p>
<p class="text-sm text-gray-500 dark:text-gray-400">{% if lang == 'fa' %}پروتکل{% else %}Protocol{% endif %}</p>
<p class="text-2xl font-bold text-gray-900 dark:text-white">OAuth 2.1</p>
</div>
<div class="w-10 h-10 bg-purple-500/20 rounded-lg flex items-center justify-center">
<svg class="w-5 h-5 text-purple-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
@@ -71,37 +71,37 @@
</div>
<!-- Clients Table -->
<div class="bg-gray-800 rounded-xl border border-gray-700 overflow-hidden">
<div class="bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700 overflow-hidden">
<div class="overflow-x-auto">
<table class="w-full">
<thead class="bg-gray-700/50">
<thead class="bg-gray-50 dark:bg-gray-700/50">
<tr>
<th class="px-6 py-4 text-{% if lang == 'fa' %}right{% else %}left{% endif %} text-sm font-medium text-gray-300">
<th class="px-6 py-4 text-{% if lang == 'fa' %}right{% else %}left{% endif %} text-sm font-medium text-gray-700 dark:text-gray-300">
{% if lang == 'fa' %}نام کلاینت{% else %}Client Name{% endif %}
</th>
<th class="px-6 py-4 text-{% if lang == 'fa' %}right{% else %}left{% endif %} text-sm font-medium text-gray-300">
<th class="px-6 py-4 text-{% if lang == 'fa' %}right{% else %}left{% endif %} text-sm font-medium text-gray-700 dark:text-gray-300">
{% if lang == 'fa' %}شناسه کلاینت{% else %}Client ID{% endif %}
</th>
<th class="px-6 py-4 text-{% if lang == 'fa' %}right{% else %}left{% endif %} text-sm font-medium text-gray-300">
<th class="px-6 py-4 text-{% if lang == 'fa' %}right{% else %}left{% endif %} text-sm font-medium text-gray-700 dark:text-gray-300">
{% if lang == 'fa' %}دامنه‌ها{% else %}Scopes{% endif %}
</th>
<th class="px-6 py-4 text-{% if lang == 'fa' %}right{% else %}left{% endif %} text-sm font-medium text-gray-300">
<th class="px-6 py-4 text-{% if lang == 'fa' %}right{% else %}left{% endif %} text-sm font-medium text-gray-700 dark:text-gray-300">
{% if lang == 'fa' %}تاریخ ایجاد{% else %}Created{% endif %}
</th>
<th class="px-6 py-4 text-{% if lang == 'fa' %}right{% else %}left{% endif %} text-sm font-medium text-gray-300">
<th class="px-6 py-4 text-{% if lang == 'fa' %}right{% else %}left{% endif %} text-sm font-medium text-gray-700 dark:text-gray-300">
{% if lang == 'fa' %}عملیات{% else %}Actions{% endif %}
</th>
</tr>
</thead>
<tbody class="divide-y divide-gray-700">
<tbody class="divide-y divide-gray-200 dark:divide-gray-700">
{% if clients %}
{% for client in clients %}
<tr class="hover:bg-gray-700/30 transition-colors">
<tr class="hover:bg-gray-50 dark:hover:bg-gray-700/30 transition-colors">
<td class="px-6 py-4">
<span class="text-white font-medium">{{ client.client_name }}</span>
<span class="text-gray-900 dark:text-white font-medium">{{ client.client_name }}</span>
</td>
<td class="px-6 py-4">
<code class="text-sm text-gray-400 bg-gray-900 px-2 py-1 rounded">{{ client.client_id[:30] }}...</code>
<code class="text-sm text-gray-500 dark:text-gray-400 bg-gray-900 px-2 py-1 rounded">{{ client.client_id[:30] }}...</code>
</td>
<td class="px-6 py-4">
<div class="flex flex-wrap gap-1">
@@ -111,7 +111,7 @@
</div>
</td>
<td class="px-6 py-4">
<span class="text-sm text-gray-400">{{ client.created_at[:10] if client.created_at else '-' }}</span>
<span class="text-sm text-gray-500 dark:text-gray-400">{{ client.created_at[:10] if client.created_at else '-' }}</span>
</td>
<td class="px-6 py-4">
<div class="flex items-center gap-2">
@@ -143,15 +143,15 @@
<td colspan="5" class="px-6 py-4">
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<p class="text-sm text-gray-400 mb-1">{% if lang == 'fa' %}آدرس‌های Redirect{% else %}Redirect URIs{% endif %}</p>
<p class="text-sm text-gray-500 dark:text-gray-400 mb-1">{% if lang == 'fa' %}آدرس‌های Redirect{% else %}Redirect URIs{% endif %}</p>
<div class="space-y-1">
{% for uri in client.redirect_uris %}
<code class="block text-xs text-gray-300 bg-gray-800 px-2 py-1 rounded">{{ uri }}</code>
<code class="block text-xs text-gray-700 dark:text-gray-300 bg-gray-100 dark:bg-gray-800 px-2 py-1 rounded">{{ uri }}</code>
{% endfor %}
</div>
</div>
<div>
<p class="text-sm text-gray-400 mb-1">{% if lang == 'fa' %}انواع Grant{% else %}Grant Types{% endif %}</p>
<p class="text-sm text-gray-500 dark:text-gray-400 mb-1">{% if lang == 'fa' %}انواع Grant{% else %}Grant Types{% endif %}</p>
<div class="flex flex-wrap gap-1">
{% for grant in client.grant_types %}
<span class="px-2 py-0.5 bg-purple-500/20 text-purple-400 text-xs rounded">{{ grant }}</span>
@@ -168,7 +168,7 @@
<svg class="w-12 h-12 mx-auto mb-4 text-gray-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M17 20h5v-2a3 3 0 00-5.356-1.857M17 20H7m10 0v-2c0-.656-.126-1.283-.356-1.857M7 20H2v-2a3 3 0 015.356-1.857M7 20v-2c0-.656.126-1.283.356-1.857m0 0a5.002 5.002 0 019.288 0M15 7a3 3 0 11-6 0 3 3 0 016 0z"/>
</svg>
<p class="text-gray-400 mb-4">
<p class="text-gray-500 dark:text-gray-400 mb-4">
{% if lang == 'fa' %}هیچ کلاینت OAuth ثبت نشده{% else %}No OAuth clients registered{% endif %}
</p>
<button
@@ -188,63 +188,53 @@
<!-- Create Client Modal -->
<div id="createModal" class="fixed inset-0 bg-black/50 hidden items-center justify-center z-50">
<div class="bg-gray-800 rounded-xl border border-gray-700 p-6 w-full max-w-lg mx-4">
<div class="bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700 p-6 w-full max-w-lg mx-4">
<h3 class="text-xl font-semibold text-white mb-4">
{% if lang == 'fa' %}ایجاد کلاینت OAuth{% else %}Create OAuth Client{% endif %}
</h3>
<form id="createForm" class="space-y-4">
<div>
<label class="block text-sm text-gray-400 mb-1">{% if lang == 'fa' %}نام کلاینت{% else %}Client Name{% endif %}</label>
<label class="block text-sm text-gray-500 dark:text-gray-400 mb-1">{% if lang == 'fa' %}نام کلاینت{% else %}Client Name{% endif %}</label>
<input
type="text"
name="client_name"
required
class="w-full bg-gray-700 border border-gray-600 rounded-lg px-4 py-2 text-white focus:ring-2 focus:ring-primary-500 focus:border-transparent"
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 focus:border-transparent"
placeholder="{% if lang == 'fa' %}مثال: My MCP Client{% else %}e.g., My MCP Client{% endif %}"
>
</div>
<div>
<label class="block text-sm text-gray-400 mb-1">{% if lang == 'fa' %}آدرس‌های Redirect{% else %}Redirect URIs{% endif %}</label>
<label class="block text-sm text-gray-500 dark:text-gray-400 mb-1">{% if lang == 'fa' %}آدرس‌های Redirect{% else %}Redirect URIs{% endif %}</label>
<textarea
name="redirect_uris"
required
rows="3"
class="w-full bg-gray-700 border border-gray-600 rounded-lg px-4 py-2 text-white focus:ring-2 focus:ring-primary-500 focus:border-transparent"
placeholder="https://example.com/callback&#10;https://app.example.com/oauth/callback"
></textarea>
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 focus:border-transparent"
>https://claude.ai/api/mcp/auth_callback
https://claude.com/api/mcp/auth_callback</textarea>
<p class="text-xs text-gray-500 mt-1">
{% if lang == 'fa' %}هر آدرس را در یک خط جداگانه وارد کنید{% else %}Enter one URI per line{% endif %}
</p>
</div>
<div>
<label class="block text-sm text-gray-400 mb-1">{% if lang == 'fa' %}دامنه‌ها{% else %}Scopes{% endif %}</label>
<div class="flex flex-wrap gap-2">
<label class="flex items-center gap-2">
<input type="checkbox" name="scopes" value="read" checked class="rounded bg-gray-700 border-gray-600 text-primary-500">
<span class="text-sm text-gray-300">read</span>
</label>
<label class="flex items-center gap-2">
<input type="checkbox" name="scopes" value="write" class="rounded bg-gray-700 border-gray-600 text-primary-500">
<span class="text-sm text-gray-300">write</span>
</label>
<label class="flex items-center gap-2">
<input type="checkbox" name="scopes" value="admin" class="rounded bg-gray-700 border-gray-600 text-primary-500">
<span class="text-sm text-gray-300">admin</span>
</label>
<div class="flex items-center gap-3">
<span class="px-3 py-1 bg-green-100 dark:bg-green-500/20 text-green-700 dark:text-green-400 text-sm font-medium rounded-lg">
{% if lang == 'fa' %}دسترسی کامل{% else %}Full Access{% endif %}
</span>
<p class="text-xs text-gray-500 dark:text-gray-400">
{% if lang == 'fa' %}دسترسی ابزار در تنظیمات هر سایت کنترل می‌شود.{% else %}Tool access is controlled per-site in Site Settings.{% endif %}
</p>
</div>
<p class="text-xs text-gray-500 mt-1">
{% if lang == 'fa' %}admin: دسترسی کامل به همه عملیات{% else %}admin: Full access to all operations{% endif %}
</p>
</div>
<div class="flex justify-end gap-3 pt-4">
<button
type="button"
onclick="closeCreateModal()"
class="px-4 py-2 bg-gray-700 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-white text-sm transition-colors"
>
{{ t.cancel }}
</button>
@@ -261,7 +251,7 @@
<!-- Success Modal (shows client secret) -->
<div id="successModal" class="fixed inset-0 bg-black/50 hidden items-center justify-center z-50">
<div class="bg-gray-800 rounded-xl border border-gray-700 p-6 w-full max-w-lg mx-4">
<div class="bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700 p-6 w-full max-w-lg mx-4">
<div class="text-center">
<div class="w-16 h-16 bg-green-500/20 rounded-full flex items-center justify-center mx-auto mb-4">
<svg class="w-8 h-8 text-green-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
@@ -271,16 +261,16 @@
<h3 class="text-xl font-semibold text-white mb-2">
{% if lang == 'fa' %}کلاینت ایجاد شد{% else %}Client Created{% endif %}
</h3>
<p class="text-sm text-gray-400 mb-4">
<p class="text-sm text-gray-500 dark:text-gray-400 mb-4">
{% if lang == 'fa' %}این رمز فقط یکبار نمایش داده می‌شود. آن را در جای امنی ذخیره کنید.{% else %}This secret is only shown once. Save it in a secure place.{% endif %}
</p>
</div>
<div class="space-y-3 mb-6">
<div>
<label class="block text-sm text-gray-400 mb-1">Client ID</label>
<label class="block text-sm text-gray-500 dark:text-gray-400 mb-1">Client ID</label>
<div class="flex items-center gap-2">
<code id="newClientId" class="flex-1 bg-gray-900 px-3 py-2 rounded text-sm text-gray-300 overflow-x-auto"></code>
<code id="newClientId" class="flex-1 bg-gray-900 px-3 py-2 rounded text-sm text-gray-700 dark:text-gray-300 overflow-x-auto"></code>
<button onclick="copyToClipboard('newClientId')" class="p-2 text-gray-400 hover:text-white">
<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="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z"/>
@@ -289,7 +279,7 @@
</div>
</div>
<div>
<label class="block text-sm text-gray-400 mb-1">Client Secret</label>
<label class="block text-sm text-gray-500 dark:text-gray-400 mb-1">Client Secret</label>
<div class="flex items-center gap-2">
<code id="newClientSecret" class="flex-1 bg-gray-900 px-3 py-2 rounded text-sm text-green-400 overflow-x-auto"></code>
<button onclick="copyToClipboard('newClientSecret')" class="p-2 text-gray-400 hover:text-white">
@@ -312,7 +302,7 @@
<!-- Delete Confirmation Modal -->
<div id="deleteModal" class="fixed inset-0 bg-black/50 hidden items-center justify-center z-50">
<div class="bg-gray-800 rounded-xl border border-gray-700 p-6 w-full max-w-md mx-4">
<div class="bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700 p-6 w-full max-w-md mx-4">
<div class="text-center">
<div class="w-16 h-16 bg-red-500/20 rounded-full flex items-center justify-center mx-auto mb-4">
<svg class="w-8 h-8 text-red-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
@@ -322,7 +312,7 @@
<h3 class="text-xl font-semibold text-white mb-2">
{% if lang == 'fa' %}حذف کلاینت{% else %}Delete Client{% endif %}
</h3>
<p class="text-gray-400 mb-1">
<p class="text-gray-500 dark:text-gray-400 mb-1">
{% if lang == 'fa' %}آیا مطمئن هستید که می‌خواهید این کلاینت را حذف کنید؟{% else %}Are you sure you want to delete this client?{% endif %}
</p>
<p id="deleteClientName" class="text-white font-medium mb-4"></p>
@@ -331,7 +321,7 @@
<div class="flex gap-3">
<button
onclick="closeDeleteModal()"
class="flex-1 px-4 py-2 bg-gray-700 hover:bg-gray-600 rounded-lg text-white text-sm transition-colors"
class="flex-1 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"
>
{{ t.cancel }}
</button>
@@ -426,10 +416,8 @@
e.preventDefault();
var formData = new FormData(this);
var scopes = [];
document.querySelectorAll('input[name="scopes"]:checked').forEach(function(cb) {
scopes.push(cb.value);
});
// F.7c: All clients get full scope — tool access controlled per-site
var scopes = ['read', 'write', 'admin'];
// Parse redirect URIs (one per line)
var redirectUrisText = formData.get('redirect_uris') || '';

View File

@@ -0,0 +1,77 @@
<!-- Vazirmatn Font for Persian -->
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Vazirmatn:wght@100;200;300;400;500;600;700;800;900&display=swap"
rel="stylesheet">
<!-- Tailwind CSS (pre-built) -->
<link rel="stylesheet" href="/static/tailwind.min.css">
<!-- HTMX for dynamic updates -->
<script src="https://unpkg.com/htmx.org@1.9.10"></script>
<!-- Alpine.js for simple interactions -->
<script defer src="https://unpkg.com/alpinejs@3.14.8/dist/cdn.min.js"></script>
<!-- Theme Initialization -->
<script>
if (localStorage.theme === 'dark' || (!('theme' in localStorage) && window.matchMedia('(prefers-color-scheme: dark)').matches)) {
document.documentElement.classList.add('dark');
} else {
document.documentElement.classList.remove('dark');
}
</script>
<!-- Global CSRF Interceptor -->
<script>
document.addEventListener('DOMContentLoaded', () => {
const getCsrfToken = () => document.querySelector('meta[name="csrf-token"]')?.content;
// Intercept HTMX requests
document.body.addEventListener('htmx:configRequest', (event) => {
const token = getCsrfToken();
if (token && event.detail.verb !== 'get') {
event.detail.headers['X-CSRF-Token'] = token;
}
});
// Intercept standard fetch requests
const originalFetch = window.fetch;
window.fetch = async function (resource, init) {
const token = getCsrfToken();
if (token) {
const url = typeof resource === 'string' ? new URL(resource, window.location.origin) : resource;
if (url.origin === window.location.origin) {
init = init || {};
init.headers = init.headers || {};
const method = (init.method || 'GET').toUpperCase();
if (method !== 'GET' && method !== 'HEAD' && method !== 'OPTIONS') {
if (init.headers instanceof Headers) {
init.headers.append('X-CSRF-Token', token);
} else if (Array.isArray(init.headers)) {
init.headers.push(['X-CSRF-Token', token]);
} else {
init.headers['X-CSRF-Token'] = token;
}
}
}
}
return originalFetch.call(this, resource, init);
};
});
</script>
<style>
[x-cloak] {
display: none !important;
}
/* Vazirmatn font for Persian */
html[lang="fa"],
html[lang="fa"] *,
[lang="fa"],
[lang="fa"] * {
font-family: 'Vazirmatn', system-ui, sans-serif !important;
}
</style>

View File

@@ -0,0 +1,90 @@
{% extends "dashboard/base.html" %}
{% block title %}{% if lang == 'fa' %}پروفایل{% else %}Profile{% endif %}{% endblock %}
{% block page_title %}{% if lang == 'fa' %}پروفایل{% else %}Profile{% endif %}{% endblock %}
{% block content %}
<div class="max-w-2xl mx-auto space-y-6">
<!-- User Info Card -->
<div class="bg-white dark:bg-gray-800 rounded-xl p-6 border border-gray-200 dark:border-gray-700">
<div class="flex items-center space-x-4 {% if lang == 'fa' %}space-x-reverse{% endif %}">
{% if user and user.avatar_url %}
<img src="{{ user.avatar_url }}" alt="{{ user.name or user.email }}" class="w-16 h-16 rounded-full border-2 border-purple-500">
{% else %}
<div class="w-16 h-16 rounded-full bg-purple-600 flex items-center justify-center text-white text-xl font-bold">
{{ (session.name or session.email or "?")[0]|upper }}
</div>
{% endif %}
<div>
<h2 class="text-xl font-semibold text-gray-900 dark:text-white">{{ user.name or session.name or "User" }}</h2>
<p class="text-gray-500 dark:text-gray-400">{{ user.email or session.email }}</p>
{% if user %}
<div class="flex items-center mt-1 space-x-2 {% if lang == 'fa' %}space-x-reverse{% endif %}">
<span class="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium
{% if user.provider == 'github' %}bg-gray-100 dark:bg-gray-700 text-gray-700 dark:text-gray-300{% else %}bg-blue-100 dark:bg-blue-900 text-blue-700 dark:text-blue-300{% endif %}">
{% if user.provider == 'github' %}
<svg class="w-3 h-3 {% if lang == 'fa' %}ml-1{% else %}mr-1{% endif %}" fill="currentColor" viewBox="0 0 24 24"><path d="M12 0c-6.626 0-12 5.373-12 12 0 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.23.957-.266 1.983-.399 3.003-.404 1.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.576 4.765-1.589 8.199-6.086 8.199-11.386 0-6.627-5.373-12-12-12z"/></svg>
{% else %}
<svg class="w-3 h-3 {% if lang == 'fa' %}ml-1{% else %}mr-1{% endif %}" fill="currentColor" viewBox="0 0 24 24"><path 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.09z"/></svg>
{% endif %}
{{ user.provider|title }}
</span>
<span class="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-purple-100 dark:bg-purple-900 text-purple-700 dark:text-purple-300">
{{ user.role|title }}
</span>
</div>
{% endif %}
</div>
</div>
</div>
<!-- Account Details -->
{% if user %}
<div class="bg-white dark:bg-gray-800 rounded-xl p-6 border border-gray-200 dark:border-gray-700">
<h3 class="text-lg font-semibold text-gray-900 dark:text-white mb-4">
{% if lang == 'fa' %}جزئیات حساب{% else %}Account Details{% endif %}
</h3>
<dl class="space-y-3">
<div class="flex justify-between">
<dt class="text-gray-500 dark:text-gray-400">{% if lang == 'fa' %}شناسه کاربر{% else %}User ID{% endif %}</dt>
<dd class="text-gray-700 dark:text-gray-200 font-mono text-sm">{{ user.id[:8] }}...</dd>
</div>
<div class="flex justify-between">
<dt class="text-gray-500 dark:text-gray-400">{% if lang == 'fa' %}ارائه‌دهنده ورود{% else %}Login Provider{% endif %}</dt>
<dd class="text-gray-700 dark:text-gray-200">{{ user.provider|title }}</dd>
</div>
<div class="flex justify-between">
<dt class="text-gray-500 dark:text-gray-400">{% if lang == 'fa' %}تاریخ عضویت{% else %}Joined{% endif %}</dt>
<dd class="text-gray-700 dark:text-gray-200">{{ user.created_at[:10] if user.created_at else "Unknown" }}</dd>
</div>
<div class="flex justify-between">
<dt class="text-gray-500 dark:text-gray-400">{% if lang == 'fa' %}آخرین ورود{% else %}Last Login{% endif %}</dt>
<dd class="text-gray-700 dark:text-gray-200">{{ user.last_login[:10] if user.last_login else "Never" }}</dd>
</div>
</dl>
<!-- Account linking note -->
<div class="mt-4 p-3 bg-blue-50 dark:bg-blue-500/10 border border-blue-200 dark:border-blue-500/30 rounded-lg">
<p class="text-xs text-blue-700 dark:text-blue-300">
{% if lang == 'fa' %}
حساب شما به آدرس ایمیل {{ user.email }} متصل است. می‌توانید با هر ارائه‌دهنده‌ای که از این ایمیل استفاده می‌کند وارد شوید.
{% else %}
Your account is linked to {{ user.email }}. You can sign in with any provider that uses this email address.
{% endif %}
</p>
</div>
</div>
{% endif %}
<!-- Logout -->
<div class="text-center">
<a href="/auth/logout" class="inline-flex items-center px-4 py-2 text-red-600 dark:text-red-400 hover:text-red-500 dark:hover:text-red-300 border border-red-200 dark:border-red-500/30 rounded-lg hover:bg-red-50 dark:hover:bg-red-500/10 transition-colors">
<svg class="w-5 h-5 {% 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="M17 16l4-4m0 0l-4-4m4 4H7m6 4v1a3 3 0 01-3 3H6a3 3 0 01-3-3V7a3 3 0 013-3h4a3 3 0 013 3v1"/>
</svg>
{% if lang == 'fa' %}خروج{% else %}Sign Out{% endif %}
</a>
</div>
</div>
{% endblock %}

View File

@@ -3,7 +3,7 @@
{% block title %}{{ project.alias or project.site_id }} - {{ t.projects }}{% endblock %}
{% block page_title %}
<div class="flex items-center">
<a href="/dashboard/projects{% if lang and lang != 'en' %}?lang={{ lang }}{% endif %}" class="text-gray-400 hover:text-white {% if lang == 'fa' %}ml-3{% else %}mr-3{% endif %} transition-colors">
<a href="/dashboard/projects{% if lang and lang != 'en' %}?lang={{ lang }}{% endif %}" class="text-gray-500 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white {% if lang == 'fa' %}ml-3{% else %}mr-3{% endif %} transition-colors">
<svg class="w-5 h-5 {% if lang == 'fa' %}rotate-180{% 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>
@@ -17,10 +17,10 @@
<!-- Stats Cards -->
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6">
<!-- Status Card -->
<div class="card-hover bg-gray-800 rounded-xl p-6 border border-gray-700">
<div class="card-hover bg-white dark:bg-gray-800 rounded-xl p-6 border border-gray-200 dark:border-gray-700">
<div class="flex items-center justify-between">
<div>
<p class="text-sm font-medium text-gray-400">{% if lang == 'fa' %}وضعیت{% else %}Status{% endif %}</p>
<p class="text-sm font-medium text-gray-500 dark:text-gray-400">{% if lang == 'fa' %}وضعیت{% else %}Status{% endif %}</p>
<div class="flex items-center mt-2">
{% if project.health.status == 'healthy' %}
<span class="w-3 h-3 bg-green-500 rounded-full status-pulse {% if lang == 'fa' %}ml-2{% else %}mr-2{% endif %}"></span>
@@ -50,18 +50,18 @@
</div>
</div>
{% if project.health.error_rate and project.health.error_rate > 0 %}
<p class="mt-2 text-sm text-gray-400">{% if lang == 'fa' %}نرخ خطا:{% else %}Error rate:{% endif %} {{ project.health.error_rate|round(1) }}%</p>
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">{% if lang == 'fa' %}نرخ خطا:{% else %}Error rate:{% endif %} {{ project.health.error_rate|round(1) }}%</p>
{% elif project.health.last_check %}
<p class="mt-2 text-sm text-gray-400">{% if lang == 'fa' %}آخرین بررسی:{% else %}Last check:{% endif %} {{ project.health.last_check[:19] }}</p>
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">{% if lang == 'fa' %}آخرین بررسی:{% else %}Last check:{% endif %} {{ project.health.last_check[:19] }}</p>
{% endif %}
</div>
<!-- Tools Card -->
<div class="card-hover bg-gray-800 rounded-xl p-6 border border-gray-700">
<div class="card-hover bg-white dark:bg-gray-800 rounded-xl p-6 border border-gray-200 dark:border-gray-700">
<div class="flex items-center justify-between">
<div>
<p class="text-sm font-medium text-gray-400">{% if lang == 'fa' %}ابزارها{% else %}Tools{% endif %}</p>
<p class="text-3xl font-bold text-white mt-1">{{ project.tools_count }}</p>
<p class="text-sm font-medium text-gray-500 dark:text-gray-400">{% if lang == 'fa' %}ابزارها{% else %}Tools{% endif %}</p>
<p class="text-3xl font-bold text-gray-900 dark:text-white mt-1">{{ project.tools_count }}</p>
</div>
<div class="w-12 h-12 bg-purple-500/20 rounded-lg flex items-center justify-center">
<svg class="w-6 h-6 text-purple-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
@@ -69,15 +69,15 @@
</svg>
</div>
</div>
<p class="mt-2 text-sm text-gray-400">{% if lang == 'fa' %}ابزار موجود{% else %}available{% endif %}</p>
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">{% if lang == 'fa' %}ابزار موجود{% else %}available{% endif %}</p>
</div>
<!-- API Keys Card -->
<div class="card-hover bg-gray-800 rounded-xl p-6 border border-gray-700">
<div class="card-hover bg-white dark:bg-gray-800 rounded-xl p-6 border border-gray-200 dark:border-gray-700">
<div class="flex items-center justify-between">
<div>
<p class="text-sm font-medium text-gray-400">{% if lang == 'fa' %}کلیدهای API{% else %}API Keys{% endif %}</p>
<p class="text-3xl font-bold text-white mt-1">{{ project.api_keys_count }}</p>
<p class="text-sm font-medium text-gray-500 dark:text-gray-400">{% if lang == 'fa' %}کلیدهای API{% else %}API Keys{% endif %}</p>
<p class="text-3xl font-bold text-gray-900 dark:text-white mt-1">{{ project.api_keys_count }}</p>
</div>
<div class="w-12 h-12 bg-green-500/20 rounded-lg flex items-center justify-center">
<svg class="w-6 h-6 text-green-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
@@ -91,11 +91,11 @@
</div>
<!-- Requests Card -->
<div class="card-hover bg-gray-800 rounded-xl p-6 border border-gray-700">
<div class="card-hover bg-white dark:bg-gray-800 rounded-xl p-6 border border-gray-200 dark:border-gray-700">
<div class="flex items-center justify-between">
<div>
<p class="text-sm font-medium text-gray-400">{% if lang == 'fa' %}درخواست‌ها{% else %}Requests{% endif %}</p>
<p class="text-3xl font-bold text-white mt-1">{{ project.requests_24h }}</p>
<p class="text-sm font-medium text-gray-500 dark:text-gray-400">{% if lang == 'fa' %}درخواست‌ها{% else %}Requests{% endif %}</p>
<p class="text-3xl font-bold text-gray-900 dark:text-white mt-1">{{ project.requests_24h }}</p>
</div>
<div class="w-12 h-12 bg-blue-500/20 rounded-lg flex items-center justify-center">
<svg class="w-6 h-6 text-blue-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
@@ -103,7 +103,7 @@
</svg>
</div>
</div>
<p class="mt-2 text-sm text-gray-400">{% if lang == 'fa' %}در 24 ساعت{% else %}/24h{% endif %}</p>
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">{% if lang == 'fa' %}در 24 ساعت{% else %}/24h{% endif %}</p>
</div>
</div>
@@ -112,33 +112,33 @@
<!-- Configuration -->
<div class="lg:col-span-2 space-y-6">
<!-- Config Section -->
<div class="bg-gray-800 rounded-xl border border-gray-700">
<div class="p-6 border-b border-gray-700">
<h2 class="text-lg font-semibold text-white">{% if lang == 'fa' %}تنظیمات{% else %}Configuration{% endif %}</h2>
<div class="bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700">
<div class="p-6 border-b border-gray-200 dark:border-gray-700">
<h2 class="text-lg font-semibold text-gray-900 dark:text-white">{% if lang == 'fa' %}تنظیمات{% else %}Configuration{% endif %}</h2>
</div>
<div class="p-6">
<dl class="space-y-4">
<div class="flex justify-between py-2 border-b border-gray-700">
<dt class="text-gray-400">{% if lang == 'fa' %}شناسه کامل{% else %}Full ID{% endif %}</dt>
<dd class="text-white font-mono">{{ project.full_id }}</dd>
<div class="flex justify-between py-2 border-b border-gray-200 dark:border-gray-700">
<dt class="text-gray-500 dark:text-gray-400">{% if lang == 'fa' %}شناسه کامل{% else %}Full ID{% endif %}</dt>
<dd class="text-gray-900 dark:text-white font-mono">{{ project.full_id }}</dd>
</div>
<div class="flex justify-between py-2 border-b border-gray-700">
<dt class="text-gray-400">{% if lang == 'fa' %}نوع پلاگین{% else %}Plugin Type{% endif %}</dt>
<dd class="text-white">{{ project.plugin_type|plugin_name }}</dd>
<div class="flex justify-between py-2 border-b border-gray-200 dark:border-gray-700">
<dt class="text-gray-500 dark:text-gray-400">{% if lang == 'fa' %}نوع پلاگین{% else %}Plugin Type{% endif %}</dt>
<dd class="text-gray-900 dark:text-white">{{ project.plugin_type|plugin_name }}</dd>
</div>
<div class="flex justify-between py-2 border-b border-gray-700">
<dt class="text-gray-400">{% if lang == 'fa' %}شناسه سایت{% else %}Site ID{% endif %}</dt>
<dd class="text-white font-mono">{{ project.site_id }}</dd>
<div class="flex justify-between py-2 border-b border-gray-200 dark:border-gray-700">
<dt class="text-gray-500 dark:text-gray-400">{% if lang == 'fa' %}شناسه سایت{% else %}Site ID{% endif %}</dt>
<dd class="text-gray-900 dark:text-white font-mono">{{ project.site_id }}</dd>
</div>
{% if project.alias %}
<div class="flex justify-between py-2 border-b border-gray-700">
<dt class="text-gray-400">{% if lang == 'fa' %}نام مستعار{% else %}Alias{% endif %}</dt>
<div class="flex justify-between py-2 border-b border-gray-200 dark:border-gray-700">
<dt class="text-gray-500 dark:text-gray-400">{% if lang == 'fa' %}نام مستعار{% else %}Alias{% endif %}</dt>
<dd class="text-primary-400 font-medium">{{ project.alias }}</dd>
</div>
{% endif %}
{% if project.url %}
<div class="flex justify-between py-2 border-b border-gray-700">
<dt class="text-gray-400">URL</dt>
<div class="flex justify-between py-2 border-b border-gray-200 dark:border-gray-700">
<dt class="text-gray-500 dark:text-gray-400">URL</dt>
<dd>
<a href="{{ project.url }}" target="_blank" class="text-blue-400 hover:text-blue-300">
{{ project.url }}
@@ -150,22 +150,22 @@
</div>
{% endif %}
<div class="flex justify-between py-2">
<dt class="text-gray-400">{% if lang == 'fa' %}اندپوینت MCP{% else %}MCP Endpoint{% endif %}</dt>
<dd class="text-white font-mono text-sm">/project/{{ project.alias or project.full_id }}/mcp</dd>
<dt class="text-gray-500 dark:text-gray-400">{% if lang == 'fa' %}اندپوینت MCP{% else %}MCP Endpoint{% endif %}</dt>
<dd class="text-gray-900 dark:text-white font-mono text-sm">/project/{{ project.alias or project.full_id }}/mcp</dd>
</div>
</dl>
</div>
</div>
<!-- Available Tools -->
<div class="bg-gray-800 rounded-xl border border-gray-700">
<div class="p-6 border-b border-gray-700 flex items-center justify-between">
<h2 class="text-lg font-semibold text-white">{% if lang == 'fa' %}ابزارهای موجود{% else %}Available Tools{% endif %}</h2>
<span class="px-2 py-1 bg-gray-700 rounded-lg text-sm text-gray-300">{{ project.tools|length }} {% if lang == 'fa' %}ابزار{% else %}tools{% endif %}</span>
<div class="bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700">
<div class="p-6 border-b border-gray-200 dark:border-gray-700 flex items-center justify-between">
<h2 class="text-lg font-semibold text-gray-900 dark:text-white">{% if lang == 'fa' %}ابزارهای موجود{% else %}Available Tools{% endif %}</h2>
<span class="px-2 py-1 bg-gray-200 dark:bg-gray-700 rounded-lg text-sm text-gray-700 dark:text-gray-300">{{ project.tools|length }} {% if lang == 'fa' %}ابزار{% else %}tools{% endif %}</span>
</div>
<div class="max-h-96 overflow-y-auto">
<table class="w-full">
<thead class="bg-gray-700/50 sticky top-0">
<thead class="bg-gray-50 dark:bg-gray-700/50 sticky top-0">
<tr>
<th class="px-4 py-2 text-{% if lang == 'fa' %}right{% else %}left{% endif %} text-xs font-medium text-gray-400 uppercase">
{% if lang == 'fa' %}نام{% else %}Name{% endif %}
@@ -178,14 +178,14 @@
</th>
</tr>
</thead>
<tbody class="divide-y divide-gray-700">
<tbody class="divide-y divide-gray-200 dark:divide-gray-700">
{% for tool in project.tools[:50] %}
<tr class="hover:bg-gray-700/30">
<tr class="hover:bg-gray-50 dark:hover:bg-gray-700/30">
<td class="px-4 py-2">
<span class="text-sm text-white font-mono">{{ tool.name }}</span>
<span class="text-sm text-gray-900 dark:text-white font-mono">{{ tool.name }}</span>
</td>
<td class="px-4 py-2">
<span class="text-sm text-gray-400 truncate block max-w-xs" title="{{ tool.description }}">
<span class="text-sm text-gray-500 dark:text-gray-400 truncate block max-w-xs" title="{{ tool.description }}">
{{ tool.description[:60] }}{% if tool.description|length > 60 %}...{% endif %}
</span>
</td>
@@ -213,9 +213,9 @@
<!-- Right Column -->
<div class="space-y-6">
<!-- Quick Actions -->
<div class="bg-gray-800 rounded-xl border border-gray-700">
<div class="p-6 border-b border-gray-700">
<h2 class="text-lg font-semibold text-white">{% if lang == 'fa' %}عملیات سریع{% else %}Quick Actions{% endif %}</h2>
<div class="bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700">
<div class="p-6 border-b border-gray-200 dark:border-gray-700">
<h2 class="text-lg font-semibold text-gray-900 dark:text-white">{% if lang == 'fa' %}عملیات سریع{% else %}Quick Actions{% endif %}</h2>
</div>
<!-- Health Check Result Display -->
<div id="health-check-result" class="hidden p-4 rounded-lg mb-2"></div>
@@ -227,7 +227,7 @@
hx-swap="none"
hx-on::before-request="showHealthCheckLoading()"
hx-on::after-request="handleHealthCheckResponse(event)"
class="w-full flex items-center justify-between px-4 py-3 bg-gray-700 hover:bg-gray-600 rounded-lg text-white transition-colors"
class="w-full flex items-center justify-between px-4 py-3 bg-gray-200 dark:bg-gray-700 hover:bg-gray-300 dark:hover:bg-gray-600 rounded-lg text-white transition-colors"
>
<span id="health-check-text">{% if lang == 'fa' %}بررسی سلامت{% else %}Check Health{% endif %}</span>
<svg id="health-check-icon" class="w-5 h-5 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
@@ -241,7 +241,7 @@
<a
href="/dashboard/api-keys?project={{ project.full_id }}{% if lang and lang != 'en' %}&lang={{ lang }}{% endif %}"
class="w-full flex items-center justify-between px-4 py-3 bg-gray-700 hover:bg-gray-600 rounded-lg text-white transition-colors"
class="w-full flex items-center justify-between px-4 py-3 bg-gray-200 dark:bg-gray-700 hover:bg-gray-300 dark:hover:bg-gray-600 rounded-lg text-white transition-colors"
>
<span>{% if lang == 'fa' %}مدیریت کلیدها{% else %}Manage API Keys{% endif %}</span>
<svg class="w-5 h-5 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
@@ -251,7 +251,7 @@
<a
href="/dashboard/audit-logs?project={{ project.full_id }}{% if lang and lang != 'en' %}&lang={{ lang }}{% endif %}"
class="w-full flex items-center justify-between px-4 py-3 bg-gray-700 hover:bg-gray-600 rounded-lg text-white transition-colors"
class="w-full flex items-center justify-between px-4 py-3 bg-gray-200 dark:bg-gray-700 hover:bg-gray-300 dark:hover:bg-gray-600 rounded-lg text-white transition-colors"
>
<span>{% if lang == 'fa' %}مشاهده لاگ‌ها{% else %}View Logs{% endif %}</span>
<svg class="w-5 h-5 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
@@ -262,14 +262,14 @@
</div>
<!-- Recent Activity -->
<div class="bg-gray-800 rounded-xl border border-gray-700">
<div class="p-6 border-b border-gray-700">
<h2 class="text-lg font-semibold text-white">{{ t.recent_activity }}</h2>
<div class="bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700">
<div class="p-6 border-b border-gray-200 dark:border-gray-700">
<h2 class="text-lg font-semibold text-gray-900 dark:text-white">{{ t.recent_activity }}</h2>
</div>
<div class="divide-y divide-gray-700">
<div class="divide-y divide-gray-200 dark:divide-gray-700">
{% if project.recent_activity %}
{% for activity in project.recent_activity[:5] %}
<div class="p-4 hover:bg-gray-700/30">
<div class="p-4 hover:bg-gray-50 dark:hover:bg-gray-700/30">
<div class="flex items-center justify-between">
<div class="flex items-center">
{% if activity.level == 'ERROR' %}
@@ -277,7 +277,7 @@
{% else %}
<span class="w-2 h-2 bg-green-500 rounded-full {% if lang == 'fa' %}ml-2{% else %}mr-2{% endif %}"></span>
{% endif %}
<span class="text-sm text-white">{{ activity.message }}</span>
<span class="text-sm text-gray-900 dark:text-white">{{ activity.message }}</span>
</div>
<span class="text-xs text-gray-500">{{ activity.timestamp[:16] }}</span>
</div>
@@ -343,19 +343,19 @@
resultDiv.classList.add('bg-green-500/20');
resultDiv.innerHTML = '<p class="text-green-400 font-medium">' +
(lang === 'fa' ? '✓ سالم' : '✓ Healthy') +
'</p><p class="text-sm text-gray-400">' +
'</p><p class="text-sm text-gray-500 dark:text-gray-400">' +
(lang === 'fa' ? 'زمان پاسخ: ' : 'Response time: ') +
(response.response_time_ms || 0).toFixed(2) + ' ms</p>';
} else if (response.status === 'unhealthy') {
resultDiv.classList.add('bg-red-500/20');
resultDiv.innerHTML = '<p class="text-red-400 font-medium">' +
(lang === 'fa' ? '✗ ناسالم' : '✗ Unhealthy') +
'</p><p class="text-sm text-gray-400">' + (response.message || '') + '</p>';
'</p><p class="text-sm text-gray-500 dark:text-gray-400">' + (response.message || '') + '</p>';
} else {
resultDiv.classList.add('bg-yellow-500/20');
resultDiv.innerHTML = '<p class="text-yellow-400 font-medium">' +
(lang === 'fa' ? '⚠ خطا' : '⚠ Error') +
'</p><p class="text-sm text-gray-400">' + (response.message || 'Unknown error') + '</p>';
'</p><p class="text-sm text-gray-500 dark:text-gray-400">' + (response.message || 'Unknown error') + '</p>';
}
// Auto-hide after 5 seconds

View File

@@ -6,20 +6,17 @@
{% block content %}
<div class="space-y-6">
<!-- Filters -->
<div class="bg-gray-800 rounded-xl border border-gray-700 p-4">
<div class="bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700 p-4">
<form method="GET" class="flex flex-wrap items-center gap-4">
{% if lang and lang != 'en' %}<input type="hidden" name="lang" value="{{ lang }}">{% endif %}
<!-- Plugin Type Filter -->
<div class="flex items-center gap-2">
<label class="text-sm text-gray-400">{{ t.filter }}:</label>
<select
name="plugin_type"
onchange="this.form.submit()"
class="px-3 py-2 bg-gray-700 border border-gray-600 rounded-lg text-white text-sm focus:outline-none focus:border-primary-500"
>
<label class="text-sm text-gray-500 dark:text-gray-400">{{ t.filter }}:</label>
<select name="plugin_type" onchange="this.form.submit()"
class="px-3 py-2 bg-gray-50 dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-lg text-gray-900 dark:text-white text-sm focus:outline-none focus:border-primary-500">
<option value="">{{ t.all }} Types</option>
{% for plugin_type in available_plugin_types %}
<option value="{{ plugin_type }}" {% if selected_plugin_type == plugin_type %}selected{% endif %}>
<option value="{{ plugin_type }}" {% if selected_plugin_type==plugin_type %}selected{% endif %}>
{{ plugin_type|plugin_name }}
</option>
{% endfor %}
@@ -28,36 +25,34 @@
<!-- Status Filter -->
<div class="flex items-center gap-2">
<select
name="status"
onchange="this.form.submit()"
class="px-3 py-2 bg-gray-700 border border-gray-600 rounded-lg text-white text-sm focus:outline-none focus:border-primary-500"
>
<select name="status" onchange="this.form.submit()"
class="px-3 py-2 bg-gray-50 dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-lg text-gray-900 dark:text-white text-sm focus:outline-none focus:border-primary-500">
<option value="">{% if lang == 'fa' %}همه وضعیت‌ها{% else %}All Status{% endif %}</option>
<option value="healthy" {% if selected_status == 'healthy' %}selected{% endif %}>{{ t.healthy }}</option>
<option value="warning" {% if selected_status == 'warning' %}selected{% endif %}>{% if lang == 'fa' %}هشدار{% else %}Warning{% endif %}</option>
<option value="unhealthy" {% if selected_status == 'unhealthy' %}selected{% endif %}>{% if lang == 'fa' %}ناسالم{% else %}Unhealthy{% endif %}</option>
<option value="unknown" {% if selected_status == 'unknown' %}selected{% endif %}>{% if lang == 'fa' %}نامشخص{% else %}Unknown{% endif %}</option>
<option value="healthy" {% if selected_status=='healthy' %}selected{% endif %}>{{ t.healthy }}
</option>
<option value="warning" {% if selected_status=='warning' %}selected{% endif %}>{% if lang == 'fa'
%}هشدار{% else %}Warning{% endif %}</option>
<option value="unhealthy" {% if selected_status=='unhealthy' %}selected{% endif %}>{% if lang ==
'fa' %}ناسالم{% else %}Unhealthy{% endif %}</option>
<option value="unknown" {% if selected_status=='unknown' %}selected{% endif %}>{% if lang == 'fa'
%}نامشخص{% else %}Unknown{% endif %}</option>
</select>
</div>
<!-- Search -->
<div class="flex-1 min-w-[200px]">
<input
type="text"
name="search"
value="{{ search_query }}"
placeholder="{{ t.search }}..."
class="w-full px-3 py-2 bg-gray-700 border border-gray-600 rounded-lg text-white text-sm placeholder-gray-400 focus:outline-none focus:border-primary-500"
>
<input type="text" name="search" value="{{ search_query }}" placeholder="{{ t.search }}..."
class="w-full px-3 py-2 bg-gray-50 dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-lg text-gray-900 dark:text-white text-sm placeholder-gray-400 focus:outline-none focus:border-primary-500">
</div>
<button type="submit" class="px-4 py-2 bg-primary-600 hover:bg-primary-700 rounded-lg text-white text-sm transition-colors">
<button type="submit"
class="px-4 py-2 bg-primary-600 hover:bg-primary-700 rounded-lg text-white text-sm transition-colors">
{{ t.search }}
</button>
{% 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-700 hover:bg-gray-600 rounded-lg text-white text-sm transition-colors">
<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-gray-800 dark:text-white text-sm transition-colors">
{{ t['clear'] }}
</a>
{% endif %}
@@ -65,149 +60,185 @@
</div>
<!-- Projects Table -->
<div class="bg-gray-800 rounded-xl border border-gray-700 overflow-hidden">
<div class="bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700 overflow-hidden">
<div class="overflow-x-auto">
<table class="w-full">
<thead class="bg-gray-700/50">
<thead class="bg-gray-50 dark:bg-gray-700/50">
<tr>
<th class="px-6 py-4 text-{% if lang == 'fa' %}right{% else %}left{% endif %} text-sm font-medium text-gray-300">
<th
class="px-6 py-4 text-{% if lang == 'fa' %}right{% else %}left{% endif %} text-sm font-medium text-gray-700 dark:text-gray-300">
{% if lang == 'fa' %}نوع{% else %}Plugin{% endif %}
</th>
<th class="px-6 py-4 text-{% if lang == 'fa' %}right{% else %}left{% endif %} text-sm font-medium text-gray-300">
<th
class="px-6 py-4 text-{% if lang == 'fa' %}right{% else %}left{% endif %} text-sm font-medium text-gray-700 dark:text-gray-300">
{% if lang == 'fa' %}شناسه{% else %}Site ID{% endif %}
</th>
<th class="px-6 py-4 text-{% if lang == 'fa' %}right{% else %}left{% endif %} text-sm font-medium text-gray-300">
<th
class="px-6 py-4 text-{% if lang == 'fa' %}right{% else %}left{% endif %} text-sm font-medium text-gray-700 dark:text-gray-300">
{% if lang == 'fa' %}نام مستعار{% else %}Alias{% endif %}
</th>
<th class="px-6 py-4 text-{% if lang == 'fa' %}right{% else %}left{% endif %} text-sm font-medium text-gray-300">
<th
class="px-6 py-4 text-{% if lang == 'fa' %}right{% else %}left{% endif %} text-sm font-medium text-gray-700 dark:text-gray-300">
URL
</th>
<th class="px-6 py-4 text-{% if lang == 'fa' %}right{% else %}left{% endif %} text-sm font-medium text-gray-300">
<th
class="px-6 py-4 text-{% if lang == 'fa' %}right{% else %}left{% endif %} text-sm font-medium text-gray-700 dark:text-gray-300">
{% if lang == 'fa' %}وضعیت{% else %}Status{% endif %}
</th>
<th class="px-6 py-4 text-{% if lang == 'fa' %}right{% else %}left{% endif %} text-sm font-medium text-gray-300">
<th
class="px-6 py-4 text-{% if lang == 'fa' %}right{% else %}left{% endif %} text-sm font-medium text-gray-700 dark:text-gray-300">
{% if lang == 'fa' %}عملیات{% else %}Actions{% endif %}
</th>
</tr>
</thead>
<tbody class="divide-y divide-gray-700">
<tbody class="divide-y divide-gray-200 dark:divide-gray-700">
{% if projects %}
{% for project in projects %}
<tr class="hover:bg-gray-700/30 transition-colors">
<!-- Plugin Type -->
<td class="px-6 py-4">
<div class="flex items-center">
{% set plugin_colors = {
'wordpress': 'bg-blue-500',
'woocommerce': 'bg-purple-500',
'wordpress_advanced': 'bg-indigo-500',
'gitea': 'bg-green-500',
'n8n': 'bg-orange-500',
'supabase': 'bg-emerald-500',
'openpanel': 'bg-cyan-500',
'appwrite': 'bg-pink-500',
'directus': 'bg-violet-500',
} %}
{% set plugin_icons = {
'wordpress': 'W',
'woocommerce': 'WC',
'wordpress_advanced': 'WA',
'gitea': 'G',
'n8n': 'n8n',
'supabase': 'SB',
'openpanel': 'OP',
'appwrite': 'AW',
'directus': 'DI',
} %}
<div class="w-8 h-8 {{ plugin_colors.get(project.plugin_type, 'bg-gray-500') }} rounded-lg flex items-center justify-center {% if lang == 'fa' %}ml-3{% else %}mr-3{% endif %}">
<span class="text-xs font-bold text-white">{{ plugin_icons.get(project.plugin_type, project.plugin_type[:2]|upper) }}</span>
</div>
<span class="text-sm text-white">{{ project.plugin_type|plugin_name }}</span>
{% for project in projects %}
<tr class="hover:bg-gray-50 dark:hover:bg-gray-700/30 transition-colors">
<!-- Plugin Type -->
<td class="px-6 py-4">
<div class="flex items-center">
{% set plugin_colors = {
'wordpress': 'bg-blue-500',
'woocommerce': 'bg-purple-500',
'wordpress_advanced': 'bg-indigo-500',
'gitea': 'bg-green-500',
'n8n': 'bg-orange-500',
'supabase': 'bg-emerald-500',
'openpanel': 'bg-cyan-500',
'appwrite': 'bg-pink-500',
'directus': 'bg-violet-500',
} %}
{% set plugin_icons = {
'wordpress': 'W',
'woocommerce': 'WC',
'wordpress_advanced': 'WA',
'gitea': 'G',
'n8n': 'n8n',
'supabase': 'SB',
'openpanel': 'OP',
'appwrite': 'AW',
'directus': 'DI',
} %}
<div
class="w-8 h-8 {{ plugin_colors.get(project.plugin_type, 'bg-gray-500') }} rounded-lg flex items-center justify-center {% if lang == 'fa' %}ml-3{% else %}mr-3{% endif %}">
<span class="text-xs font-bold text-white">{{ plugin_icons.get(project.plugin_type,
project.plugin_type[:2]|upper) }}</span>
</div>
</td>
<span class="text-sm text-gray-900 dark:text-white">{{ project.plugin_type|plugin_name }}</span>
</div>
</td>
<!-- Site ID -->
<td class="px-6 py-4">
<span class="text-sm text-gray-300 font-mono">{{ project.site_id }}</span>
</td>
<!-- Site ID -->
<td class="px-6 py-4">
<span class="text-sm text-gray-700 dark:text-gray-300 font-mono">{{ project.site_id }}</span>
</td>
<!-- Alias -->
<td class="px-6 py-4">
{% if project.alias %}
<span class="px-2 py-1 bg-primary-500/20 text-primary-400 text-xs rounded-lg font-medium">
{{ project.alias }}
</span>
{% else %}
<span class="text-gray-500 text-sm">-</span>
{% endif %}
</td>
<!-- Alias -->
<td class="px-6 py-4">
{% if project.alias %}
<span class="px-2 py-1 bg-primary-500/20 text-primary-400 text-xs rounded-lg font-medium">
{{ project.alias }}
</span>
{% else %}
<span class="text-gray-500 text-sm">-</span>
{% endif %}
</td>
<!-- URL -->
<td class="px-6 py-4">
{% if project.url %}
<a href="{{ project.url }}" target="_blank" class="text-sm text-blue-400 hover:text-blue-300 truncate max-w-[200px] block" title="{{ project.url }}">
{{ project.url[:40] }}{% if project.url|length > 40 %}...{% endif %}
</a>
{% else %}
<span class="text-gray-500 text-sm">-</span>
{% endif %}
</td>
<!-- URL -->
<td class="px-6 py-4">
{% if project.url %}
<a href="{{ project.url }}" target="_blank"
class="text-sm text-blue-400 hover:text-blue-300 truncate max-w-[200px] block"
title="{{ project.url }}">
{{ project.url[:40] }}{% if project.url|length > 40 %}...{% endif %}
</a>
{% else %}
<span class="text-gray-500 text-sm">-</span>
{% endif %}
</td>
<!-- Status -->
<td class="px-6 py-4">
{% if project.health_status == 'healthy' %}
<div class="flex items-center">
<span class="w-2 h-2 bg-green-500 rounded-full status-pulse {% if lang == 'fa' %}ml-2{% else %}mr-2{% endif %}"></span>
<span class="text-sm text-green-400">{{ t.healthy }}</span>
</div>
{% elif project.health_status == 'warning' %}
<div class="flex items-center">
<span class="w-2 h-2 bg-yellow-500 rounded-full {% if lang == 'fa' %}ml-2{% else %}mr-2{% endif %}"></span>
<span class="text-sm text-yellow-400">{% if lang == 'fa' %}هشدار{% else %}Warning{% endif %}</span>
</div>
{% elif project.health_status == 'unhealthy' %}
<div class="flex items-center">
<span class="w-2 h-2 bg-red-500 rounded-full {% if lang == 'fa' %}ml-2{% else %}mr-2{% endif %}"></span>
<span class="text-sm text-red-400">{% if lang == 'fa' %}ناسالم{% else %}Unhealthy{% endif %}</span>
</div>
{% else %}
<div class="flex items-center">
<span class="w-2 h-2 bg-gray-500 rounded-full {% if lang == 'fa' %}ml-2{% else %}mr-2{% endif %}"></span>
<span class="text-sm text-gray-400">{% if lang == 'fa' %}نامشخص{% else %}Unknown{% endif %}</span>
</div>
{% endif %}
</td>
<!-- 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-700 hover:bg-gray-600 rounded-lg text-sm 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">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7"/>
<!-- Status -->
<td class="px-6 py-4">
{% if project.health_status == 'healthy' %}
<div class="flex items-center">
<span
class="w-2 h-2 bg-green-500 rounded-full status-pulse {% if lang == 'fa' %}ml-2{% else %}mr-2{% endif %}"></span>
<span class="text-sm text-green-400">{{ t.healthy }}</span>
</div>
{% elif project.health_status == 'warning' %}
<div class="flex items-center">
<span
class="w-2 h-2 bg-yellow-500 rounded-full {% if lang == 'fa' %}ml-2{% else %}mr-2{% endif %}"></span>
<span class="text-sm text-yellow-400">{% if lang == 'fa' %}هشدار{% else %}Warning{%
endif %}</span>
</div>
{% elif project.health_status == 'unhealthy' %}
<div class="flex items-center" {% if project.reason %}title="{{ project.reason }}" {% endif
%}>
<span
class="w-2 h-2 bg-red-500 rounded-full {% if lang == 'fa' %}ml-2{% else %}mr-2{% endif %}"></span>
<span class="text-sm text-red-400">
{% if lang == 'fa' %}ناسالم{% else %}Unhealthy{% endif %}
{% if project.reason %}
<svg class="w-4 h-4 inline-block ml-1 opacity-70" fill="none" stroke="currentColor"
viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"></path>
</svg>
</a>
</td>
</tr>
{% endfor %}
{% else %}
<tr>
<td colspan="6" class="px-6 py-12 text-center">
<svg class="w-12 h-12 mx-auto mb-4 text-gray-500 opacity-50" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 7v10a2 2 0 002 2h14a2 2 0 002-2V9a2 2 0 00-2-2h-6l-2-2H5a2 2 0 00-2 2z"/>
{% endif %}
</span>
</div>
{% else %}
<div class="flex items-center">
<span
class="w-2 h-2 bg-gray-500 rounded-full {% if lang == 'fa' %}ml-2{% else %}mr-2{% endif %}"></span>
<span class="text-sm text-gray-500 dark:text-gray-400">{% if lang == 'fa' %}نامشخص{% else %}Unknown{% endif
%}</span>
</div>
{% endif %}
</td>
<!-- 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-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">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M9 5l7 7-7 7" />
</svg>
<p class="text-gray-400">
{% if lang == 'fa' %}پروژه‌ای یافت نشد{% else %}No projects found{% endif %}
</p>
{% if search_query or selected_plugin_type %}
<p class="text-gray-500 text-sm mt-2">
{% if lang == 'fa' %}فیلترها را پاک کنید{% else %}Try clearing the filters{% endif %}
</p>
</a>
</td>
</tr>
{% endfor %}
{% else %}
<tr>
<td colspan="6" class="px-6 py-12 text-center">
<svg class="w-12 h-12 mx-auto mb-4 text-gray-500 opacity-50" fill="none"
stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M3 7v10a2 2 0 002 2h14a2 2 0 002-2V9a2 2 0 00-2-2h-6l-2-2H5a2 2 0 00-2 2z" />
</svg>
<p class="text-gray-500 dark:text-gray-400">
{% if lang == 'fa' %}پروژه‌ای یافت نشد{% else %}No projects found{% endif %}
</p>
{% if search_query or selected_plugin_type %}
<p class="text-gray-500 text-sm mt-2">
{% if lang == 'fa' %}فیلترها را پاک کنید{% else %}Try clearing the filters{% endif %}
</p>
{% else %}
<p class="text-gray-500 text-sm mt-2">
{% if lang == 'fa' %}
سایت‌ها از طریق <a href="/dashboard/sites{% if lang and lang != 'en' %}?lang={{ lang }}{% endif %}" class="text-primary-400 hover:underline">صفحه سایت‌ها</a> مدیریت می‌شوند.
{% else %}
Sites are managed via the <a href="/dashboard/sites" class="text-primary-400 hover:underline">My Sites</a> page.
{% endif %}
</td>
</tr>
</p>
{% endif %}
</td>
</tr>
{% endif %}
</tbody>
</table>
@@ -216,44 +247,47 @@
<!-- Pagination -->
{% if total_pages > 1 %}
<div class="px-6 py-4 border-t border-gray-700 flex items-center justify-between">
<p class="text-sm text-gray-400">
<p class="text-sm text-gray-500 dark:text-gray-400">
{% if lang == 'fa' %}
نمایش {{ ((page_number - 1) * per_page) + 1 }} تا {{ [page_number * per_page, total_count]|min }} از {{ total_count }} پروژه
نمایش {{ ((page_number - 1) * per_page) + 1 }} تا {{ [page_number * per_page, total_count]|min }} از {{
total_count }} پروژه
{% else %}
Showing {{ ((page_number - 1) * per_page) + 1 }} to {{ [page_number * per_page, total_count]|min }} of {{ total_count }} projects
Showing {{ ((page_number - 1) * per_page) + 1 }} to {{ [page_number * per_page, total_count]|min }} of
{{ total_count }} projects
{% endif %}
</p>
<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-700 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 %}
{% for page_num in range(1, total_pages + 1) %}
{% if page_num == page_number %}
<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_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-700 hover:bg-gray-600 rounded-lg text-sm text-white transition-colors">
{{ page_num }}
{% if page_num == page_number %}
<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_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-gray-800 dark:text-white transition-colors">
{{ page_num }}
</a>
{% elif page_num == page_number - 3 or page_num == page_number + 3 %}
<span class="text-gray-500">...</span>
{% endif %}
{% endfor %}
{% endfor %}
{% 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-700 hover:bg-gray-600 rounded-lg text-sm text-white transition-colors">
{% if lang == 'fa' %}بعدی{% else %}Next{% endif %}
</a>
{% endif %}
{% 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-gray-800 dark:text-white transition-colors">
{% if lang == 'fa' %}بعدی{% else %}Next{% endif %}
</a>
{% endif %}
</div>
</div>
{% endif %}
</div>
</div>
{% endblock %}
{% endblock %}

View File

@@ -0,0 +1,281 @@
{% extends "dashboard/base.html" %}
{% block title %}{{ service.display_name }} - MCP Hub{% endblock %}
{% block page_title %}{{ service.display_name }}{% endblock %}
{% block content %}
<div class="space-y-6">
<!-- Back button + Header -->
<div class="flex items-center justify-between">
<div class="flex items-center gap-3">
<a href="/dashboard/services{% if lang and lang != 'en' %}?lang={{ lang }}{% endif %}"
class="text-gray-400 hover:text-gray-300 transition-colors">
<svg class="w-5 h-5{% if lang == 'fa' %} rotate-180{% endif %}" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 19l-7-7m0 0l7-7m-7 7h18"/>
</svg>
</a>
<h2 class="text-xl font-semibold text-gray-900 dark:text-white">{{ service.display_name }}</h2>
{% if service.is_public %}
<span class="inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium bg-green-100 dark:bg-green-500/20 text-green-700 dark:text-green-300">
{% if lang == 'fa' %}عمومی{% else %}Public{% endif %}
</span>
{% else %}
<span class="inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium bg-gray-100 dark:bg-gray-500/20 text-gray-600 dark:text-gray-400">
{% if lang == 'fa' %}فقط مدیر{% else %}Admin Only{% endif %}
</span>
{% endif %}
</div>
<a href="/dashboard/sites/add?plugin_type={{ service.plugin_type }}{% if lang and lang != 'en' %}&lang={{ lang }}{% endif %}"
class="btn-primary px-4 py-2 rounded-lg text-white text-sm font-medium">
+ {{ t.get('add_site', 'Add Site') }}
</a>
</div>
<!-- Description & Notes -->
{% if service.description %}
<div class="bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700 p-6">
{% if lang == 'fa' and service.description.fa %}
<p class="text-sm text-gray-600 dark:text-gray-300 leading-relaxed" dir="rtl">{{ service.description.fa }}</p>
{% elif service.description.en %}
<p class="text-sm text-gray-600 dark:text-gray-300 leading-relaxed">{{ service.description.en }}</p>
{% endif %}
{% set notes_key = 'notes_fa' if lang == 'fa' else 'notes_en' %}
{% if service.description[notes_key] %}
<ul class="mt-4 space-y-2{% if lang == 'fa' %} text-right{% endif %}" {% if lang == 'fa' %}dir="rtl"{% endif %}>
{% for note in service.description[notes_key] %}
<li class="flex items-start gap-2 text-sm text-gray-500 dark:text-gray-400">
<svg class="w-4 h-4 text-primary-400 mt-0.5 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"/>
</svg>
<span>{{ note|safe }}</span>
</li>
{% endfor %}
</ul>
{% endif %}
</div>
{% endif %}
<!-- Overview Cards -->
{% set admin_tools = service.tools|selectattr('scope', 'equalto', 'admin')|list %}
<div class="grid grid-cols-1 md:grid-cols-{{ 4 if admin_tools|length > 0 else 3 }} gap-6">
<!-- Tools Count -->
<div class="bg-white dark:bg-gray-800 rounded-xl p-6 border border-gray-200 dark:border-gray-700">
<div class="flex items-center justify-between">
<div>
<p class="text-sm font-medium text-gray-500 dark:text-gray-400">
{% if lang == 'fa' %}تعداد ابزار{% else %}Available Tools{% endif %}
</p>
<p class="text-3xl font-bold text-gray-900 dark:text-white mt-1">{{ service.tools_count }}</p>
</div>
<div class="w-12 h-12 bg-blue-500/20 rounded-lg flex items-center justify-center">
<svg class="w-6 h-6 text-blue-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0"/>
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"/>
</svg>
</div>
</div>
</div>
<!-- Read Tools -->
{% set read_tools = service.tools|selectattr('scope', 'equalto', 'read')|list %}
<div class="bg-white dark:bg-gray-800 rounded-xl p-6 border border-gray-200 dark:border-gray-700">
<div class="flex items-center justify-between">
<div>
<p class="text-sm font-medium text-gray-500 dark:text-gray-400">
{% if lang == 'fa' %}ابزار خواندن{% else %}Read Tools{% endif %}
</p>
<p class="text-3xl font-bold text-gray-900 dark:text-white mt-1">{{ read_tools|length }}</p>
</div>
<div class="w-12 h-12 bg-green-500/20 rounded-lg flex items-center justify-center">
<svg class="w-6 h-6 text-green-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"/>
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z"/>
</svg>
</div>
</div>
</div>
<!-- Write Tools -->
{% set write_tools = service.tools|selectattr('scope', 'equalto', 'write')|list %}
<div class="bg-white dark:bg-gray-800 rounded-xl p-6 border border-gray-200 dark:border-gray-700">
<div class="flex items-center justify-between">
<div>
<p class="text-sm font-medium text-gray-500 dark:text-gray-400">
{% if lang == 'fa' %}ابزار نوشتن{% else %}Write Tools{% endif %}
</p>
<p class="text-3xl font-bold text-gray-900 dark:text-white mt-1">{{ write_tools|length }}</p>
</div>
<div class="w-12 h-12 bg-orange-500/20 rounded-lg flex items-center justify-center">
<svg class="w-6 h-6 text-orange-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z"/>
</svg>
</div>
</div>
</div>
<!-- Admin Tools (F.7d) -->
{% if admin_tools|length > 0 %}
<div class="bg-white dark:bg-gray-800 rounded-xl p-6 border border-gray-200 dark:border-gray-700">
<div class="flex items-center justify-between">
<div>
<p class="text-sm font-medium text-gray-500 dark:text-gray-400">
{% if lang == 'fa' %}ابزار مدیر{% else %}Admin Tools{% endif %}
</p>
<p class="text-3xl font-bold text-gray-900 dark:text-white mt-1">{{ admin_tools|length }}</p>
</div>
<div class="w-12 h-12 bg-red-500/20 rounded-lg flex items-center justify-center">
<svg class="w-6 h-6 text-red-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4M7.835 4.697a3.42 3.42 0 001.946-.806 3.42 3.42 0 014.438 0 3.42 3.42 0 001.946.806 3.42 3.42 0 013.138 3.138 3.42 3.42 0 00.806 1.946 3.42 3.42 0 010 4.438 3.42 3.42 0 00-.806 1.946 3.42 3.42 0 01-3.138 3.138 3.42 3.42 0 00-1.946.806 3.42 3.42 0 01-4.438 0 3.42 3.42 0 00-1.946-.806 3.42 3.42 0 01-3.138-3.138 3.42 3.42 0 00-.806-1.946 3.42 3.42 0 010-4.438 3.42 3.42 0 00.806-1.946 3.42 3.42 0 013.138-3.138z"/>
</svg>
</div>
</div>
</div>
{% endif %}
</div>
<!-- F.20 prep: Companion plugin download hint (WP / WC only) -->
{% if companion_download_url %}
<div class="bg-indigo-50 dark:bg-indigo-500/10 border border-indigo-200 dark:border-indigo-500/30 rounded-xl p-5">
<div class="flex items-start gap-3">
<div class="w-10 h-10 bg-indigo-500/20 rounded-lg flex items-center justify-center flex-shrink-0">
<svg class="w-5 h-5 text-indigo-500 dark:text-indigo-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 10v6m0 0l-3-3m3 3l3-3m2 8H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"/>
</svg>
</div>
<div class="flex-1 min-w-0">
<h4 class="text-sm font-semibold text-indigo-900 dark:text-indigo-200">
{% if lang == 'fa' %}
افزونه همراه Airano MCP Bridge (اختیاری اما توصیه‌شده)
{% else %}
Airano MCP Bridge — companion plugin (optional but recommended)
{% endif %}
</h4>
<p class="mt-1 text-xs text-indigo-800/90 dark:text-indigo-300/90 leading-relaxed">
{% if lang == 'fa' %}
نصب افزونه همراه، این قابلیت‌ها را فعال می‌کند: آپلود فایل بزرگ‌تر از
<code class="font-mono">upload_max_filesize</code>، گزارش یکپارچه‌ی سلامت سایت،
purge کش، پاکسازی transient، متاهای bulk، خروجی ساختاریافته، probe قابلیت‌ها،
و webhook لاگ ممیزی. بدون آن، ابزارهای پایه کار می‌کنند اما موارد فوق غیرفعال خواهند بود.
{% else %}
Installing the companion plugin unlocks: uploads larger than
<code class="font-mono">upload_max_filesize</code>, unified site-health snapshot,
cache purge, transient flush, bulk meta writes, structured export, capability probe,
and audit-hook webhooks. Without it, basic tools still work but those features stay disabled.
{% endif %}
</p>
<div class="mt-3 flex flex-wrap items-center gap-3">
<a href="{{ companion_download_url }}"
class="inline-flex items-center gap-1.5 px-3 py-1.5 rounded-lg bg-indigo-600 hover:bg-indigo-500 text-white text-xs font-medium transition-colors">
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4"/>
</svg>
{% if lang == 'fa' %}دانلود airano-mcp-bridge.zip{% else %}Download airano-mcp-bridge.zip{% endif %}
</a>
<span class="text-xs text-indigo-700/70 dark:text-indigo-300/70">
{% if lang == 'fa' %}
نصب: WP Admin → افزونه‌ها → افزودن → Upload Plugin → انتخاب فایل → نصب و فعال‌سازی
{% else %}
Install via WP Admin → Plugins → Add New → Upload Plugin → select the zip → Activate
{% endif %}
</span>
</div>
</div>
</div>
</div>
{% endif %}
<!-- Setup Requirements -->
{% if service.credential_fields %}
<div class="bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700">
<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' %}اطلاعات مورد نیاز{% else %}Setup Requirements{% endif %}
</h3>
</div>
<div class="p-6">
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
{% for field in service.credential_fields %}
<div class="flex items-start gap-3 p-3 bg-gray-50 dark:bg-gray-700/50 rounded-lg">
<div class="w-8 h-8 bg-purple-500/20 rounded flex items-center justify-center flex-shrink-0 mt-0.5">
{% if field.type == 'password' %}
<svg class="w-4 h-4 text-purple-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z"/>
</svg>
{% else %}
<svg class="w-4 h-4 text-purple-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z"/>
</svg>
{% endif %}
</div>
<div>
<p class="text-sm font-medium text-gray-900 dark:text-white">{{ field.label }}</p>
{% if field.hint %}
<p class="text-xs text-gray-500 dark:text-gray-400 mt-1">{{ field.hint }}</p>
{% endif %}
{% if field.required %}
<span class="inline-flex items-center px-1.5 py-0.5 rounded text-xs font-medium bg-red-100 dark:bg-red-500/20 text-red-600 dark:text-red-400 mt-1">
{% if lang == 'fa' %}الزامی{% else %}Required{% endif %}
</span>
{% else %}
<span class="inline-flex items-center px-1.5 py-0.5 rounded text-xs font-medium bg-gray-100 dark:bg-gray-600 text-gray-500 dark:text-gray-400 mt-1">
{% if lang == 'fa' %}اختیاری{% else %}Optional{% endif %}
</span>
{% endif %}
</div>
</div>
{% endfor %}
</div>
</div>
</div>
{% endif %}
<!-- Tools List -->
<div class="bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700">
<div class="p-6 border-b border-gray-200 dark:border-gray-700 flex items-center justify-between">
<h3 class="text-lg font-semibold text-gray-900 dark:text-white">
{% if lang == 'fa' %}لیست ابزارها{% else %}Tools{% endif %}
<span class="text-sm font-normal text-gray-400 dark:text-gray-500">({{ service.tools_count }})</span>
</h3>
<div class="relative">
<input type="text" id="tool-search" placeholder="{% if lang == 'fa' %}جستجو...{% else %}Search tools...{% endif %}"
class="w-48 px-3 py-1.5 text-sm bg-gray-50 dark:bg-gray-700 border border-gray-200 dark:border-gray-600 rounded-lg text-gray-900 dark:text-white placeholder-gray-400 focus:outline-none focus:border-primary-500">
</div>
</div>
<div class="divide-y divide-gray-100 dark:divide-gray-700" id="tools-list">
{% for tool in service.tools %}
<div class="tool-item p-4 hover:bg-gray-50 dark:hover:bg-gray-700/50 transition-colors" data-name="{{ tool.name }}" data-desc="{{ tool.description|lower }}">
<div class="flex items-center justify-between">
<div class="flex-1 min-w-0">
<div class="flex items-center gap-2">
<code class="text-sm font-mono text-primary-600 dark:text-primary-400">{{ tool.name }}</code>
{% if tool.scope == 'read' %}
<span class="inline-flex items-center px-1.5 py-0.5 rounded text-xs font-medium bg-green-100 dark:bg-green-500/20 text-green-700 dark:text-green-300">read</span>
{% elif tool.scope == 'write' %}
<span class="inline-flex items-center px-1.5 py-0.5 rounded text-xs font-medium bg-orange-100 dark:bg-orange-500/20 text-orange-700 dark:text-orange-300">write</span>
{% else %}
<span class="inline-flex items-center px-1.5 py-0.5 rounded text-xs font-medium bg-red-100 dark:bg-red-500/20 text-red-700 dark:text-red-300">admin</span>
{% endif %}
</div>
<p class="text-sm text-gray-500 dark:text-gray-400 mt-1 truncate">{{ tool.description }}</p>
</div>
</div>
</div>
{% endfor %}
</div>
</div>
</div>
{% endblock %}
{% block scripts %}
<script>
document.getElementById('tool-search').addEventListener('input', function(e) {
const q = e.target.value.toLowerCase();
document.querySelectorAll('.tool-item').forEach(function(item) {
const name = item.dataset.name || '';
const desc = item.dataset.desc || '';
item.style.display = (name.includes(q) || desc.includes(q)) ? '' : 'none';
});
});
</script>
{% endblock %}

View File

@@ -0,0 +1,61 @@
{% extends "dashboard/base.html" %}
{% block title %}{{ t.get('services', 'Services') }} - MCP Hub{% endblock %}
{% block page_title %}{{ t.get('services', 'Services') }}{% endblock %}
{% block content %}
<div class="space-y-6">
<div class="flex items-center justify-between">
<h2 class="text-xl font-semibold text-gray-900 dark:text-white">
{% if lang == 'fa' %}سرویس‌های MCP{% else %}MCP Services{% endif %}
</h2>
</div>
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
{% set plugin_colors = {
'wordpress': 'blue',
'woocommerce': 'purple',
'supabase': 'emerald',
'gitea': 'green',
'n8n': 'orange',
'openpanel': 'cyan',
'appwrite': 'pink',
'directus': 'violet',
'wordpress_advanced': 'indigo',
} %}
{% for svc in services %}
{% set color = plugin_colors.get(svc.plugin_type, 'gray') %}
<a href="/dashboard/services/{{ svc.plugin_type }}{% if lang and lang != 'en' %}?lang={{ lang }}{% endif %}"
class="block bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700 p-6 hover:shadow-lg transition-all group">
<div class="flex items-center justify-between mb-4">
<h3 class="text-lg font-semibold text-gray-900 dark:text-white transition-colors">
{{ svc.display_name }}
</h3>
{% if svc.is_public %}
<span class="inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium bg-green-100 dark:bg-green-500/20 text-green-700 dark:text-green-300">
{% if lang == 'fa' %}عمومی{% else %}Public{% endif %}
</span>
{% else %}
<span class="inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium bg-gray-100 dark:bg-gray-500/20 text-gray-600 dark:text-gray-400">
{% if lang == 'fa' %}فقط مدیر{% else %}Admin Only{% endif %}
</span>
{% endif %}
</div>
<div class="flex flex-wrap items-center gap-x-3 gap-y-1 text-sm text-gray-500 dark:text-gray-400">
<span>{{ svc.tools_count }} {% if lang == 'fa' %}ابزار{% else %}tools{% endif %}</span>
<span>&middot;</span>
{% set read_count = svc.tools|selectattr('scope', 'equalto', 'read')|list|length %}
{% set write_count = svc.tools|selectattr('scope', 'equalto', 'write')|list|length %}
{% set admin_count = svc.tools|selectattr('scope', 'equalto', 'admin')|list|length %}
<span class="text-green-600 dark:text-green-400">{{ read_count }} read</span>
<span class="text-orange-600 dark:text-orange-400">{{ write_count }} write</span>
{% if admin_count > 0 %}
<span class="text-red-600 dark:text-red-400">{{ admin_count }} admin</span>
{% endif %}
</div>
</a>
{% endfor %}
</div>
</div>
{% endblock %}

View File

@@ -6,22 +6,22 @@
{% block content %}
<div class="space-y-6">
<!-- Language Settings -->
<div class="bg-gray-800 rounded-xl border border-gray-700 p-6">
<h3 class="text-lg font-semibold text-white mb-4">
<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' %}تنظیمات زبان{% else %}Language Settings{% endif %}
</h3>
<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-300 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-300 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>
<p class="text-sm text-gray-400">
<p class="text-sm text-gray-500 dark:text-gray-400">
{% if lang == 'fa' %}
زبان فعلی: فارسی
{% else %}
@@ -32,71 +32,128 @@
</div>
<!-- System Configuration -->
<div class="bg-gray-800 rounded-xl border border-gray-700 p-6">
<h3 class="text-lg font-semibold text-white mb-4">
<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' %}پیکربندی سیستم{% else %}System Configuration{% endif %}
</h3>
<div class="space-y-4">
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
<div>
<p class="text-sm text-gray-400">{% if lang == 'fa' %}حالت سرور{% else %}Server Mode{% endif %}</p>
<p class="text-white font-mono">{{ config.server_mode }}</p>
<p class="text-sm text-gray-500 dark:text-gray-400">{% if lang == 'fa' %}حالت سرور{% else %}Server Mode{% endif %}</p>
<p class="text-gray-900 dark:text-white font-mono">{{ config.server_mode }}</p>
</div>
<div>
<p class="text-sm text-gray-400">{% if lang == 'fa' %}پورت{% else %}Port{% endif %}</p>
<p class="text-white font-mono">{{ config.port }}</p>
<p class="text-sm text-gray-500 dark:text-gray-400">{% if lang == 'fa' %}پورت{% else %}Port{% endif %}</p>
<p class="text-gray-900 dark:text-white font-mono">{{ config.port }}</p>
</div>
<div>
<p class="text-sm text-gray-400">{% if lang == 'fa' %}سطح لاگ{% else %}Log Level{% endif %}</p>
<p class="text-white font-mono">{{ config.log_level }}</p>
<p class="text-sm text-gray-500 dark:text-gray-400">{% if lang == 'fa' %}سطح لاگ{% else %}Log Level{% endif %}</p>
<p class="text-gray-900 dark:text-white font-mono">{{ config.log_level }}</p>
</div>
<div>
<p class="text-sm text-gray-400">{% if lang == 'fa' %}حالت احراز هویت OAuth{% else %}OAuth Auth Mode{% endif %}</p>
<p class="text-white font-mono">{{ config.oauth_auth_mode }}</p>
<p class="text-sm text-gray-500 dark:text-gray-400">{% if lang == 'fa' %}حالت احراز هویت OAuth{% else %}OAuth Auth Mode{% endif %}</p>
<p class="text-gray-900 dark:text-white font-mono">{{ config.oauth_auth_mode }}</p>
</div>
<div>
<p class="text-sm text-gray-400">{% if lang == 'fa' %}حد نرخ روزانه{% else %}Daily Rate Limit{% endif %}</p>
<p class="text-white font-mono">{{ config.rate_limit_per_day }}</p>
<p class="text-sm text-gray-500 dark:text-gray-400">{% if lang == 'fa' %}حد نرخ روزانه{% else %}Daily Rate Limit{% endif %}</p>
<p class="text-gray-900 dark:text-white font-mono">{{ config.rate_limit_per_day }}</p>
</div>
<div>
<p class="text-sm text-gray-400">{% if lang == 'fa' %}حد نرخ هر دقیقه{% else %}Per Minute Rate Limit{% endif %}</p>
<p class="text-white font-mono">{{ config.rate_limit_per_minute }}</p>
<p class="text-sm text-gray-500 dark:text-gray-400">{% if lang == 'fa' %}حد نرخ هر دقیقه{% else %}Per Minute Rate Limit{% endif %}</p>
<p class="text-gray-900 dark:text-white font-mono">{{ config.rate_limit_per_minute }}</p>
</div>
</div>
</div>
</div>
<!-- Security Settings -->
<div class="bg-gray-800 rounded-xl border border-gray-700 p-6">
<h3 class="text-lg font-semibold text-white mb-4">
<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' %}تنظیمات امنیتی{% else %}Security Settings{% endif %}
</h3>
<div class="space-y-4">
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
<div>
<p class="text-sm text-gray-400">{% if lang == 'fa' %}احراز هویت API فعال{% else %}API Auth Enabled{% endif %}</p>
<p class="text-white font-mono">{{ 'Yes' if config.api_auth_enabled else 'No' }}</p>
<p class="text-sm text-gray-500 dark:text-gray-400">{% if lang == 'fa' %}احراز هویت API فعال{% else %}API Auth Enabled{% endif %}</p>
<p class="text-gray-900 dark:text-white font-mono">{{ 'Yes' if config.api_auth_enabled else 'No' }}</p>
</div>
<div>
<p class="text-sm text-gray-400">{% if lang == 'fa' %}کوکی امن داشبورد{% else %}Dashboard Secure Cookie{% endif %}</p>
<p class="text-white font-mono">{{ 'Yes' if config.dashboard_secure_cookie else 'No' }}</p>
<p class="text-sm text-gray-500 dark:text-gray-400">{% if lang == 'fa' %}کوکی امن داشبورد{% else %}Dashboard Secure Cookie{% endif %}</p>
<p class="text-gray-900 dark:text-white font-mono">{{ 'Yes' if config.dashboard_secure_cookie else 'No' }}</p>
</div>
<div>
<p class="text-sm text-gray-400">{% if lang == 'fa' %}دامنه‌های مجاز OAuth{% else %}OAuth Trusted Domains{% endif %}</p>
<p class="text-white font-mono text-sm">{{ config.oauth_trusted_domains or 'localhost' }}</p>
<p class="text-sm text-gray-500 dark:text-gray-400">{% if lang == 'fa' %}دامنه‌های مجاز OAuth{% else %}OAuth Trusted Domains{% endif %}</p>
<p class="text-gray-900 dark:text-white font-mono text-sm">{{ config.oauth_trusted_domains or 'localhost' }}</p>
</div>
<div>
<p class="text-sm text-gray-400">{% if lang == 'fa' %}مدت انقضا سشن داشبورد{% else %}Dashboard Session Expiry{% endif %}</p>
<p class="text-white font-mono">{{ config.dashboard_session_expiry }} {% if lang == 'fa' %}ساعت{% else %}hours{% endif %}</p>
<p class="text-sm text-gray-500 dark:text-gray-400">{% if lang == 'fa' %}مدت انقضا سشن داشبورد{% else %}Dashboard Session Expiry{% endif %}</p>
<p class="text-gray-900 dark:text-white font-mono">{{ config.dashboard_session_expiry }} {% if lang == 'fa' %}ساعت{% else %}hours{% endif %}</p>
</div>
</div>
</div>
</div>
<!-- Managed Settings (4C.3) -->
{% if managed_settings %}
<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' %}تنظیمات قابل مدیریت{% else %}Managed Settings{% endif %}
</h3>
<p class="text-sm text-gray-500 dark:text-gray-400 mb-6">
{% if lang == 'fa' %}این تنظیمات از پنل قابل تغییر هستند. اولویت: دیتابیس > متغیر محیطی > پیش‌فرض{% else %}These settings can be changed from the panel. Priority: Database > Environment > Default{% endif %}
</p>
<div class="space-y-4">
{% for s in managed_settings %}
<div class="border border-gray-200 dark:border-gray-700 rounded-lg p-4" id="setting-{{ s.key }}">
<div class="flex items-center justify-between mb-2">
<div>
<label class="text-sm font-medium text-gray-700 dark:text-gray-300">
{% if lang == 'fa' %}{{ s.label_fa }}{% else %}{{ s.label }}{% endif %}
</label>
<p class="text-xs text-gray-500 dark:text-gray-400 mt-0.5">
{% if lang == 'fa' %}{{ s.hint_fa }}{% else %}{{ s.hint }}{% endif %}
</p>
</div>
<span class="px-2 py-0.5 rounded text-xs font-medium
{% if s.source == 'database' %}bg-blue-100 dark:bg-blue-500/20 text-blue-700 dark:text-blue-300
{% elif s.source == 'environment' %}bg-amber-100 dark:bg-amber-500/20 text-amber-700 dark:text-amber-300
{% else %}bg-gray-100 dark:bg-gray-500/20 text-gray-600 dark:text-gray-400{% endif %}"
id="source-{{ s.key }}">
{{ s.source }}
</span>
</div>
<div class="flex items-center gap-2">
<input type="text" value="{{ s.value }}" id="input-{{ s.key }}"
class="flex-1 bg-gray-50 dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-lg px-3 py-2 text-sm text-gray-900 dark:text-white focus:ring-2 focus:ring-blue-500 focus:border-transparent">
<button onclick="saveSetting('{{ s.key }}')"
class="px-3 py-2 bg-blue-600 hover:bg-blue-700 text-white text-sm rounded-lg transition-colors"
id="save-{{ s.key }}">
{% if lang == 'fa' %}ذخیره{% else %}Save{% endif %}
</button>
{% if s.source == 'database' %}
<button onclick="resetSetting('{{ s.key }}')"
class="px-3 py-2 bg-gray-200 dark:bg-gray-600 hover:bg-gray-300 dark:hover:bg-gray-500 text-gray-700 dark:text-gray-300 text-sm rounded-lg transition-colors"
id="reset-{{ s.key }}"
title="{% if lang == 'fa' %}بازگشت به پیش‌فرض{% else %}Reset to default{% endif %}">
<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="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"/>
</svg>
</button>
{% endif %}
</div>
<p class="text-xs text-gray-400 mt-1" id="default-{{ s.key }}">
{% if lang == 'fa' %}پیش‌فرض: {{ s.default }}{% else %}Default: {{ s.default }}{% endif %}
</p>
</div>
{% endfor %}
</div>
</div>
{% endif %}
<!-- Registered Plugins -->
<div class="bg-gray-800 rounded-xl border border-gray-700 p-6">
<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-white">
<h3 class="text-lg font-semibold text-gray-900 dark:text-white">
{% if lang == 'fa' %}پلاگین‌های ثبت شده{% else %}Registered Plugins{% endif %}
</h3>
<span class="px-2 py-1 bg-blue-500/20 text-blue-400 text-xs rounded-lg">
@@ -106,10 +163,10 @@
<div class="space-y-2">
{% if plugins %}
{% for plugin in plugins %}
<div class="flex items-center justify-between py-2 border-b border-gray-700 last:border-0">
<div class="flex items-center justify-between py-2 border-b border-gray-200 dark:border-gray-700 last:border-0">
<div>
<p class="text-white font-medium">{{ plugin.name }}</p>
<p class="text-sm text-gray-400">{{ plugin.description }}</p>
<p class="text-gray-900 dark:text-white font-medium">{{ plugin.name }}</p>
<p class="text-sm text-gray-500 dark:text-gray-400">{{ plugin.description }}</p>
</div>
<span class="px-2 py-1 bg-green-500/20 text-green-400 text-xs rounded-lg">
{{ t.active }}
@@ -117,8 +174,8 @@
</div>
{% endfor %}
{% else %}
<div class="bg-gray-700/30 rounded-lg p-4">
<p class="text-gray-400 mb-2">
<div class="bg-gray-100 dark:bg-gray-700/30 rounded-lg p-4">
<p class="text-gray-500 dark:text-gray-400 mb-2">
{% if lang == 'fa' %}هیچ پلاگینی ثبت نشده{% else %}No plugins registered{% endif %}
</p>
<p class="text-sm text-gray-500">
@@ -134,8 +191,8 @@
</div>
<!-- About Section -->
<div class="bg-gray-800 rounded-xl border border-gray-700 p-6">
<h3 class="text-lg font-semibold text-white mb-4">
<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' %}درباره{% else %}About{% endif %}
</h3>
<div class="space-y-4">
@@ -146,30 +203,30 @@
</svg>
</div>
<div>
<h4 class="text-xl font-bold text-white">MCP Hub</h4>
<p class="text-gray-400">{% if lang == 'fa' %}هاب پروتکل کانتکست مدل{% else %}Model Context Protocol Hub{% endif %}</p>
<h4 class="text-xl font-bold text-gray-900 dark:text-white">MCP Hub</h4>
<p class="text-gray-500 dark:text-gray-400">{% if lang == 'fa' %}هاب پروتکل کانتکست مدل{% else %}Model Context Protocol Hub{% endif %}</p>
</div>
</div>
<div class="grid grid-cols-2 md:grid-cols-4 gap-4 mt-4">
<div>
<p class="text-sm text-gray-400">{% if lang == 'fa' %}نسخه{% else %}Version{% endif %}</p>
<p class="text-white font-mono">{{ about.version }}</p>
<p class="text-sm text-gray-500 dark:text-gray-400">{% if lang == 'fa' %}نسخه{% else %}Version{% endif %}</p>
<p class="text-gray-900 dark:text-white font-mono">{{ about.version }}</p>
</div>
<div>
<p class="text-sm text-gray-400">{% if lang == 'fa' %}نسخه MCP{% else %}MCP Version{% endif %}</p>
<p class="text-white font-mono">{{ about.mcp_version }}</p>
<p class="text-sm text-gray-500 dark:text-gray-400">{% if lang == 'fa' %}نسخه MCP{% else %}MCP Version{% endif %}</p>
<p class="text-gray-900 dark:text-white font-mono">{{ about.mcp_version }}</p>
</div>
<div>
<p class="text-sm text-gray-400">{% if lang == 'fa' %}نسخه Python{% else %}Python Version{% endif %}</p>
<p class="text-white font-mono">{{ about.python_version }}</p>
<p class="text-sm text-gray-500 dark:text-gray-400">{% if lang == 'fa' %}نسخه Python{% else %}Python Version{% endif %}</p>
<p class="text-gray-900 dark:text-white font-mono">{{ about.python_version }}</p>
</div>
<div>
<p class="text-sm text-gray-400">{% if lang == 'fa' %}تعداد ابزار{% else %}Tools Count{% endif %}</p>
<p class="text-white font-mono">{{ about.tools_count }}</p>
<p class="text-sm text-gray-500 dark:text-gray-400">{% if lang == 'fa' %}تعداد ابزار{% else %}Tools Count{% endif %}</p>
<p class="text-gray-900 dark:text-white font-mono">{{ about.tools_count }}</p>
</div>
</div>
<div class="mt-4 pt-4 border-t border-gray-700">
<p class="text-sm text-gray-400">
<div class="mt-4 pt-4 border-t border-gray-200 dark:border-gray-700">
<p class="text-sm text-gray-500 dark:text-gray-400">
{% if lang == 'fa' %}
MCP Hub یک سرور MCP چند منظوره است که امکان اتصال به سرویس‌های مختلف از جمله WordPress، Gitea، n8n و دیگر سرویس‌ها را فراهم می‌کند.
{% else %}
@@ -181,25 +238,25 @@
</div>
<!-- Session Information -->
<div class="bg-gray-800 rounded-xl border border-gray-700 p-6">
<h3 class="text-lg font-semibold text-white mb-4">
<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' %}اطلاعات سشن{% else %}Session Information{% endif %}
</h3>
<div class="grid grid-cols-1 md:grid-cols-3 gap-4">
<div>
<p class="text-sm text-gray-400">{% if lang == 'fa' %}نوع کاربر{% else %}User Type{% endif %}</p>
<p class="text-white font-mono">{{ session.user_type }}</p>
<p class="text-sm text-gray-500 dark:text-gray-400">{% if lang == 'fa' %}نوع کاربر{% else %}User Type{% endif %}</p>
<p class="text-gray-900 dark:text-white font-mono">{{ session_display.user_type }}</p>
</div>
<div>
<p class="text-sm text-gray-400">{% if lang == 'fa' %}ایجاد شده در{% else %}Created At{% endif %}</p>
<p class="text-white font-mono text-sm">{{ session.created_at[:19] if session.created_at else '-' }}</p>
<p class="text-sm text-gray-500 dark:text-gray-400">{% if lang == 'fa' %}ایجاد شده در{% else %}Created At{% endif %}</p>
<p class="text-gray-900 dark:text-white font-mono text-sm">{{ session_display.created_at[:19] if session_display.created_at else '-' }}</p>
</div>
<div>
<p class="text-sm text-gray-400">{% if lang == 'fa' %}انقضا در{% else %}Expires At{% endif %}</p>
<p class="text-white font-mono text-sm">{{ session.expires_at[:19] if session.expires_at else '-' }}</p>
<p class="text-sm text-gray-500 dark:text-gray-400">{% if lang == 'fa' %}انقضا در{% else %}Expires At{% endif %}</p>
<p class="text-gray-900 dark:text-white font-mono text-sm">{{ session_display.expires_at[:19] if session_display.expires_at else '-' }}</p>
</div>
</div>
<div class="mt-4 pt-4 border-t border-gray-700">
<div class="mt-4 pt-4 border-t border-gray-200 dark:border-gray-700">
<a href="/dashboard/logout"
class="px-4 py-2 bg-red-600 hover:bg-red-700 rounded-lg text-white text-sm transition-colors inline-block">
{{ t.logout }}
@@ -208,3 +265,59 @@
</div>
</div>
{% endblock %}
{% block scripts %}
<script>
async function saveSetting(key) {
const input = document.getElementById('input-' + key);
const btn = document.getElementById('save-' + key);
const value = input.value.trim();
if (!value) return;
btn.textContent = '...';
btn.disabled = true;
try {
const resp = await fetch('/api/dashboard/settings', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ key, value, action: 'save' }),
});
const data = await resp.json();
if (resp.ok) {
btn.textContent = '✓';
btn.className = btn.className.replace('bg-blue-600', 'bg-green-600').replace('hover:bg-blue-700', 'hover:bg-green-700');
const source = document.getElementById('source-' + key);
if (source) { source.textContent = 'database'; source.className = 'px-2 py-0.5 rounded text-xs font-medium bg-blue-100 dark:bg-blue-500/20 text-blue-700 dark:text-blue-300'; }
setTimeout(() => location.reload(), 1000);
} else {
alert(data.error || 'Failed to save');
btn.textContent = '{% if lang == "fa" %}ذخیره{% else %}Save{% endif %}';
}
} catch (e) {
alert('Error: ' + e.message);
btn.textContent = '{% if lang == "fa" %}ذخیره{% else %}Save{% endif %}';
} finally {
btn.disabled = false;
}
}
async function resetSetting(key) {
if (!confirm('Reset this setting to default?')) return;
try {
const resp = await fetch('/api/dashboard/settings', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ key, action: 'reset' }),
});
if (resp.ok) {
location.reload();
} else {
const data = await resp.json();
alert(data.error || 'Failed to reset');
}
} catch (e) {
alert('Error: ' + e.message);
}
}
</script>
{% endblock %}

View File

@@ -0,0 +1,173 @@
{# F.X.fix #9 — capability-badge partial, HTMX-swappable.
Rendered both (a) inline by manage.html on first page load and (b) as
the response body of GET /api/sites/{id}/capabilities/badge when the
Re-check button is clicked. Swapping this element with hx-swap=
"outerHTML" avoids the full-page reload the legacy button triggered.
F.X.fix-pass2 — wording corrected for WordPress: application passwords
inherit ALL capabilities from the user account, so the "credential
tier" framing is misleading. WP-specific branches reference the WP
user's role instead. AI-provider availability is surfaced here too
so removing a provider key gives visible feedback on Re-check.
Required context:
capability_probe — probe payload (with .fit, .granted, .reason,
.ai_providers_configured, optionally .install_hint)
site — site dict (for .plugin_type, .tool_scope)
lang — "fa" | "en"
companion_download_url — string, may be empty
#}
{% set fit_status = capability_probe.fit.status %}
{% set is_wp = site.plugin_type in ['wordpress', 'wordpress_advanced'] %}
{% set is_wc = site.plugin_type == 'woocommerce' %}
{% set is_wp_like = is_wp or is_wc %}
{% set ai_providers = capability_probe.ai_providers_configured or [] %}
{% set ai_provider_missing = is_wp_like and (ai_providers | length == 0) %}
<div id="capability-badge"
class="border rounded-lg px-4 py-3 text-sm
{% if fit_status == 'ok' %}
bg-green-50 dark:bg-green-500/10 border-green-200 dark:border-green-500/30 text-green-800 dark:text-green-300
{% elif fit_status == 'warning' %}
bg-amber-50 dark:bg-amber-500/10 border-amber-200 dark:border-amber-500/30 text-amber-800 dark:text-amber-300
{% elif fit_status == 'unknown_tier' %}
bg-gray-50 dark:bg-gray-700/50 border-gray-200 dark:border-gray-600 text-gray-700 dark:text-gray-300
{% else %}
bg-gray-50 dark:bg-gray-700/50 border-gray-200 dark:border-gray-600 text-gray-600 dark:text-gray-400
{% endif %}">
<div class="flex items-start justify-between gap-3">
<div class="flex-1 min-w-0">
{% if fit_status == 'ok' %}
<div class="font-semibold text-sm">
{% if lang == 'fa' %}✓ دسترسی credential برای سطح انتخاب‌شده کافی است{% else %}✓ Credential grants what this tier needs{% endif %}
</div>
{% if capability_probe.granted %}
<div class="mt-1 text-xs opacity-80">
{% if lang == 'fa' %}دسترسی‌های احراز شده:{% else %}Granted:{% endif %}
<code class="font-mono text-xs">{{ capability_probe.granted | join(', ') }}</code>
</div>
{% endif %}
{% elif fit_status == 'warning' %}
<div class="font-semibold text-sm">
{% if is_wc %}
{% if lang == 'fa' %}
⚠ سطح دسترسی Consumer Key ووکامرس برای سطح «{{ site.tool_scope }}» کافی نیست
{% else %}
⚠ The WooCommerce REST API key permission is below the selected "{{ site.tool_scope }}" tier
{% endif %}
{% elif is_wp %}
{% if lang == 'fa' %}
⚠ کاربر وردپرس این credential نقش لازم برای سطح «{{ site.tool_scope }}» را ندارد
{% else %}
⚠ The WordPress user behind this application password lacks the role for the "{{ site.tool_scope }}" tier
{% endif %}
{% else %}
{% if lang == 'fa' %}
⚠ credential ذخیره‌شده برای سطح «{{ site.tool_scope }}» کافی نیست
{% else %}
⚠ Saved credential is below the selected "{{ site.tool_scope }}" tier
{% endif %}
{% endif %}
</div>
<div class="mt-1 text-xs opacity-90">
{% if lang == 'fa' %}دسترسی‌های ناموجود:{% else %}Missing:{% endif %}
<code class="font-mono text-xs">{{ capability_probe.fit.missing | join(', ') }}</code>
</div>
{% if capability_probe.granted %}
<div class="mt-1 text-xs opacity-80">
{% if lang == 'fa' %}آنچه در دسترس است:{% else %}Currently granted:{% endif %}
<code class="font-mono text-xs">{{ capability_probe.granted | join(', ') }}</code>
</div>
{% endif %}
<div class="mt-2 text-xs">
{% if is_wc %}
{% if lang == 'fa' %}
کلید ووکامرس (Consumer Key + Secret) دارای فیلد <code class="font-mono">permissions</code> با مقادیر
<code class="font-mono">read</code> یا <code class="font-mono">read_write</code> است. گزینه‌ها:
(۱) سطح پایین‌تر انتخاب کنید؛
(۲) در WP Admin → WooCommerce → Settings → Advanced → REST API یک کلید جدید با Permission مناسب (Read / Write یا Read/Write) بسازید و در Connection Settings ذخیره کنید؛
(۳) ادامه — ابزارهای نیازمند این دسترسی هنگام فراخوانی ۴۰۳ می‌دهند.
{% else %}
WooCommerce REST API keys carry a <code class="font-mono">permissions</code> field
(<code class="font-mono">read</code> / <code class="font-mono">read_write</code>). Options:
(1) pick a lower access level;
(2) regenerate the REST key with Read/Write permission in WP Admin → WooCommerce → Settings → Advanced → REST API and re-paste it in Connection Settings;
(3) continue — tools needing missing caps will return 403 at call time.
{% endif %}
{% elif is_wp %}
{% if lang == 'fa' %}
application password در وردپرس همیشه تمام دسترسی‌های کاربر صاحب آن را می‌گیرد و قابل محدودکردن نیست. گزینه‌ها:
(۱) سطح دسترسی پایین‌تری انتخاب کنید؛
(۲) application password را از کاربری با نقش بالاتر (مثلاً Administrator یا Editor) بسازید؛
(۳) ادامه — ابزارهای نیازمند این دسترسی هنگام فراخوانی ۴۰۳ می‌دهند.
{% else %}
WordPress application passwords inherit ALL capabilities of the user account and cannot be scoped down.
Options: (1) pick a lower access level;
(2) create the application password from a higher-role user (e.g. Administrator or Editor);
(3) continue — tools needing missing caps will return 403 at call time.
{% endif %}
{% else %}
{% if lang == 'fa' %}
گزینه‌ها: (۱) انتخاب سطح پایین‌تر؛ (۲) استفاده از credential با دسترسی بالاتر؛ (۳) ادامه — ابزارهای نیازمند این دسترسی هنگام فراخوانی ۴۰۳ می‌دهند.
{% else %}
Options: (1) pick a lower tier; (2) use a higher-privileged credential; (3) continue — tools needing missing caps will return 403 at call time.
{% endif %}
{% endif %}
</div>
{% elif fit_status == 'probe_unavailable' %}
<div class="font-semibold text-sm">
{% if lang == 'fa' %} امکان بررسی خودکار credential در این سایت وجود ندارد{% else %} Capability probe is unavailable for this site{% endif %}
</div>
<div class="mt-1 text-xs opacity-80 font-mono">
{{ capability_probe.reason or 'probe_unavailable' }}
</div>
{% if site.plugin_type in ['wordpress', 'woocommerce'] and companion_download_url %}
<div class="mt-2 text-xs">
{% if lang == 'fa' %}
برای فعال‌سازی probe، افزونه
<a href="{{ companion_download_url }}" class="underline font-medium">Airano MCP Bridge</a>
را روی وردپرس نصب کنید.
{% else %}
Install the
<a href="{{ companion_download_url }}" class="underline font-medium">Airano MCP Bridge</a>
plugin on your WordPress to enable the probe.
{% endif %}
</div>
{% endif %}
{% elif fit_status == 'unknown_tier' %}
<div class="font-semibold text-sm">
{% if lang == 'fa' %} probe برای این plugin + سطح پیاده‌سازی نشده{% else %} Probe not implemented for this plugin/tier combination{% endif %}
</div>
{% endif %}
{# F.X.fix-pass2 — AI provider status line. Independent of the
tier-fit state: even a perfectly-fit credential cannot run
the AI image tool without an OpenRouter / OpenAI / … key. #}
{% if ai_provider_missing %}
<div class="mt-3 pt-2 border-t border-current/20 text-xs">
<span class="font-semibold">{% if lang == 'fa' %}⚠ هیچ کلید AI Provider تنظیم نشده{% else %}⚠ No AI provider key configured{% endif %}</span>
<span class="opacity-80">
{% if lang == 'fa' %}
— ابزار <code class="font-mono">wordpress_generate_and_upload_image</code> غیرفعال است. کلید را در بخش «تولید تصویر با هوش مصنوعی» بالا ذخیره کنید.
{% else %}
<code class="font-mono">wordpress_generate_and_upload_image</code> is disabled. Add a key in the "AI Image Generation" section above.
{% endif %}
</span>
</div>
{% elif is_wp_like and ai_providers %}
<div class="mt-3 pt-2 border-t border-current/20 text-xs opacity-80">
{% if lang == 'fa' %}کلیدهای AI Provider فعال:{% else %}Configured AI providers:{% endif %}
<code class="font-mono text-xs">{{ ai_providers | join(', ') }}</code>
</div>
{% endif %}
</div>
<button type="button"
hx-get="/api/sites/{{ site.id }}/capabilities/badge?force=1"
hx-target="#capability-badge"
hx-swap="outerHTML"
hx-disabled-elt="this"
class="flex-shrink-0 px-2.5 py-1 rounded border border-current/30 text-xs font-medium hover:bg-white/20 dark:hover:bg-black/20 transition-colors disabled:opacity-50">
{% if lang == 'fa' %}بررسی دوباره{% else %}Re-check{% endif %}
</button>
</div>
</div>

View File

@@ -0,0 +1,268 @@
{% extends "dashboard/base.html" %}
{% block title %}{{ t.add_site }} - MCP Hub{% endblock %}
{% block page_title %}{{ t.add_site }}{% endblock %}
{% block content %}
<div class="max-w-3xl mx-auto space-y-6">
<!-- Header -->
<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.add_site }}</h2>
</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>
<!-- ═══════════════════════════════════════════════════════════════ -->
<!-- Section 1: Add Site / Connect Service -->
<!-- ═══════════════════════════════════════════════════════════════ -->
<div class="bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700">
<button type="button" onclick="toggleSection('addsite')" class="w-full flex items-center justify-between p-6 cursor-pointer">
<div class="flex items-center gap-3">
<div class="w-8 h-8 bg-blue-500/20 rounded-lg flex items-center justify-center">
<svg class="w-4 h-4 text-blue-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4"/>
</svg>
</div>
<div>
<h3 class="text-base font-semibold text-gray-900 dark:text-white">
{% if lang == 'fa' %}اتصال سرویس{% else %}Connect Service{% endif %}
</h3>
<p class="text-xs text-gray-500 dark:text-gray-400 mt-0.5">
{% if lang == 'fa' %}نوع پلاگین و اطلاعات اتصال{% else %}Plugin type and connection details{% endif %}
</p>
</div>
</div>
<svg id="addsite-chevron" class="w-5 h-5 text-gray-400 transition-transform rotate-180" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7"/>
</svg>
</button>
<div id="addsite-section" class="border-t border-gray-200 dark:border-gray-700 p-6 space-y-4">
<form id="add-site-form" class="space-y-4">
<!-- Plugin Type -->
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">{{ t.plugin_type }}</label>
<select id="plugin_type" name="plugin_type" required
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 focus:ring-2 focus:ring-blue-500 focus:border-transparent"
onchange="updateFields()">
<option value="">{{ t.select_plugin }}</option>
{% for ptype, pname in plugin_names.items() %}
<option value="{{ ptype }}" {% if ptype == preselect_plugin %}selected{% endif %}>{{ pname }}</option>
{% endfor %}
</select>
</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 placeholder="https://example.com"
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">
<p id="url-hint" class="text-xs text-gray-500 dark:text-gray-400 mt-1 hidden"></p>
</div>
<!-- Alias -->
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">{{ t.site_alias }}</label>
<input type="text" id="alias" name="alias" required placeholder="myblog" pattern="[a-zA-Z0-9_-]+"
minlength="2" maxlength="50"
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">
<p class="text-xs text-gray-500 dark:text-gray-400 mt-1">{{ t.site_alias_hint }}</p>
</div>
<!-- Dynamic Credential Fields -->
<div id="credential-fields" class="space-y-4">
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300">{{ t.credentials }}</label>
<p class="text-sm text-gray-500 dark:text-gray-400">{{ t.select_plugin }}</p>
</div>
<!-- Submit -->
<div class="flex items-center gap-3 pt-2">
<button type="submit" id="submit-btn"
class="px-5 py-2 bg-blue-600 hover:bg-blue-700 rounded-lg text-white text-sm font-medium disabled:opacity-50 transition-colors">
{{ t.add_site }}
</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 text-sm">
{{ t.cancel }}
</a>
</div>
</form>
</div>
</div>
<!-- ═══════════════════════════════════════════════════════════════ -->
<!-- Section 2: What happens next (info card) -->
<!-- ═══════════════════════════════════════════════════════════════ -->
<div class="bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700">
<button type="button" onclick="toggleSection('info')" class="w-full flex items-center justify-between p-6 cursor-pointer">
<div class="flex items-center gap-3">
<div class="w-8 h-8 bg-green-500/20 rounded-lg flex items-center justify-center">
<svg class="w-4 h-4 text-green-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"/>
</svg>
</div>
<h3 class="text-base font-semibold text-gray-900 dark:text-white">
{% if lang == 'fa' %}مراحل بعدی{% else %}What happens next{% endif %}
</h3>
</div>
<svg id="info-chevron" class="w-5 h-5 text-gray-400 transition-transform" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7"/>
</svg>
</button>
<div id="info-section" class="hidden border-t border-gray-200 dark:border-gray-700 p-6">
<div class="space-y-3 text-sm text-gray-600 dark:text-gray-400">
<div class="flex items-start gap-3">
<span class="flex-shrink-0 w-6 h-6 bg-blue-500/20 text-blue-500 rounded-full flex items-center justify-center text-xs font-bold">1</span>
<p>{% if lang == 'fa' %}سرویس خود را با انتخاب نوع پلاگین و وارد کردن URL و اعتبارنامه اضافه کنید.{% else %}Add your service by selecting the plugin type and entering the URL and credentials.{% endif %}</p>
</div>
<div class="flex items-start gap-3">
<span class="flex-shrink-0 w-6 h-6 bg-blue-500/20 text-blue-500 rounded-full flex items-center justify-center text-xs font-bold">2</span>
<p>{% if lang == 'fa' %}سطح دسترسی ابزارها و تنظیمات اتصال را از صفحه مدیریت سایت پیکربندی کنید.{% else %}Configure tool access levels and connection settings from the site management page.{% endif %}</p>
</div>
<div class="flex items-start gap-3">
<span class="flex-shrink-0 w-6 h-6 bg-blue-500/20 text-blue-500 rounded-full flex items-center justify-center text-xs font-bold">3</span>
<p>{% if lang == 'fa' %}یک کلید API بسازید و از قطعه کد پیکربندی برای اتصال MCP client خود استفاده کنید.{% else %}Create an API key and use the configuration snippet to connect your MCP client.{% endif %}</p>
</div>
</div>
</div>
</div>
</div>
{% endblock %}
{% block scripts %}
<script>
const pluginFields = {{ plugin_fields_json| safe }};
const urlHints = {
'openpanel': 'OpenPanel API URL — Cloud: https://api.openpanel.dev | Self-hosted: your API service URL (e.g., https://analytics.example.com)',
'supabase': 'Your Supabase project URL (e.g., https://xxxxx.supabase.co)',
};
// ── Section toggle ──────────────────────────────────────────────
function toggleSection(name) {
const section = document.getElementById(name + '-section');
const chevron = document.getElementById(name + '-chevron');
section.classList.toggle('hidden');
chevron.classList.toggle('rotate-180');
}
function updateFields() {
const ptype = document.getElementById('plugin_type').value;
const container = document.getElementById('credential-fields');
const urlHintEl = document.getElementById('url-hint');
// Update URL hint
if (urlHints[ptype]) {
urlHintEl.textContent = urlHints[ptype];
urlHintEl.classList.remove('hidden');
} else {
urlHintEl.classList.add('hidden');
}
if (!ptype || !pluginFields[ptype]) {
container.innerHTML = '<label class="block text-sm font-medium text-gray-700 dark:text-gray-300">{{ t.credentials }}</label><p class="text-sm text-gray-500 dark:text-gray-400">{{ t.select_plugin }}</p>';
return;
}
const mainFields = pluginFields[ptype].filter(f => !f.advanced);
const advFields = pluginFields[ptype].filter(f => f.advanced);
let html = '<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">{{ t.credentials }}</label>';
function renderField(field) {
const req = field.required ? 'required' : '';
const hintHtml = field.hint ? `<p class="text-xs text-gray-500 dark:text-gray-400 mt-1">${field.hint}</p>` : '';
return `
<div class="mb-4">
<label class="block text-xs font-medium text-gray-500 dark:text-gray-400 mb-1">${field.label}${field.required ? ' *' : ''}</label>
<input type="${field.type}" name="cred_${field.name}" ${req}
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"
placeholder="${field.label}">
${hintHtml}
</div>`;
}
mainFields.forEach(field => { html += renderField(field); });
if (advFields.length > 0) {
html += `
<details class="mt-2 border border-gray-200 dark:border-gray-600 rounded-lg">
<summary class="cursor-pointer px-4 py-2.5 text-sm font-medium text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white select-none">
Advanced Settings
</summary>
<div class="px-4 pb-4 pt-2">`;
advFields.forEach(field => { html += renderField(field); });
html += `</div></details>`;
}
container.innerHTML = html;
}
// Auto-fill credential fields if plugin_type is pre-selected
if (document.getElementById('plugin_type').value) {
updateFields();
}
document.getElementById('add-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.adding_site }}';
btn.disabled = true;
const ptype = document.getElementById('plugin_type').value;
const url = document.getElementById('url').value;
const alias = document.getElementById('alias').value;
// Collect credentials
const creds = {};
if (pluginFields[ptype]) {
pluginFields[ptype].forEach(field => {
const input = document.querySelector(`[name="cred_${field.name}"]`);
if (input) creds[field.name] = input.value;
});
}
try {
const resp = await fetch('/api/sites', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
plugin_type: ptype,
url: url,
alias: alias,
credentials: creds,
}),
});
const data = await resp.json();
if (resp.ok) {
// Redirect to manage page for the new site
const siteId = data.site?.id || data.site_id;
if (siteId) {
window.location.href = '/dashboard/sites/' + siteId + '{% if lang and lang != "en" %}?lang={{ lang }}{% endif %}';
} else {
window.location.href = '/dashboard/sites?msg={{ t.site_added }}{% if lang and lang != "en" %}&lang={{ lang }}{% endif %}';
}
} else {
errorEl.textContent = data.error || 'Failed to add site';
errorEl.classList.remove('hidden');
btn.textContent = '{{ t.add_site }}';
btn.disabled = false;
}
} catch (err) {
errorEl.textContent = 'Network error: ' + err.message;
errorEl.classList.remove('hidden');
btn.textContent = '{{ t.add_site }}';
btn.disabled = false;
}
});
</script>
{% endblock %}

View File

@@ -0,0 +1,365 @@
{% 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 %}
{% if not field.get('advanced', False) %}
<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 }}"
placeholder="{% if field.required %}{{ t.keep_existing }}{% else %}{{ t.leave_blank_to_clear }}{% endif %}"
data-required="{{ field.required | lower }}"
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>
{% endif %}
{% endfor %}
{% set has_advanced = fields | map(attribute='advanced') | select | list | length > 0 if fields else false %}
{% if has_advanced %}
<details class="mt-2 border border-gray-200 dark:border-gray-600 rounded-lg">
<summary class="cursor-pointer px-4 py-2.5 text-sm font-medium text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white select-none">
Advanced Settings
</summary>
<div class="px-4 pb-4 pt-2 space-y-4">
{% for field in fields %}
{% if field.get('advanced', False) %}
<div class="mb-4">
<label class="block text-xs font-medium text-gray-500 dark:text-gray-400 mb-1">
{{ field.label }}
</label>
<input type="{{ field.type }}" name="cred_{{ field.name }}"
placeholder="{{ t.leave_blank_to_clear }}"
data-required="false"
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>
{% endif %}
{% endfor %}
</div>
</details>
{% endif %}
</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>
<!-- Tool Access Card -->
<div id="tool-access-card" class="bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700 p-6 space-y-4">
<h3 class="text-base font-semibold text-gray-900 dark:text-white">
{% if lang == 'fa' %}دسترسی به ابزارها{% else %}Tool Access{% endif %}
</h3>
<div id="tool-access-loading" class="text-gray-400 dark:text-gray-500 text-sm">
{% if lang == 'fa' %}در حال بارگذاری...{% else %}Loading...{% endif %}
</div>
<div id="tool-access-content" class="hidden space-y-4">
<!-- Scope selector -->
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
{% if lang == 'fa' %}سطح دسترسی{% else %}Access Scope{% endif %}
</label>
<select id="tool-scope-select"
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 focus:ring-2 focus:ring-blue-500 focus:border-transparent">
<option value="read">{% if lang == 'fa' %}فقط خواندن (Read){% else %}Read{% endif %}</option>
<option value="read:sensitive">{% if lang == 'fa' %}خواندن + داده حساس{% else %}Read + Sensitive{% endif %}</option>
<option value="deploy">{% if lang == 'fa' %}استقرار (start/stop/restart){% else %}Deploy{% endif %}</option>
<option value="write">{% if lang == 'fa' %}نوشتن + Lifecycle{% else %}Write{% endif %}</option>
<option value="admin">{% if lang == 'fa' %}دسترسی کامل{% else %}Admin (all tools){% endif %}</option>
<option value="custom">{% if lang == 'fa' %}سفارشی (per-tool){% else %}Custom{% endif %}</option>
</select>
<p id="scope-desc" class="text-xs text-gray-500 dark:text-gray-400 mt-1.5"></p>
</div>
<!-- Status message for scope save -->
<div id="scope-status" class="hidden text-xs"></div>
<!-- Advanced: per-tool overrides -->
<details id="tool-overrides-section">
<summary class="cursor-pointer text-sm font-medium text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white select-none py-1">
{% if lang == 'fa' %}پیشرفته — انتخاب دستی ابزارها{% else %}Advanced — per-tool overrides{% endif %}
</summary>
<div id="tool-list" class="mt-3 space-y-4 border-t border-gray-200 dark:border-gray-700 pt-4"></div>
</details>
</div>
</div>
</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 — non-empty values always sent,
// optional fields sent as empty string to allow clearing
const creds = {};
if (pluginFields[pluginType]) {
pluginFields[pluginType].forEach(field => {
const input = document.querySelector(`[name="cred_${field.name}"]`);
if (!input) return;
const val = input.value.trim();
if (val) {
creds[field.name] = val;
} else if (input.dataset.required === 'false') {
// Send empty string for optional fields so server can clear them
creds[field.name] = '';
}
});
}
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;
}
});
// ── Tool Access ──────────────────────────────────────────────
const SCOPE_DESCS = {
'read': '{% if lang == "fa" %}ابزارهای read-only (list/get) — بدون داده‌های حساس{% else %}Read-only list/get tools — no sensitive data{% endif %}',
'read:sensitive': '{% if lang == "fa" %}read + لاگ‌ها، بکاپ‌ها و متغیرهای محیطی{% else %}Read + logs, backups, and env vars{% endif %}',
'deploy': '{% if lang == "fa" %}read + start/stop/restart/deploy{% else %}Read + start/stop/restart/deploy{% endif %}',
'write': '{% if lang == "fa" %}read + lifecycle + ایجاد/بروزرسانی + env{% else %}Read + lifecycle + create/update + env{% endif %}',
'admin': '{% if lang == "fa" %}دسترسی کامل به همه ابزارها{% else %}Full access to all tools{% endif %}',
'custom': '{% if lang == "fa" %}بدون فیلتر سطح — فقط toggleهای دستی اعمال می‌شوند{% else %}No scope filter — only per-tool toggles apply{% endif %}',
};
const CAT_LABELS = {
'read': '{% if lang == "fa" %}خواندن{% else %}Read{% endif %}',
'read_sensitive': '{% if lang == "fa" %}خواندن حساس{% else %}Sensitive Read{% endif %}',
'lifecycle': 'Lifecycle',
'crud': 'CRUD',
'env': '{% if lang == "fa" %}محیطی{% else %}Environment{% endif %}',
'backup': 'Backup',
'system': 'System',
};
async function loadToolAccess() {
try {
const r = await fetch('/api/sites/' + siteId + '/tools');
if (!r.ok) { hideToolAccess(); return; }
const data = await r.json();
renderToolAccess(data);
} catch (_) { hideToolAccess(); }
}
function hideToolAccess() {
document.getElementById('tool-access-loading').textContent = '';
}
function renderToolAccess(data) {
const loading = document.getElementById('tool-access-loading');
const content = document.getElementById('tool-access-content');
loading.classList.add('hidden');
content.classList.remove('hidden');
// Set scope dropdown
const select = document.getElementById('tool-scope-select');
select.value = data.tool_scope || 'admin';
updateScopeDesc(data.tool_scope || 'admin');
// Scope change handler
select.onchange = async () => {
const scope = select.value;
updateScopeDesc(scope);
const statusEl = document.getElementById('scope-status');
statusEl.textContent = '{% if lang == "fa" %}در حال ذخیره...{% else %}Saving...{% endif %}';
statusEl.className = 'text-xs text-gray-400';
statusEl.classList.remove('hidden');
try {
const r = await fetch('/api/sites/' + siteId + '/tool-scope', {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ scope }),
});
if (r.ok) {
statusEl.textContent = '{% if lang == "fa" %}ذخیره شد{% else %}Saved{% endif %}';
statusEl.className = 'text-xs text-green-500';
} else {
statusEl.textContent = '{% if lang == "fa" %}خطا{% else %}Error saving{% endif %}';
statusEl.className = 'text-xs text-red-500';
}
} catch (_) {
statusEl.textContent = '{% if lang == "fa" %}خطای شبکه{% else %}Network error{% endif %}';
statusEl.className = 'text-xs text-red-500';
}
setTimeout(() => statusEl.classList.add('hidden'), 2000);
};
// Render per-tool list grouped by category
const grouped = {};
for (const tool of data.tools) {
const cat = tool.category || 'read';
if (!grouped[cat]) grouped[cat] = [];
grouped[cat].push(tool);
}
const catOrder = ['read', 'read_sensitive', 'lifecycle', 'crud', 'env', 'backup', 'system'];
const container = document.getElementById('tool-list');
container.innerHTML = '';
for (const cat of catOrder) {
if (!grouped[cat]) continue;
const section = document.createElement('div');
section.className = 'space-y-1';
const header = document.createElement('p');
header.className = 'text-xs font-semibold uppercase tracking-wider text-gray-400 dark:text-gray-500 mb-2';
header.textContent = CAT_LABELS[cat] || cat;
section.appendChild(header);
for (const tool of grouped[cat]) {
section.appendChild(renderToolRow(tool));
}
container.appendChild(section);
}
}
function updateScopeDesc(scope) {
const el = document.getElementById('scope-desc');
el.textContent = SCOPE_DESCS[scope] || '';
}
function renderToolRow(tool) {
const row = document.createElement('div');
row.id = 'tool-row-' + tool.name;
row.className = 'flex items-center justify-between py-1.5 px-2 rounded hover:bg-gray-50 dark:hover:bg-gray-700/30';
const left = document.createElement('div');
left.className = 'flex items-center gap-2 flex-1 min-w-0';
const nameEl = document.createElement('span');
nameEl.className = 'text-sm text-gray-800 dark:text-gray-200 truncate font-mono';
nameEl.textContent = tool.name;
nameEl.title = tool.description || '';
left.appendChild(nameEl);
if (tool.sensitivity === 'sensitive') {
const badge = document.createElement('span');
badge.className = 'flex-shrink-0 px-1.5 py-0.5 rounded text-xs font-medium bg-red-100 dark:bg-red-500/20 text-red-600 dark:text-red-400';
badge.textContent = '{% if lang == "fa" %}حساس{% else %}sensitive{% endif %}';
left.appendChild(badge);
}
// Toggle switch
const label = document.createElement('label');
label.className = 'relative inline-flex items-center cursor-pointer flex-shrink-0';
const input = document.createElement('input');
input.type = 'checkbox';
input.className = 'sr-only peer';
input.checked = tool.enabled !== false;
input.onchange = async () => {
const enabled = input.checked;
try {
const r = await fetch('/api/sites/' + siteId + '/tools/' + tool.name, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ enabled }),
});
if (!r.ok) { input.checked = !enabled; }
} catch (_) { input.checked = !enabled; }
};
const slider = document.createElement('div');
slider.className = 'w-9 h-5 bg-gray-300 dark:bg-gray-600 peer-checked:bg-blue-600 rounded-full peer peer-focus:ring-2 peer-focus:ring-blue-300 dark:peer-focus:ring-blue-800 transition-colors after:content-[""] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:rounded-full after:h-4 after:w-4 after:transition-all peer-checked:after:translate-x-4';
label.appendChild(input);
label.appendChild(slider);
row.appendChild(left);
row.appendChild(label);
return row;
}
document.addEventListener('DOMContentLoaded', loadToolAccess);
</script>
{% endblock %}

View File

@@ -0,0 +1,197 @@
{% extends "dashboard/base.html" %}
{% block title %}{{ t.my_sites }} - MCP Hub{% endblock %}
{% block page_title %}{{ t.my_sites }}{% endblock %}
{% block content %}
<div class="space-y-6">
<!-- Header with Add Site button -->
<div class="flex items-center justify-between">
<h2 class="text-xl font-semibold text-gray-900 dark:text-white">{{ t.my_sites }}</h2>
<a href="/dashboard/sites/add{% if lang and lang != 'en' %}?lang={{ lang }}{% endif %}"
class="btn-primary px-4 py-2 rounded-lg text-white text-sm font-medium"
title="{{ t.add_site }}">
+ {{ t.add_site }}
</a>
</div>
<!-- Flash messages -->
{% if msg %}
<div class="bg-green-50 dark:bg-green-500/20 border border-green-200 dark:border-green-500/50 text-green-700 dark:text-green-300 px-4 py-3 rounded-lg">
{{ msg }}
</div>
{% endif %}
{% if error %}
<div class="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">
{{ error }}
</div>
{% endif %}
{% if sites %}
<!-- Sites table -->
<div class="bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700 overflow-hidden">
<table class="w-full">
<thead class="bg-gray-50 dark:bg-gray-700/50">
<tr>
<th class="px-6 py-4 text-{{ 'right' if lang == 'fa' else 'left' }} text-sm font-medium text-gray-500 dark:text-gray-300">{{ t.site_alias }}</th>
<th class="px-6 py-4 text-{{ 'right' if lang == 'fa' else 'left' }} text-sm font-medium text-gray-500 dark:text-gray-300">{{ t.plugin_type }}</th>
<th class="px-6 py-4 text-{{ 'right' if lang == 'fa' else 'left' }} text-sm font-medium text-gray-500 dark:text-gray-300">{{ t.site_url }}</th>
<th class="px-6 py-4 text-{{ 'right' if lang == 'fa' else 'left' }} text-sm font-medium text-gray-500 dark:text-gray-300">{{ t.status }}</th>
<th class="px-6 py-4 text-{{ 'right' if lang == 'fa' else 'left' }} text-sm font-medium text-gray-500 dark:text-gray-300">{{ t.actions }}</th>
</tr>
</thead>
<tbody class="divide-y divide-gray-100 dark:divide-gray-700">
{% for site in sites %}
<tr class="hover:bg-gray-50 dark:hover:bg-gray-700/30 transition-colors" id="site-{{ site.id }}">
<td class="px-6 py-4">
<span class="text-gray-900 dark:text-white font-medium">{{ site.alias }}</span>
</td>
<td class="px-6 py-4">
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-blue-100 dark:bg-blue-500/20 text-blue-700 dark:text-blue-300">
{{ plugin_names.get(site.plugin_type, site.plugin_type) }}
</span>
</td>
<td class="px-6 py-4 text-gray-500 dark:text-gray-400 text-sm max-w-xs truncate">{{ site.url }}</td>
<td class="px-6 py-4" id="status-cell-{{ site.id }}">
{% if site.status == 'active' %}
<span class="status-badge inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-green-100 dark:bg-green-500/20 text-green-700 dark:text-green-300">{{ t.active }}</span>
{% elif site.status == 'error' %}
<span class="status-badge inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-red-100 dark:bg-red-500/20 text-red-700 dark:text-red-300">{{ t.error }}</span>
{% elif site.status == 'pending' %}
<span class="status-badge inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-yellow-100 dark:bg-yellow-500/20 text-yellow-700 dark:text-yellow-300">Pending</span>
{% else %}
<span class="status-badge inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-gray-100 dark:bg-gray-500/20 text-gray-600 dark:text-gray-400">{{ site.status }}</span>
{% endif %}
{% if site.status_msg %}
<p class="text-xs text-gray-500 mt-1 status-msg">{{ site.status_msg[:60] }}</p>
{% endif %}
<p class="text-xs text-gray-400 dark:text-gray-500 mt-1 last-tested-time">
{% if site.last_tested_at %}
{{ t.last_tested }}: {{ site.last_tested_at[:16] }}
{% else %}
{{ t.never_tested }}
{% endif %}
</p>
</td>
<td class="px-6 py-4">
<div class="flex items-center gap-2">
<button onclick="testSite('{{ site.id }}')"
class="text-sm text-blue-600 dark:text-blue-400 hover:text-blue-500 dark:hover:text-blue-300"
id="test-btn-{{ site.id }}">
{{ t.test_connection }}
</button>
<a href="/dashboard/sites/{{ site.id }}{% if lang and lang != 'en' %}?lang={{ lang }}{% endif %}"
class="text-sm text-blue-600 dark:text-blue-400 hover:text-blue-500">
{{ t.get('connect', 'Connect') }}
</a>
<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 }}
</button>
</div>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% else %}
<!-- Empty state -->
<div class="bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700 p-12 text-center">
<svg class="w-16 h-16 mx-auto text-gray-300 dark:text-gray-600 mb-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M5 12h14M12 5l7 7-7 7"/>
</svg>
<h3 class="text-lg font-medium text-gray-700 dark:text-gray-300 mb-2">{{ t.no_sites }}</h3>
<p class="text-gray-500 mb-6">{{ t.add_first_site }}</p>
<a href="/dashboard/sites/add{% if lang and lang != 'en' %}?lang={{ lang }}{% endif %}"
class="btn-primary px-6 py-2 rounded-lg text-white text-sm font-medium">
+ {{ t.add_site }}
</a>
</div>
{% endif %}
</div>
{% endblock %}
{% block scripts %}
<script>
function updateStatusBadge(siteId, status) {
const cell = document.getElementById('status-cell-' + siteId);
if (!cell) return;
const badge = cell.querySelector('.status-badge');
if (badge) {
const base = 'status-badge inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium';
if (status === 'active') {
badge.className = base + ' bg-green-100 dark:bg-green-500/20 text-green-700 dark:text-green-300';
badge.textContent = '{{ t.active }}';
} else {
badge.className = base + ' bg-red-100 dark:bg-red-500/20 text-red-700 dark:text-red-300';
badge.textContent = '{{ t.error }}';
}
}
const msgEl = cell.querySelector('.status-msg');
if (msgEl) msgEl.remove();
const timeEl = cell.querySelector('.last-tested-time');
if (timeEl) {
timeEl.textContent = '{{ t.last_tested }}: {{ t.just_now }}';
}
}
async function testSite(siteId) {
const btn = document.getElementById('test-btn-' + siteId);
btn.textContent = '{{ t.testing }}';
btn.disabled = true;
try {
const resp = await fetch('/api/sites/' + siteId + '/test', { method: 'POST' });
if (!resp.ok) {
btn.textContent = '{{ t.connection_failed }}';
btn.className = 'text-sm text-red-600 dark:text-red-400';
updateStatusBadge(siteId, 'error');
return;
}
const ct = resp.headers.get('content-type') || '';
if (!ct.includes('application/json')) {
btn.textContent = '{{ t.connection_failed }}';
btn.className = 'text-sm text-red-600 dark:text-red-400';
updateStatusBadge(siteId, 'error');
return;
}
const data = await resp.json();
if (data.ok) {
btn.textContent = '{{ t.connection_ok }}';
btn.className = 'text-sm text-green-600 dark:text-green-400';
updateStatusBadge(siteId, data.status || 'active');
} else {
btn.textContent = '{{ t.connection_failed }}';
btn.className = 'text-sm text-red-600 dark:text-red-400';
updateStatusBadge(siteId, data.status || 'error');
}
} catch (e) {
btn.textContent = '{{ t.connection_failed }}';
btn.className = 'text-sm text-red-600 dark:text-red-400';
updateStatusBadge(siteId, 'error');
} finally {
setTimeout(() => {
btn.textContent = '{{ t.test_connection }}';
btn.className = 'text-sm text-blue-600 dark:text-blue-400 hover:text-blue-500 dark:hover:text-blue-300';
btn.disabled = false;
}, 3000);
}
}
async function deleteSite(siteId) {
if (!confirm('Are you sure you want to delete this site?')) return;
try {
const resp = await fetch('/api/sites/' + siteId, { method: 'DELETE' });
if (resp.ok) {
document.getElementById('site-' + siteId).remove();
}
} catch (e) {
alert('Failed to delete site');
}
}
</script>
{% endblock %}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,134 @@
{% extends "dashboard/base.html" %}
{% block title %}{{ site.alias }} - MCP Hub{% endblock %}
{% block page_title %}{{ site.alias }}{% endblock %}
{% block content %}
<div class="max-w-3xl 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">
{{ site.alias }}
<span class="ml-2 inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-blue-100 dark:bg-blue-500/20 text-blue-700 dark:text-blue-300">
{{ plugin_names.get(site.plugin_type, site.plugin_type) }}
</span>
</h2>
<a href="/dashboard/sites/{{ site.id }}/edit{% if lang and lang != 'en' %}?lang={{ lang }}{% endif %}"
class="ml-auto text-sm text-gray-500 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white">
{% if lang == 'fa' %}ویرایش{% else %}Edit{% endif %}
</a>
</div>
<!-- MCP Endpoint URL -->
<div class="bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700 p-6">
<h3 class="text-base font-semibold text-gray-900 dark:text-white mb-3">
{% if lang == 'fa' %}آدرس MCP{% else %}MCP Endpoint{% endif %}
</h3>
<div class="flex items-center gap-2">
<code id="mcp-url" 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">
{{ mcp_url }}
</code>
<button onclick="copyMcpUrl()" class="flex-shrink-0 px-4 py-2 bg-blue-600 hover:bg-blue-700 rounded-lg text-white text-sm transition-colors">
{% if lang == 'fa' %}کپی{% else %}Copy{% endif %}
</button>
</div>
<p class="text-xs text-gray-500 dark:text-gray-400 mt-2">
{% if lang == 'fa' %}
برای احراز هویت از کلید API (Bearer) یا OAuth استفاده کنید.
{% else %}
Authenticate with an API key (Bearer token) or OAuth.
{% endif %}
</p>
</div>
<!-- Config Snippets -->
<div class="bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700 p-6">
<h3 class="text-base font-semibold text-gray-900 dark:text-white mb-4">
{% if lang == 'fa' %}نمونه کدهای پیکربندی{% else %}Configuration Snippets{% endif %}
</h3>
<div class="mb-4">
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
{% if lang == 'fa' %}انتخاب کلاینت{% else %}Select Client{% endif %}
</label>
<select id="config-client" onchange="updateConfig()"
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 focus:ring-2 focus:ring-blue-500">
{% for client in clients %}
<option value="{{ client.id }}">{{ client.label }}</option>
{% endfor %}
</select>
</div>
<div class="relative">
<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-[100px]">{% if lang == 'fa' %}در حال بارگذاری...{% else %}Loading...{% endif %}</pre>
<button onclick="copyConfig()" id="copy-config-btn"
class="absolute top-2 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">
{% if lang == 'fa' %}کپی{% else %}Copy{% endif %}
</button>
</div>
<div id="transport-note" 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 استفاده کنید.
{% else %}
<strong>Note:</strong> Use <code>streamableHttp</code> for Claude Desktop, <code>http</code> for VS Code/Claude Code.
{% endif %}
</p>
</div>
<div id="bearer-hint" class="mt-3 bg-gray-50 dark:bg-gray-700/50 border border-gray-200 dark:border-gray-600 rounded-lg p-4">
<p class="text-sm text-gray-600 dark:text-gray-400 mb-2">
{% if lang == 'fa' %}
<strong>API Key:</strong> از صفحه <a href="/dashboard/keys{% if lang and lang != 'en' %}?lang={{ lang }}{% endif %}" class="underline">کلیدها</a> بسازید:
{% else %}
<strong>API Key:</strong> Create one on the <a href="/dashboard/keys{% if lang and lang != 'en' %}?lang={{ lang }}{% endif %}" class="underline">API Keys</a> page:
{% endif %}
</p>
<code class="block bg-gray-100 dark:bg-gray-900 px-3 py-1.5 rounded text-xs font-mono text-gray-800 dark:text-gray-200">"Authorization": "Bearer mhu_YOUR_API_KEY_HERE"</code>
</div>
</div>
</div>
{% endblock %}
{% block scripts %}
<script>
const siteAlias = "{{ site.alias }}";
const WEB_CLIENTS = ['claude_connectors', 'chatgpt'];
async function updateConfig() {
const client = document.getElementById('config-client')?.value;
if (!client) return;
const isWeb = WEB_CLIENTS.includes(client);
document.getElementById('transport-note').style.display = isWeb ? 'none' : '';
document.getElementById('bearer-hint').style.display = isWeb ? 'none' : '';
try {
const r = await fetch('/api/config/' + siteAlias + '?client=' + client);
const data = await r.json();
document.getElementById('config-output').textContent = r.ok ? data.config : '{% if lang == "fa" %}خطا در بارگذاری{% else %}Error loading config{% endif %}';
} catch (_) {
document.getElementById('config-output').textContent = '{% if lang == "fa" %}خطای شبکه{% else %}Network error{% endif %}';
}
}
function copyMcpUrl() {
navigator.clipboard.writeText(document.getElementById('mcp-url').textContent.trim());
}
function copyConfig() {
const text = document.getElementById('config-output').textContent;
navigator.clipboard.writeText(text);
const btn = document.getElementById('copy-config-btn');
const orig = btn.textContent;
btn.textContent = '{% if lang == "fa" %}کپی شد!{% else %}Copied!{% endif %}';
setTimeout(() => btn.textContent = orig, 2000);
}
document.addEventListener('DOMContentLoaded', updateConfig);
</script>
{% endblock %}

View File

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

View File

@@ -68,9 +68,57 @@
{% if state %}
<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');
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');
});
// 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 %}

View File

@@ -0,0 +1,3 @@
@tailwind base;
@tailwind components;
@tailwind utilities;

File diff suppressed because one or more lines are too long

765
core/tool_access.py Normal file
View File

@@ -0,0 +1,765 @@
"""Tool access manager — site-scoped visibility and per-site toggles (F.7c).
Provides a central pipeline that filters the set of MCP tools presented for
a user endpoint based on:
1. **Universal scope tiers.** A 3-level system (``read`` / ``write`` /
``admin``) that works across ALL plugins using the tool's
``required_scope`` field. For Coolify (which has fine-grained
``category`` annotations) the legacy category mapping is kept as an
overlay for the ``custom`` preset.
2. **Per-site tool toggles.** Site owners may explicitly disable specific
tools via the ``site_tool_toggles`` table. Only overrides are stored —
tools without an entry are enabled by default.
The ``tool_scope`` value ``"custom"`` is a sentinel meaning "do not apply a
site-level preset filter" — in that case only the per-tool toggles and the
key scope are considered.
Usage::
from core.tool_access import get_tool_access_manager
mgr = get_tool_access_manager()
visible = await mgr.get_visible_tools(
site_id=site["id"],
key_scopes=["read"],
plugin_type="coolify",
)
"""
from __future__ import annotations
import logging
from typing import Any
from core.tool_registry import ToolDefinition
logger = logging.getLogger(__name__)
# ── Universal 3-tier scope system (F.7c) ─────────────────────────────
# Maps a scope tier to the set of ``required_scope`` values it may access.
# Works for ALL plugins because every tool has ``required_scope``.
UNIVERSAL_SCOPE_TIERS: dict[str, set[str]] = {
"read": {"read"},
"write": {"read", "write"},
"admin": {"read", "write", "admin"},
}
# ── Legacy Coolify category mapping (kept for ``custom`` overlay) ─────
# Mapping from scope → set of tool categories that scope may see.
SCOPE_TO_CATEGORIES: dict[str, set[str]] = {
"read": {"read"},
"read:sensitive": {"read", "read_sensitive", "backup"},
"deploy": {"read", "lifecycle"},
"write": {"read", "lifecycle", "crud", "env"},
"admin": {
"read",
"read_sensitive",
"lifecycle",
"crud",
"env",
"backup",
"system",
},
}
# All known Coolify categories.
KNOWN_CATEGORIES: set[str] = {
"read",
"read_sensitive",
"lifecycle",
"crud",
"env",
"backup",
"system",
}
# Sentinel meaning "no site-level preset filter — use per-tool toggles only".
SCOPE_CUSTOM = "custom"
# Plugins that have fine-grained category annotations.
_CATEGORY_PLUGINS: set[str] = {"coolify"}
def scopes_to_categories(scopes: list[str]) -> set[str]:
"""Return the union of categories allowed by the given scope list.
Args:
scopes: List of scope strings as presented on the API key / token.
Returns:
Set of category names the scopes collectively allow.
"""
allowed: set[str] = set()
for scope in scopes:
allowed |= SCOPE_TO_CATEGORIES.get(scope.strip(), set())
return allowed
def _scopes_to_required(scopes: list[str]) -> set[str]:
"""Return the union of ``required_scope`` values allowed by universal tiers."""
allowed: set[str] = set()
for scope in scopes:
allowed |= UNIVERSAL_SCOPE_TIERS.get(scope.strip(), set())
return allowed
def get_scope_presets_for_plugin(plugin_type: str) -> list[dict[str, str]]:
"""Return the appropriate scope presets for a plugin type (F.7d).
Each preset is a dict with ``value`` (the canonical scope key persisted
to ``sites.tool_scope``), ``label`` / ``label_fa`` (button title), and
``hint`` / ``hint_fa`` (one-line description shown under the title).
The valid scope values are constrained by ``_VALID_TOOL_SCOPES`` in
``core.dashboard.routes`` — currently:
``{"read", "read:sensitive", "deploy", "write", "admin", "custom"}``.
"""
custom = {
"value": "custom",
"label": "Custom",
"label_fa": "سفارشی",
"hint": "Per-tool toggles",
"hint_fa": "هر ابزار جداگانه",
}
if plugin_type == "coolify":
# 5 fine-grained Coolify tiers + custom (matches SCOPE_TO_CATEGORIES).
return [
{
"value": "read",
"label": "Read",
"label_fa": "خواندن",
"hint": "List/inspect resources",
"hint_fa": "مشاهده و فهرست منابع",
},
{
"value": "read:sensitive",
"label": "Read + Secrets",
"label_fa": "خواندن + اسرار",
"hint": "Includes env vars and backups",
"hint_fa": "شامل متغیرهای محیطی و بکاپ",
},
{
"value": "deploy",
"label": "Deploy",
"label_fa": "استقرار",
"hint": "Read + lifecycle (start/stop/restart)",
"hint_fa": "مشاهده + راه‌اندازی/توقف/ریستارت",
},
{
"value": "write",
"label": "Write",
"label_fa": "نوشتن",
"hint": "Read + lifecycle + CRUD + env",
"hint_fa": "مشاهده + لایفسایکل + CRUD + env",
},
{
"value": "admin",
"label": "Root",
"label_fa": "روت",
"hint": "Everything including system commands",
"hint_fa": "همه چیز شامل دستورات سیستم",
},
custom,
]
if plugin_type == "openpanel":
return [
{
"value": "read",
"label": "Read",
"label_fa": "خواندن",
"hint": "Export current project only",
"hint_fa": "خروجی پروژه فعلی",
},
{
"value": "write",
"label": "Write",
"label_fa": "نوشتن",
"hint": "Default ingestion (track events)",
"hint_fa": "ارسال رویداد (ingestion)",
},
{
"value": "admin",
"label": "Root",
"label_fa": "روت",
"hint": "Export any project",
"hint_fa": "خروجی هر پروژه",
},
custom,
]
if plugin_type == "woocommerce":
# WooCommerce has no admin-scope tools (read=14, write=14, admin=0),
# so Write and "Read + Write" tiers are identical. Present a single
# full-access tier instead of two duplicates.
return [
{
"value": "read",
"label": "Read Only",
"label_fa": "فقط خواندن",
"hint": "Browse products, orders, customers",
"hint_fa": "مشاهده محصولات، سفارش‌ها و مشتریان",
},
{
"value": "admin",
"label": "Read + Write",
"label_fa": "خواندن + نوشتن",
"hint": "Full store management (all 28 tools)",
"hint_fa": "مدیریت کامل فروشگاه (همه ۲۸ ابزار)",
},
custom,
]
if plugin_type in {"wordpress", "wordpress_advanced"}:
# WordPress has no admin-scope tools (read=27, write=40, admin=0).
# SEO + plugin/theme tools require the Airano MCP SEO Bridge plugin
# to be installed on the WP site itself. Present 2 tiers + custom.
return [
{
"value": "read",
"label": "Read Only",
"label_fa": "فقط خواندن",
"hint": "View posts, pages, media",
"hint_fa": "مشاهده نوشته‌ها، صفحات و رسانه",
},
{
"value": "admin",
"label": "Full Access",
"label_fa": "دسترسی کامل",
"hint": "All tools (CRUD + SEO via add-on)",
"hint_fa": "همه ابزارها (CRUD و SEO با افزونه)",
},
custom,
]
if plugin_type == "gitea":
return [
{
"value": "read",
"label": "Read",
"label_fa": "خواندن",
"hint": "Browse repos, issues, users",
"hint_fa": "مشاهده مخازن، ایشوها، کاربران",
},
{
"value": "write",
"label": "Read + Write",
"label_fa": "خواندن + نوشتن",
"hint": "Create issues, PRs, branches",
"hint_fa": "ایجاد ایشو، PR و شاخه",
},
{
"value": "admin",
"label": "Admin",
"label_fa": "مدیر",
"hint": "Repo + org + user admin",
"hint_fa": "مدیریت مخزن، سازمان و کاربر",
},
custom,
]
# Universal default for all other plugins.
return [
{
"value": "read",
"label": "Read",
"label_fa": "فقط خواندن",
"hint": "View only",
"hint_fa": "فقط مشاهده",
},
{
"value": "write",
"label": "Read + Write",
"label_fa": "خواندن + نوشتن",
"hint": "CRUD ops",
"hint_fa": "عملیات CRUD",
},
{
"value": "admin",
"label": "Full Access",
"label_fa": "دسترسی کامل",
"hint": "All tools",
"hint_fa": "همه ابزارها",
},
custom,
]
class ToolAccessManager:
"""Central manager for scope-based visibility and per-site tool toggles."""
def apply_scope_filter(
self,
tools: list[ToolDefinition],
scopes: list[str],
plugin_type: str | None = None,
) -> list[ToolDefinition]:
"""Drop tools not allowed by the presented scopes.
For plugins with category annotations (Coolify) the legacy
category-based filter is used. For all other plugins the universal
3-tier filter based on ``required_scope`` is applied.
Args:
tools: Candidate tool list.
scopes: Scopes presented on the API key (or a single-element list
containing a site's ``tool_scope`` preset).
plugin_type: Plugin type hint. When provided and the plugin is
NOT in ``_CATEGORY_PLUGINS``, the universal tier filter is
used.
Returns:
Filtered tool list.
"""
# Try universal tiers first (works for all plugins)
allowed_scopes = _scopes_to_required(scopes)
if allowed_scopes and (plugin_type is None or plugin_type not in _CATEGORY_PLUGINS):
# Universal filter: match tool.required_scope against allowed tiers
return [t for t in tools if t.required_scope in allowed_scopes]
# Fallback: legacy category-based filter for Coolify / custom scopes
allowed = scopes_to_categories(scopes)
if not allowed:
return [t for t in tools if t.category not in KNOWN_CATEGORIES]
result: list[ToolDefinition] = []
for tool in tools:
if tool.category not in KNOWN_CATEGORIES:
result.append(tool)
continue
if tool.category in allowed:
result.append(tool)
return result
async def apply_site_toggles(
self,
tools: list[ToolDefinition],
site_id: str,
) -> list[ToolDefinition]:
"""Drop tools the site owner has explicitly disabled.
Args:
tools: Candidate tool list.
site_id: Site UUID.
Returns:
Filtered tool list.
"""
from core.database import get_database
try:
db = get_database()
except RuntimeError:
return tools
toggles = await db.get_site_tool_toggles(site_id)
if not toggles:
return tools
return [t for t in tools if toggles.get(t.name, True)]
async def get_visible_tools(
self,
site_id: str,
key_scopes: list[str],
plugin_type: str,
) -> list[ToolDefinition]:
"""Return the visible tool list for a site on a given plugin.
Pipeline:
1. ``ToolRegistry.get_by_plugin_type``
2. Key-scope filter (API key's declared scopes)
3. Site-scope filter (site's stored ``tool_scope`` preset,
skipped when it is ``custom``)
4. Per-site toggle filter (``site_tool_toggles``)
Args:
site_id: Site UUID (the MCP endpoint alias resolves to this).
key_scopes: Scopes presented on the API key / token.
plugin_type: Plugin type (e.g. ``coolify``).
Returns:
List of visible ``ToolDefinition`` objects.
"""
from core.database import get_database
from core.tool_registry import get_tool_registry
registry = get_tool_registry()
tools = registry.get_by_plugin_type(plugin_type)
tools = self.apply_scope_filter(tools, key_scopes, plugin_type=plugin_type)
try:
db = get_database()
site_scope = await db.get_site_tool_scope(site_id)
except RuntimeError:
site_scope = "admin"
if site_scope and site_scope != SCOPE_CUSTOM:
tools = self.apply_scope_filter(tools, [site_scope], plugin_type=plugin_type)
tools = await self.apply_site_toggles(tools, site_id)
# F.X.fix-pass3: filter out tools whose central prerequisites
# are unmet (no AI provider key, missing companion route,
# missing SEO plugin) so the live endpoint never advertises a
# tool that would 100% fail at call time.
tools = await self.apply_prerequisites_filter(tools, site_id)
return tools
async def toggle_tool(
self,
site_id: str,
tool_name: str,
enabled: bool,
reason: str | None = None,
) -> None:
"""Enable or disable a single tool for a site.
Args:
site_id: Site UUID.
tool_name: Fully-qualified tool name.
enabled: True to enable, False to disable.
reason: Optional note.
"""
from core.database import get_database
db = get_database()
await db.set_site_tool_toggle(site_id, tool_name, enabled, reason)
logger.info(
"site %s toggled %s%s",
site_id,
tool_name,
"enabled" if enabled else "disabled",
)
async def bulk_toggle_by_scope(
self,
site_id: str,
scope_name: str,
enabled: bool,
plugin_type: str | None = None,
) -> int:
"""Toggle every tool whose category belongs to the given scope.
Only the *exclusive* category set of the scope is affected — i.e.
the categories explicitly listed under ``SCOPE_TO_CATEGORIES[scope_name]``.
Tools outside those categories are left unchanged.
Args:
site_id: Site UUID.
scope_name: Scope key (``"read"``, ``"deploy"``, ...).
enabled: True to enable, False to disable.
plugin_type: Optional filter — only affect tools from this plugin.
When ``None`` every plugin's tools in that category are touched.
Returns:
Number of tools affected.
"""
from core.database import get_database
from core.tool_registry import get_tool_registry
categories = SCOPE_TO_CATEGORIES.get(scope_name)
if categories is None:
raise ValueError(f"Unknown scope '{scope_name}'")
registry = get_tool_registry()
candidates = registry.get_all()
if plugin_type is not None:
candidates = [t for t in candidates if t.plugin_type == plugin_type]
affected = [t.name for t in candidates if t.category in categories]
if not affected:
return 0
db = get_database()
await db.bulk_set_site_tool_toggles(
site_id,
[(name, enabled) for name in affected],
reason=f"bulk:{scope_name}",
)
return len(affected)
async def list_tools_for_site(
self,
site_id: str,
plugin_type: str,
) -> list[dict[str, Any]]:
"""Return every tool for a plugin, annotated with per-site toggle state.
Used by the dashboard API to present the per-site management view.
Does not apply scope filters — the UI decides what to show.
F.X.fix #8: each row also carries ``provider_key_required`` and
``provider_key_configured`` so the Tool Access template can
render AI tools greyed + "Configure key" CTA when the site has
no keys for the required provider, instead of letting the user
enable a tool that errors at call time with ``NO_PROVIDER_KEY``.
F.X.fix-pass3: rows also carry ``available`` + ``unavailable_reason``
derived from a central :data:`_TOOL_PREREQUISITES` resolver so
the UI can auto-grey/disable tools whose requirements (companion
route, SEO plugin, AI key) the site doesn't satisfy. This is the
same data the live MCP endpoint uses to silently filter the
catalog (see :meth:`get_visible_tools`).
Args:
site_id: Site UUID.
plugin_type: Plugin type.
Returns:
List of dicts with tool metadata + ``enabled`` flag.
"""
from core.database import get_database
from core.tool_registry import get_tool_registry
try:
db = get_database()
toggles = await db.get_site_tool_toggles(site_id)
except RuntimeError:
toggles = {}
try:
from core.site_api import list_site_providers_set
configured_providers = await list_site_providers_set(site_id)
except Exception: # noqa: BLE001
configured_providers = set()
# F.X.fix-pass3: pull the cached probe payload so we can
# evaluate prerequisites for SEO / companion-route tools.
# ``probe_site_capabilities`` reuses its 10-min cache so the
# cost is one SQLite query in the common path.
probe_payload: dict[str, Any] | None = None
try:
from core.capability_probe import get_probe_cache
cached = get_probe_cache().get(site_id)
if cached is not None:
probe_payload = cached
except Exception: # noqa: BLE001
probe_payload = None
registry = get_tool_registry()
tools = registry.get_by_plugin_type(plugin_type)
rows: list[dict[str, Any]] = []
for t in tools:
available, reason = check_tool_prerequisites(
t.name,
probe_payload=probe_payload,
configured_providers=configured_providers,
)
rows.append(
{
"name": t.name,
"description": t.description,
"plugin_type": t.plugin_type,
"category": t.category,
"sensitivity": t.sensitivity,
"required_scope": t.required_scope,
"enabled": toggles.get(t.name, True),
"provider_key_required": _tool_requires_provider_key(t.name),
"provider_key_configured": _tool_has_configured_provider(
t.name, configured_providers
),
"available": available,
"unavailable_reason": reason,
}
)
return rows
async def apply_prerequisites_filter(
self,
tools: list[ToolDefinition],
site_id: str,
) -> list[ToolDefinition]:
"""Drop tools whose central prerequisites are not satisfied.
Mirrors :meth:`list_tools_for_site` but returns ``ToolDefinition``
objects so the live MCP endpoint pipeline (``get_visible_tools``)
can call it inline. Reads the same cached probe + provider-key
set so a fresh probe never gets triggered from this hot path.
"""
try:
from core.site_api import list_site_providers_set
configured_providers = await list_site_providers_set(site_id)
except Exception: # noqa: BLE001
configured_providers = set()
probe_payload: dict[str, Any] | None = None
try:
from core.capability_probe import get_probe_cache
cached = get_probe_cache().get(site_id)
if cached is not None:
probe_payload = cached
except Exception: # noqa: BLE001
probe_payload = None
kept: list[ToolDefinition] = []
for tool in tools:
available, _ = check_tool_prerequisites(
tool.name,
probe_payload=probe_payload,
configured_providers=configured_providers,
)
if available:
kept.append(tool)
return kept
# F.X.fix #8 — tools that need a per-site AI provider key to succeed.
# Today only the AI image generator; expand as more AI tools land.
_PROVIDER_KEY_REQUIRED_TOOLS: frozenset[str] = frozenset({"wordpress_generate_and_upload_image"})
def _tool_requires_provider_key(tool_name: str) -> bool:
return tool_name in _PROVIDER_KEY_REQUIRED_TOOLS
def _tool_has_configured_provider(tool_name: str, configured: set[str]) -> bool:
"""``True`` iff the site has at least one provider key for this tool.
The AI image tool is happy with ANY supported provider key (the
caller picks which one at call time via the ``provider`` arg) so
any non-empty configured set counts.
"""
if not _tool_requires_provider_key(tool_name):
return True
return bool(configured)
# ── F.X.fix-pass3: central tool-prerequisites resolver ───────────────
# Single source of truth for "this tool needs X to work". The resolver
# below decides per-call whether a tool is *available* on a given site
# given the cached probe payload + the site's configured provider keys.
# Three predicate kinds:
#
# provider_key — site needs at least one of the listed AI provider
# keys.
# companion_route — the companion plugin must advertise the named
# route in probe.routes.
# feature_any — the WP probe features must include at least one of
# the listed feature names (e.g. rank_math / yoast).
#
# The resolver's output (``available`` + ``unavailable_reason``) is
# attached to every tool row in ``list_tools_for_site`` AND used by
# ``apply_prerequisites_filter`` to drop unavailable tools from the
# live MCP endpoint, so models calling the endpoint never see a tool
# that is guaranteed to fail at call time.
_TOOL_PREREQUISITES: dict[str, list[dict[str, Any]]] = {
# AI image — any provider key is enough; the caller picks at call time.
"wordpress_generate_and_upload_image": [
{
"kind": "provider_key",
"any_of": ["openai", "stability", "replicate", "openrouter"],
}
],
# Companion-route-backed WP tools.
"wordpress_cache_purge": [{"kind": "companion_route", "name": "cache_purge"}],
"wordpress_bulk_update_meta": [{"kind": "companion_route", "name": "bulk_meta"}],
"wordpress_export_content": [{"kind": "companion_route", "name": "export"}],
"wordpress_site_health": [{"kind": "companion_route", "name": "site_health"}],
"wordpress_transient_flush": [{"kind": "companion_route", "name": "transient_flush"}],
"wordpress_audit_hook_status": [{"kind": "companion_route", "name": "audit_hook"}],
"wordpress_audit_hook_configure": [{"kind": "companion_route", "name": "audit_hook"}],
"wordpress_audit_hook_disable": [{"kind": "companion_route", "name": "audit_hook"}],
"wordpress_regenerate_thumbnails": [
{"kind": "companion_route", "name": "regenerate_thumbnails"}
],
"wordpress_bulk_delete_media": [{"kind": "companion_route", "name": "bulk_meta"}],
"wordpress_bulk_reassign_media": [{"kind": "companion_route", "name": "bulk_meta"}],
# SEO — needs Rank Math (or Yoast in the future). Probe surfaces
# both flags; we accept either.
"wordpress_get_post_seo": [{"kind": "feature_any", "names": ["rank_math", "yoast"]}],
"wordpress_update_post_seo": [{"kind": "feature_any", "names": ["rank_math", "yoast"]}],
"wordpress_get_product_seo": [{"kind": "feature_any", "names": ["rank_math", "yoast"]}],
"wordpress_update_product_seo": [{"kind": "feature_any", "names": ["rank_math", "yoast"]}],
"wordpress_get_internal_links": [{"kind": "feature_any", "names": ["rank_math", "yoast"]}],
# F.X.fix-pass5 — WooCommerce media tools hit /wp/v2/media which
# WC consumer_key + secret can't authenticate. Need a WP App
# Password (wp_username + wp_app_password fields, or legacy
# username + app_password single-credential mode).
"woocommerce_attach_media_to_product": [{"kind": "wp_credentials"}],
"woocommerce_upload_and_attach_to_product": [{"kind": "wp_credentials"}],
"woocommerce_set_featured_image": [{"kind": "wp_credentials"}],
# AI image, exposed on both WP and WC plugins. The WC variant
# needs both an AI provider key AND WP credentials (to upload
# /wp/v2/media); the WP variant only needs the provider key
# since its primary client already uses Application Password.
"woocommerce_generate_and_upload_image": [
{"kind": "provider_key", "any_of": ["openai", "stability", "replicate", "openrouter"]},
{"kind": "wp_credentials"},
],
}
def check_tool_prerequisites(
tool_name: str,
*,
probe_payload: dict[str, Any] | None,
configured_providers: set[str],
) -> tuple[bool, str | None]:
"""Decide whether a tool's prerequisites are satisfied.
Returns ``(available, unavailable_reason)``. ``unavailable_reason``
is one of ``provider_key`` | ``companion_route`` | ``feature``
| ``probe_unknown`` when the tool is unavailable, else ``None``.
A tool with no prerequisites declared is always available — most
of the catalog (plain CRUD over WP REST) needs no companion / no
AI key, so the default of "no entry → True" keeps the rule list
short.
"""
rules = _TOOL_PREREQUISITES.get(tool_name)
if not rules:
return True, None
routes = ((probe_payload or {}).get("routes")) or {}
features = ((probe_payload or {}).get("features")) or {}
for rule in rules:
kind = rule.get("kind")
if kind == "provider_key":
allowed = set(rule.get("any_of") or [])
if allowed and not (allowed & configured_providers):
return False, "provider_key"
elif kind == "companion_route":
name = rule.get("name") or ""
# Probe is authoritative when present; absence of probe data
# (e.g. companion not installed → companion_available=False)
# is also treated as "unavailable".
if not routes.get(name):
return False, "companion_route"
elif kind == "feature_any":
names = list(rule.get("names") or [])
# WP companion's features dict carries booleans like
# rank_math: true / yoast: false. WC has no analogous gate.
if not any(features.get(n) for n in names):
return False, "feature"
elif kind == "wp_credentials":
# F.X.fix-pass5 — WC media tools and the WC AI image tool
# need a WP Application Password to authenticate
# /wp/v2/media. The WC plugin probe surfaces a
# ``wp_credentials_present`` flag; absence is a hard fail.
if not (probe_payload or {}).get("wp_credentials_present"):
return False, "wp_credentials"
return True, None
# Singleton
_manager: ToolAccessManager | None = None
def get_tool_access_manager() -> ToolAccessManager:
"""Return the singleton :class:`ToolAccessManager`."""
global _manager
if _manager is None:
_manager = ToolAccessManager()
return _manager

View File

@@ -181,6 +181,9 @@ class ToolGenerator:
description = spec["description"]
schema = spec["schema"]
scope = spec.get("scope", "read")
# F.7: optional category + sensitivity for scope-based visibility
category = spec.get("category", "read")
sensitivity = spec.get("sensitivity", "normal")
# Create full tool name
tool_name = f"{plugin_type}_{action_name}"
@@ -202,6 +205,8 @@ class ToolGenerator:
handler=handler,
required_scope=scope,
plugin_type=plugin_type,
category=category,
sensitivity=sensitivity,
)
def _add_site_parameter(
@@ -448,12 +453,15 @@ class ToolGenerator:
except Exception as e:
# Import custom exceptions for better error handling
from plugins.wordpress.client import AuthenticationError, ConfigurationError
from plugins.wordpress.client import (
AuthenticationError,
ConfigurationError,
ConnectionError,
)
error_type = type(e).__name__
if isinstance(e, ConfigurationError):
# Configuration error - likely missing env vars
logger.error(f"Configuration error in {plugin_type}_{method_name}: {e}")
return (
f"Configuration Error: {str(e)}\n\n"
@@ -464,12 +472,14 @@ class ToolGenerator:
)
elif isinstance(e, AuthenticationError):
# Authentication error - 401/403
logger.warning(f"Authentication error in {plugin_type}_{method_name}: {e}")
return f"Authentication Error: {str(e)}"
elif isinstance(e, ConnectionError):
logger.warning(f"Connection error in {plugin_type}_{method_name}: {e}")
return f"Connection Error: {str(e)}"
else:
# Unexpected error
logger.error(
f"Error in unified handler for {plugin_type}_{method_name}: {e}",
exc_info=True,

122
core/tool_rate_limiter.py Normal file
View File

@@ -0,0 +1,122 @@
"""Per-tool, per-user rate limiting (F.5a.6).
Provides a small token-bucket limiter keyed by (user_id, tool_name) for
expensive tools such as AI image generation and chunked-upload finish.
Admin / env-fallback callers (``user_id is None``) are exempt.
Intentionally minimal — separate from :mod:`core.rate_limiter` (which is a
global per-client limiter). This one is scoped to specific tools with
small hourly caps.
"""
from __future__ import annotations
import logging
import threading
from dataclasses import dataclass
from core.rate_limiter import TokenBucket
logger = logging.getLogger(__name__)
# Default caps (per hour per user). Documented in ROADMAP F.5a.6.
DEFAULT_LIMITS: dict[str, int] = {
"wordpress_generate_and_upload_image": 10,
"wordpress_upload_media_chunked_finish": 30,
}
@dataclass
class ToolRateLimitError(Exception):
"""Raised when a per-tool limit is exceeded."""
tool_name: str
limit_per_hour: int
retry_after_seconds: float
def __post_init__(self) -> None:
super().__init__(
f"Per-tool rate limit exceeded for '{self.tool_name}' "
f"({self.limit_per_hour}/hour per user). "
f"Retry in {self.retry_after_seconds:.0f}s."
)
def to_dict(self) -> dict:
return {
"error_code": "TOOL_RATE_LIMITED",
"message": str(self),
"details": {
"tool": self.tool_name,
"limit_per_hour": self.limit_per_hour,
"retry_after_seconds": round(self.retry_after_seconds, 2),
},
}
class PerToolRateLimiter:
"""Token-bucket limiter keyed by (user_id, tool_name).
``check(tool, user_id)`` consumes one token and raises
:class:`ToolRateLimitError` when the user is over quota. Admin / env
callers (``user_id`` is None or empty) are exempt.
"""
def __init__(self, limits: dict[str, int] | None = None) -> None:
self._limits = dict(limits if limits is not None else DEFAULT_LIMITS)
self._buckets: dict[tuple[str, str], TokenBucket] = {}
self._lock = threading.Lock()
def configure(self, tool_name: str, per_hour: int) -> None:
"""Override the per-hour cap for a tool."""
self._limits[tool_name] = per_hour
# Existing buckets keep their old capacity until reset — tests can
# reset() to re-read the new limit.
def reset(self) -> None:
with self._lock:
self._buckets.clear()
def check(self, tool_name: str, user_id: str | None) -> None:
"""Consume one token for (user_id, tool_name). Exempt when user_id is falsy."""
if not user_id:
return
limit = self._limits.get(tool_name)
if limit is None or limit <= 0:
return
key = (user_id, tool_name)
with self._lock:
bucket = self._buckets.get(key)
if bucket is None:
bucket = TokenBucket(capacity=limit, refill_rate=limit / 3600.0)
self._buckets[key] = bucket
if not bucket.consume(1):
wait = bucket.get_wait_time(1)
logger.warning(
"Per-tool rate limit hit: user=%s tool=%s limit=%d/h retry_after=%.1fs",
user_id,
tool_name,
limit,
wait,
)
raise ToolRateLimitError(
tool_name=tool_name, limit_per_hour=limit, retry_after_seconds=wait
)
_limiter: PerToolRateLimiter | None = None
def get_tool_rate_limiter() -> PerToolRateLimiter:
global _limiter
if _limiter is None:
_limiter = PerToolRateLimiter()
return _limiter
def set_tool_rate_limiter(limiter: PerToolRateLimiter | None) -> None:
"""Override the singleton (used by tests)."""
global _limiter
_limiter = limiter

View File

@@ -27,6 +27,10 @@ class ToolDefinition(BaseModel):
handler: Async function that executes the tool
required_scope: Required API key scope ("read", "write", "admin")
plugin_type: Plugin type this tool belongs to (e.g., "wordpress")
category: Tool category for scope-based visibility filtering (F.7)
One of: "read", "read_sensitive", "lifecycle", "crud", "env",
"backup", "system". Defaults to "read" for backward compatibility.
sensitivity: "normal" or "sensitive" (logs, envs, backups, connection strings).
"""
name: str = Field(..., description="Unique tool identifier")
@@ -40,6 +44,14 @@ class ToolDefinition(BaseModel):
default="read", description="Required API key scope (read/write/admin)"
)
plugin_type: str = Field(..., description="Plugin type (wordpress, gitea, etc)")
category: str = Field(
default="read",
description="Tool category for scope-based visibility (F.7)",
)
sensitivity: str = Field(
default="normal",
description="Data sensitivity: normal or sensitive (F.7)",
)
model_config = ConfigDict(arbitrary_types_allowed=True) # Allow Callable type

View File

@@ -1,363 +0,0 @@
"""
Unified Tool Generator
Generates context-based tools that work across multiple sites.
Maintains backward compatibility by keeping per-site tools.
Architecture:
- Old: wordpress_site1_get_post(post_id)
- New: wordpress_get_post(site, post_id)
- Both work simultaneously!
"""
import logging
from collections.abc import Callable
from typing import Any
from core.site_registry import get_site_registry
logger = logging.getLogger(__name__)
class UnifiedToolGenerator:
"""
Generates unified tools from per-site tool definitions.
Takes existing plugin tools and creates context-based versions
that accept a 'site' parameter for dynamic routing.
"""
def __init__(self, project_manager):
"""
Initialize unified tool generator.
Args:
project_manager: ProjectManager instance with discovered projects
"""
self.project_manager = project_manager
self.site_registry = get_site_registry()
self.logger = logging.getLogger("UnifiedToolGenerator")
def generate_unified_tools(self, plugin_type: str) -> list[dict[str, Any]]:
"""
Generate unified tools for a specific plugin type.
Args:
plugin_type: Type of plugin (e.g., 'wordpress')
Returns:
List of unified tool definitions
"""
# Get all projects of this type
projects = self.project_manager.get_projects_by_type(plugin_type)
if not projects:
self.logger.warning(f"No projects found for plugin type: {plugin_type}")
return []
# Use the first project as a template to get tool definitions
first_project_id = list(projects.keys())[0]
template_plugin = projects[first_project_id]
template_tools = template_plugin.get_tools()
self.logger.info(
f"Generating unified tools for {plugin_type} "
f"from {len(template_tools)} template tools"
)
unified_tools = []
seen_actions = set()
for tool in template_tools:
# Extract action name from per-site tool name
# e.g., "wordpress_site1_get_post" -> "get_post"
tool_name = tool["name"]
parts = tool_name.split("_")
# Skip if not in expected format
if len(parts) < 3:
continue
# Extract action (everything after plugin_type_site_id_)
# e.g., wordpress_site1_get_post -> get_post
action = "_".join(parts[2:])
# Skip duplicates (we only need one unified tool per action)
if action in seen_actions:
continue
seen_actions.add(action)
# Create unified tool
unified_tool = self._create_unified_tool(
plugin_type=plugin_type, action=action, template_tool=tool
)
if unified_tool:
unified_tools.append(unified_tool)
self.logger.info(f"Generated {len(unified_tools)} unified tools for {plugin_type}")
return unified_tools
def _create_unified_tool(
self, plugin_type: str, action: str, template_tool: dict[str, Any]
) -> dict[str, Any] | None:
"""
Create a unified tool from a template.
Args:
plugin_type: Plugin type (e.g., 'wordpress')
action: Action name (e.g., 'get_post')
template_tool: Original per-site tool definition
Returns:
Unified tool definition
"""
try:
# Create unified tool name
unified_name = f"{plugin_type}_{action}"
# Get available sites for this plugin type
site_options = self.site_registry.get_site_options(plugin_type)
if not site_options:
self.logger.warning(f"No sites available for {plugin_type}, skipping {action}")
return None
# Modify input schema to add 'site' parameter
original_schema = template_tool.get("inputSchema", {})
unified_schema = self._add_site_parameter(original_schema, plugin_type, site_options)
# Update description to mention site parameter
original_description = template_tool.get("description", "")
unified_description = self._update_description(original_description, plugin_type)
# Create wrapper handler
original_handler = template_tool.get("handler")
unified_handler = self._create_unified_handler(plugin_type, action, original_handler)
return {
"name": unified_name,
"description": unified_description,
"inputSchema": unified_schema,
"handler": unified_handler,
}
except Exception as e:
self.logger.error(
f"Error creating unified tool for {plugin_type}_{action}: {e}", exc_info=True
)
return None
def _add_site_parameter(
self, original_schema: dict[str, Any], plugin_type: str, site_options: list[str]
) -> dict[str, Any]:
"""
Add 'site' parameter to input schema.
Args:
original_schema: Original input schema
plugin_type: Plugin type
site_options: Available site IDs/aliases
Returns:
Modified schema with site parameter
"""
# Deep copy to avoid modifying original
import copy
schema = copy.deepcopy(original_schema)
# Ensure schema has required structure
if "properties" not in schema:
schema["properties"] = {}
if "required" not in schema:
schema["required"] = []
# Add 'site' as first parameter
schema["properties"] = {
"site": {
"type": "string",
"description": (
f"Site ID or alias. Available options: {', '.join(site_options)}. "
f"Use list_projects() to see all configured sites."
),
"enum": site_options,
},
**schema["properties"],
}
# Make 'site' required
if "site" not in schema["required"]:
schema["required"].insert(0, "site")
return schema
def _update_description(self, original_description: str, plugin_type: str) -> str:
"""
Update tool description to mention unified context.
Args:
original_description: Original description
plugin_type: Plugin type
Returns:
Updated description
"""
# Remove site-specific mentions (e.g., "from site1", "in site2")
import re
cleaned = re.sub(
r"\b(?:from|in|for)\s+site\d+\b", "", original_description, flags=re.IGNORECASE
)
cleaned = re.sub(
r"\bsite\d+\b", f"the specified {plugin_type} site", cleaned, flags=re.IGNORECASE
)
# Add unified context note
prefix = "[UNIFIED] "
if not cleaned.startswith(prefix):
cleaned = prefix + cleaned
return cleaned.strip()
def _create_unified_handler(
self, plugin_type: str, action: str, original_handler: Callable | None
) -> Callable:
"""
Create a unified handler that routes to the correct site.
Args:
plugin_type: Plugin type
action: Action name
original_handler: Original handler (not used, we call plugin method directly)
Returns:
Async handler function
"""
async def unified_handler(site: str, **kwargs):
"""
Unified handler that routes to the correct site plugin.
Args:
site: Site ID or alias
**kwargs: Other parameters for the tool
"""
try:
# Get site info from registry
site_info = self.site_registry.get_site(plugin_type, site)
if not site_info:
available = self.site_registry.get_site_options(plugin_type)
return (
f"Error: Site '{site}' not found for {plugin_type}. "
f"Available sites: {', '.join(available)}"
)
# Get the plugin instance
full_id = site_info.get_full_id()
# SECURITY: Check if API key has access to this project
from core.context import get_api_key_context
api_key_info = get_api_key_context()
if api_key_info and not api_key_info.get("is_global"):
# Per-project key - must match the project
allowed_project = api_key_info.get("project_id")
# Resolve allowed_project to normalize alias vs site_id
# API key might have been created with alias (wordpress_myblog)
# or site_id (wordpress_site1)
allowed_project_normalized = allowed_project
if allowed_project and "_" in allowed_project:
# Extract plugin type and site identifier from allowed_project
allowed_parts = allowed_project.split("_", 1)
if len(allowed_parts) == 2:
allowed_plugin_type, allowed_site_identifier = allowed_parts
# Try to resolve the site identifier to site_id
try:
allowed_site_info = self.site_registry.get_site(
allowed_plugin_type, allowed_site_identifier
)
if allowed_site_info:
# Normalize to plugin_type_site_id format
allowed_project_normalized = allowed_site_info.get_full_id()
except (ValueError, Exception):
# Site not found, keep original for error message
pass
if allowed_project_normalized != full_id:
logger.warning(
f"Access denied: API key for project '{allowed_project}' "
f"attempted to access '{full_id}'"
)
return (
f"Error: Access denied. This API key is restricted to project '{allowed_project}'. "
f"Use a global API key or create a key for '{full_id}'."
)
plugin = self.project_manager.get_project(full_id)
if not plugin:
return f"Error: Plugin instance not found for {full_id}"
# Find the original handler method in the plugin
# The original per-site tool name was: {plugin_type}_{site_id}_{action}
original_tool_name = f"{plugin_type}_{site_info.site_id}_{action}"
# Get all tools from plugin and find the matching handler
tools = plugin.get_tools()
handler = None
for tool in tools:
if tool["name"] == original_tool_name:
handler = tool.get("handler")
break
if not handler:
return (
f"Error: Handler not found for {original_tool_name}. "
f"This may be a plugin implementation issue."
)
# Filter out None values from kwargs to avoid validation errors
# WordPress API doesn't accept None values in query parameters
filtered_kwargs = {key: value for key, value in kwargs.items() if value is not None}
# Call the handler with filtered kwargs
result = await handler(**filtered_kwargs)
return result
except Exception as e:
logger.error(
f"Error in unified handler for {plugin_type}_{action}: {e}", exc_info=True
)
return f"Error: {str(e)}"
return unified_handler
def generate_all_unified_tools(self) -> list[dict[str, Any]]:
"""
Generate unified tools for all registered plugin types.
Returns:
List of all unified tool definitions
"""
all_tools = []
# Get all plugin types from registry
from plugins import registry
plugin_types = registry.get_registered_types()
for plugin_type in plugin_types:
tools = self.generate_unified_tools(plugin_type)
all_tools.extend(tools)
self.logger.info(
f"Generated {len(all_tools)} total unified tools "
f"across {len(plugin_types)} plugin types"
)
return all_tools

474
core/upload_sessions.py Normal file
View File

@@ -0,0 +1,474 @@
"""Chunked upload session store (F.5a.5).
Server-side buffering for large media uploads: SQLite metadata + disk spill.
Sessions are deterministic (same user+file metadata yields the same
session_id), enabling resumable uploads. Enforces per-user concurrency
quota, per-session byte cap, and a 1h TTL reaped by a background task.
Chunks are appended sequentially; out-of-order or duplicate indexes raise
a typed error. Optional full-payload sha256 (supplied at `start`) is
verified when the session is finalized.
"""
from __future__ import annotations
import asyncio
import hashlib
import logging
import os
import uuid
from datetime import UTC, datetime, timedelta
from pathlib import Path
from typing import Any
from core.database import Database, get_database
logger = logging.getLogger(__name__)
# --- Config ----------------------------------------------------------------
DEFAULT_SPILL_DIR = Path(os.environ.get("MCPHUB_UPLOAD_SPILL_DIR", "/tmp/mcphub-uploads"))
SESSION_TTL = timedelta(seconds=int(os.environ.get("MCPHUB_UPLOAD_TTL_SEC", "3600")))
MAX_SESSION_BYTES = int(os.environ.get("MCPHUB_UPLOAD_MAX_BYTES", str(500 * 1024 * 1024)))
MAX_CONCURRENT_PER_USER = int(os.environ.get("MCPHUB_UPLOAD_MAX_CONCURRENT", "10"))
# --- Errors ----------------------------------------------------------------
class UploadSessionError(Exception):
"""Typed session error with stable JSON code."""
def __init__(self, code: str, message: str, details: dict | None = None):
super().__init__(message)
self.code = code
self.message = message
self.details = details or {}
def to_dict(self) -> dict:
return {"error_code": self.code, "message": self.message, "details": self.details}
# --- Helpers ---------------------------------------------------------------
def _utc_now() -> datetime:
return datetime.now(UTC)
def _iso(dt: datetime) -> str:
return dt.isoformat()
def _parse_iso(value: str) -> datetime:
dt = datetime.fromisoformat(value)
if dt.tzinfo is None:
dt = dt.replace(tzinfo=UTC)
return dt
def make_session_id(
user_id: str,
filename: str,
total_bytes: int,
mime: str | None,
sha256: str | None,
) -> str:
"""Deterministic session id — same tuple → same id (enables resume)."""
payload = f"{user_id}|{filename}|{total_bytes}|{mime or ''}|{sha256 or ''}"
return hashlib.sha256(payload.encode("utf-8")).hexdigest()[:32]
def _ensure_spill_dir(spill_dir: Path) -> None:
spill_dir.mkdir(parents=True, exist_ok=True)
try:
os.chmod(spill_dir, 0o700)
except OSError:
pass
# --- Store -----------------------------------------------------------------
class UploadSession:
"""Runtime view of an upload session row."""
__slots__ = (
"id",
"user_id",
"filename",
"total_bytes",
"mime",
"sha256",
"received_bytes",
"next_chunk",
"spill_path",
"status",
"created_at",
"expires_at",
)
def __init__(self, **kwargs: Any) -> None:
for name in self.__slots__:
setattr(self, name, kwargs.get(name))
@classmethod
def from_row(cls, row: dict[str, Any]) -> UploadSession:
return cls(
id=row["id"],
user_id=row["user_id"],
filename=row["filename"],
total_bytes=int(row["total_bytes"]),
mime=row["mime"],
sha256=row["sha256"],
received_bytes=int(row["received_bytes"]),
next_chunk=int(row["next_chunk"]),
spill_path=Path(row["spill_path"]),
status=row["status"],
created_at=_parse_iso(row["created_at"]),
expires_at=_parse_iso(row["expires_at"]),
)
def to_public_dict(self) -> dict[str, Any]:
return {
"session_id": self.id,
"filename": self.filename,
"total_bytes": self.total_bytes,
"received_bytes": self.received_bytes,
"next_chunk": self.next_chunk,
"status": self.status,
"expires_at": _iso(self.expires_at),
}
class UploadSessionStore:
"""Persistent chunk-upload session store with disk spill."""
def __init__(
self,
*,
db: Database | None = None,
spill_dir: Path | None = None,
ttl: timedelta = SESSION_TTL,
max_session_bytes: int = MAX_SESSION_BYTES,
max_concurrent_per_user: int = MAX_CONCURRENT_PER_USER,
) -> None:
self._db = db
self.spill_dir = Path(spill_dir or DEFAULT_SPILL_DIR)
self.ttl = ttl
self.max_session_bytes = max_session_bytes
self.max_concurrent_per_user = max_concurrent_per_user
self._lock = asyncio.Lock()
_ensure_spill_dir(self.spill_dir)
@property
def db(self) -> Database:
return self._db or get_database()
# -- start -------------------------------------------------------------
async def start(
self,
*,
user_id: str,
filename: str,
total_bytes: int,
mime: str | None = None,
sha256: str | None = None,
) -> UploadSession:
if total_bytes <= 0:
raise UploadSessionError("BAD_SIZE", "total_bytes must be positive.")
if total_bytes > self.max_session_bytes:
raise UploadSessionError(
"SESSION_TOO_LARGE",
f"total_bytes {total_bytes} exceeds limit {self.max_session_bytes}.",
{"max": self.max_session_bytes},
)
session_id = make_session_id(user_id, filename, total_bytes, mime, sha256)
async with self._lock:
existing = await self._get_row(session_id)
if existing is not None:
sess = UploadSession.from_row(existing)
if sess.status == "open" and sess.expires_at > _utc_now():
return sess
# Stale/finished — replace
await self._delete_row(session_id)
_unlink_silent(sess.spill_path)
open_count = await self._count_open_for_user(user_id)
if open_count >= self.max_concurrent_per_user:
raise UploadSessionError(
"QUOTA_EXCEEDED",
f"User has {open_count} open upload sessions "
f"(max {self.max_concurrent_per_user}).",
{"open": open_count, "max": self.max_concurrent_per_user},
)
now = _utc_now()
expires = now + self.ttl
spill_path = self.spill_dir / f"{session_id}.part"
# Touch an empty spill file with 0600 perms
_ensure_spill_dir(self.spill_dir)
with open(spill_path, "wb") as f:
f.truncate(0)
try:
os.chmod(spill_path, 0o600)
except OSError:
pass
await self.db.execute(
"INSERT INTO upload_sessions "
"(id, user_id, filename, total_bytes, mime, sha256, "
" received_bytes, next_chunk, spill_path, status, "
" created_at, expires_at) "
"VALUES (?, ?, ?, ?, ?, ?, 0, 0, ?, 'open', ?, ?)",
(
session_id,
user_id,
filename,
total_bytes,
mime,
sha256,
str(spill_path),
_iso(now),
_iso(expires),
),
)
return UploadSession(
id=session_id,
user_id=user_id,
filename=filename,
total_bytes=total_bytes,
mime=mime,
sha256=sha256,
received_bytes=0,
next_chunk=0,
spill_path=spill_path,
status="open",
created_at=now,
expires_at=expires,
)
# -- append chunk ------------------------------------------------------
async def append_chunk(
self,
session_id: str,
index: int,
data: bytes,
*,
chunk_sha256: str | None = None,
) -> UploadSession:
async with self._lock:
sess = await self._require_open(session_id)
if index != sess.next_chunk:
raise UploadSessionError(
"CHUNK_ORDER",
f"Expected chunk index {sess.next_chunk}, got {index}.",
{"expected": sess.next_chunk, "got": index},
)
new_size = sess.received_bytes + len(data)
if new_size > sess.total_bytes:
raise UploadSessionError(
"CHUNK_OVERFLOW",
f"Chunk would exceed declared total_bytes "
f"({new_size} > {sess.total_bytes}).",
{"declared": sess.total_bytes, "would_be": new_size},
)
if chunk_sha256 is not None:
actual = hashlib.sha256(data).hexdigest()
if actual.lower() != chunk_sha256.lower():
raise UploadSessionError(
"CHUNK_CHECKSUM",
"Chunk sha256 does not match supplied value.",
{"expected": chunk_sha256, "actual": actual, "index": index},
)
with open(sess.spill_path, "ab") as f:
f.write(data)
sess.received_bytes = new_size
sess.next_chunk = index + 1
await self.db.execute(
"UPDATE upload_sessions SET received_bytes = ?, next_chunk = ? WHERE id = ?",
(sess.received_bytes, sess.next_chunk, session_id),
)
return sess
# -- finalize ----------------------------------------------------------
async def finalize(self, session_id: str) -> tuple[UploadSession, bytes]:
"""Read the full spill file and verify. Returns (session, bytes).
On checksum mismatch the session is kept (status remains 'open') so
the caller can retry; on success, the row and spill file are removed.
"""
async with self._lock:
sess = await self._require_open(session_id)
if sess.received_bytes != sess.total_bytes:
raise UploadSessionError(
"INCOMPLETE",
f"Received {sess.received_bytes}/{sess.total_bytes} bytes.",
{
"received": sess.received_bytes,
"total": sess.total_bytes,
},
)
with open(sess.spill_path, "rb") as f:
data = f.read()
if sess.sha256:
actual = hashlib.sha256(data).hexdigest()
if actual.lower() != sess.sha256.lower():
raise UploadSessionError(
"CHECKSUM_MISMATCH",
"Assembled sha256 does not match value supplied at start.",
{"expected": sess.sha256, "actual": actual},
)
# Success — drop the session
await self._delete_row(session_id)
_unlink_silent(sess.spill_path)
return sess, data
# -- abort -------------------------------------------------------------
async def abort(self, session_id: str) -> bool:
async with self._lock:
row = await self._get_row(session_id)
if row is None:
return False
sess = UploadSession.from_row(row)
await self._delete_row(session_id)
_unlink_silent(sess.spill_path)
return True
# -- get ---------------------------------------------------------------
async def get(self, session_id: str) -> UploadSession | None:
row = await self._get_row(session_id)
return UploadSession.from_row(row) if row else None
# -- cleanup -----------------------------------------------------------
async def cleanup_expired(self, *, now: datetime | None = None) -> int:
now = now or _utc_now()
rows = await self.db.fetchall(
"SELECT * FROM upload_sessions WHERE expires_at < ?", (_iso(now),)
)
reaped = 0
for row in rows:
sess = UploadSession.from_row(row)
_unlink_silent(sess.spill_path)
await self._delete_row(sess.id)
reaped += 1
if reaped:
logger.info("Reaped %d expired upload session(s)", reaped)
return reaped
# -- internals ---------------------------------------------------------
async def _get_row(self, session_id: str) -> dict[str, Any] | None:
return await self.db.fetchone("SELECT * FROM upload_sessions WHERE id = ?", (session_id,))
async def _delete_row(self, session_id: str) -> None:
await self.db.execute("DELETE FROM upload_sessions WHERE id = ?", (session_id,))
async def _count_open_for_user(self, user_id: str) -> int:
row = await self.db.fetchone(
"SELECT COUNT(*) AS c FROM upload_sessions "
"WHERE user_id = ? AND status = 'open' AND expires_at > ?",
(user_id, _iso(_utc_now())),
)
return int(row["c"]) if row else 0
async def _require_open(self, session_id: str) -> UploadSession:
row = await self._get_row(session_id)
if row is None:
raise UploadSessionError(
"NO_SESSION", f"Session {session_id} not found.", {"session_id": session_id}
)
sess = UploadSession.from_row(row)
if sess.status != "open":
raise UploadSessionError(
"BAD_STATE",
f"Session {session_id} is in state '{sess.status}'.",
{"state": sess.status},
)
if sess.expires_at <= _utc_now():
raise UploadSessionError(
"EXPIRED", f"Session {session_id} has expired.", {"session_id": session_id}
)
return sess
def _unlink_silent(path: Path) -> None:
try:
path.unlink(missing_ok=True)
except OSError as e:
logger.debug("Could not remove spill file %s: %s", path, e)
# --- Singleton + background cleanup ---------------------------------------
_store: UploadSessionStore | None = None
def get_upload_session_store() -> UploadSessionStore:
global _store
if _store is None:
_store = UploadSessionStore()
return _store
def set_upload_session_store(store: UploadSessionStore | None) -> None:
"""Override the singleton (used by tests)."""
global _store
_store = store
class CleanupTask:
"""Periodically reaps expired sessions. Register in server lifespan."""
def __init__(
self,
store: UploadSessionStore | None = None,
interval_seconds: int = 300,
) -> None:
self._store = store
self.interval = interval_seconds
self._task: asyncio.Task | None = None
self._stop = asyncio.Event()
self.id = uuid.uuid4().hex[:8]
@property
def store(self) -> UploadSessionStore:
return self._store or get_upload_session_store()
async def start(self) -> None:
if self._task is not None:
return
self._stop.clear()
self._task = asyncio.create_task(self._run(), name=f"upload-cleanup-{self.id}")
async def stop(self) -> None:
if self._task is None:
return
self._stop.set()
self._task.cancel()
try:
await self._task
except (asyncio.CancelledError, Exception):
pass
self._task = None
async def _run(self) -> None:
while not self._stop.is_set():
try:
await self.store.cleanup_expired()
except Exception as e: # noqa: BLE001
logger.warning("Upload-session cleanup error: %s", e)
try:
await asyncio.wait_for(self._stop.wait(), timeout=self.interval)
except TimeoutError:
continue

483
core/user_auth.py Normal file
View File

@@ -0,0 +1,483 @@
"""User authentication system with OAuth Social Login (GitHub + Google).
Handles OAuth 2.0 authorization flows, token exchange, user info fetching,
session creation (JWT cookies), and registration rate limiting.
This module is for user-facing authentication (Track E.2). The existing
admin authentication (core/auth.py, core/dashboard/auth.py) is unchanged.
Usage:
auth = initialize_user_auth(
github_client_id="...",
github_client_secret="...",
public_url="https://mcp.example.com",
)
url, state = auth.get_authorization_url("github")
# ... user redirected, callback received ...
user_info = await auth.exchange_code("github", code)
token = auth.create_user_session(user_id="...", email="...", name="...", role="user")
"""
import logging
import os
import secrets
import time
from urllib.parse import urlencode
import httpx
import jwt
logger = logging.getLogger(__name__)
# OAuth provider endpoints
_GITHUB_AUTH_URL = "https://github.com/login/oauth/authorize"
_GITHUB_TOKEN_URL = "https://github.com/login/oauth/access_token"
_GITHUB_USER_URL = "https://api.github.com/user"
_GITHUB_EMAILS_URL = "https://api.github.com/user/emails"
_GOOGLE_AUTH_URL = "https://accounts.google.com/o/oauth2/v2/auth"
_GOOGLE_TOKEN_URL = "https://oauth2.googleapis.com/token"
_GOOGLE_USERINFO_URL = "https://www.googleapis.com/oauth2/v3/userinfo"
# State expiry: 10 minutes
_STATE_EXPIRY_SECONDS = 600
# Registration rate limit: 3 per IP per hour
_REG_RATE_LIMIT = 3
_REG_RATE_WINDOW = 3600 # 1 hour
class OAuthProvider:
"""OAuth provider constants (for type hints / constants)."""
GITHUB = "github"
GOOGLE = "google"
class UserAuth:
"""OAuth Social Login and user session management.
Manages OAuth authorization URL generation, code-for-token exchange,
user info fetching, JWT session creation/validation, and registration
rate limiting.
"""
def __init__(
self,
github_client_id: str | None = None,
github_client_secret: str | None = None,
google_client_id: str | None = None,
google_client_secret: str | None = None,
public_url: str | None = None,
session_secret: str | None = None,
session_expiry_hours: int = 168, # 7 days
) -> None:
"""Initialize user authentication.
Args:
github_client_id: GitHub OAuth App client ID.
github_client_secret: GitHub OAuth App client secret.
google_client_id: Google OAuth client ID.
google_client_secret: Google OAuth client secret.
public_url: Public-facing URL of the server (for callbacks).
session_secret: Secret key for JWT session signing.
session_expiry_hours: Session token lifetime in hours.
"""
self._github_client_id = github_client_id
self._github_client_secret = github_client_secret
self._google_client_id = google_client_id
self._google_client_secret = google_client_secret
self._public_url = (public_url or "").rstrip("/")
self._session_secret = session_secret or os.getenv(
"DASHBOARD_SESSION_SECRET",
os.getenv("OAUTH_JWT_SECRET_KEY", secrets.token_hex(32)),
)
self._session_expiry_hours = int(
os.getenv("SESSION_EXPIRY_HOURS", str(session_expiry_hours))
)
# CSRF state tokens: state -> timestamp
self._pending_states: dict[str, float] = {}
# Registration rate limiting: IP -> [timestamps]
self._registration_records: dict[str, list[float]] = {}
providers = self.available_providers()
if providers and not self._public_url:
logger.warning(
"OAuth providers configured (%s) but PUBLIC_URL is not set. "
"OAuth login will fail until PUBLIC_URL is configured.",
providers,
)
logger.info(
"UserAuth initialized: providers=%s, session_expiry=%dh",
providers,
self._session_expiry_hours,
)
# ── Provider availability ─────────────────────────────────
def available_providers(self) -> list[str]:
"""Return list of configured OAuth providers.
Returns:
List of provider name strings (e.g. ["github", "google"]).
"""
providers: list[str] = []
if self._github_client_id and self._github_client_secret:
providers.append("github")
if self._google_client_id and self._google_client_secret:
providers.append("google")
return providers
# ── OAuth URL generation ──────────────────────────────────
def get_authorization_url(self, provider: str) -> tuple[str, str]:
"""Generate OAuth authorization URL with CSRF state.
Args:
provider: "github" or "google".
Returns:
Tuple of (authorization_url, state_token).
Raises:
ValueError: If provider is unsupported or not configured.
"""
if not self._public_url:
raise ValueError(
"PUBLIC_URL environment variable must be set for OAuth login to work. "
"Example: PUBLIC_URL=https://mcp.example.com"
)
state = secrets.token_hex(32)
self._pending_states[state] = time.time()
self._cleanup_expired_states()
callback_url = f"{self._public_url}/auth/callback/{provider}"
if provider == "github":
if not self._github_client_id:
raise ValueError("GitHub OAuth not configured")
params = {
"client_id": self._github_client_id,
"redirect_uri": callback_url,
"scope": "read:user user:email",
"state": state,
}
return f"{_GITHUB_AUTH_URL}?{urlencode(params)}", state
if provider == "google":
if not self._google_client_id:
raise ValueError("Google OAuth not configured")
params = {
"client_id": self._google_client_id,
"redirect_uri": callback_url,
"scope": "openid email profile",
"response_type": "code",
"state": state,
"access_type": "online",
}
return f"{_GOOGLE_AUTH_URL}?{urlencode(params)}", state
raise ValueError(f"Unsupported provider: {provider}")
# ── State validation ──────────────────────────────────────
def validate_state(self, state: str) -> bool:
"""Validate and consume an OAuth state token (one-time use).
Args:
state: The state parameter from the callback.
Returns:
True if valid and not expired, False otherwise.
"""
timestamp = self._pending_states.pop(state, None)
if timestamp is None:
return False
return not (time.time() - timestamp > _STATE_EXPIRY_SECONDS)
def _cleanup_expired_states(self) -> None:
"""Remove expired state tokens."""
now = time.time()
expired = [s for s, t in self._pending_states.items() if now - t > _STATE_EXPIRY_SECONDS]
for s in expired:
del self._pending_states[s]
# ── Token exchange ────────────────────────────────────────
async def exchange_code(self, provider: str, code: str) -> dict:
"""Exchange authorization code for user info.
Args:
provider: "github" or "google".
code: Authorization code from callback.
Returns:
Dict with keys: provider, provider_id, email, name, avatar_url.
Raises:
ValueError: If token exchange or user info fetch fails.
"""
if provider == "github":
return await self._exchange_github(code)
if provider == "google":
return await self._exchange_google(code)
raise ValueError(f"Unsupported provider: {provider}")
async def _exchange_github(self, code: str) -> dict:
"""Exchange GitHub authorization code for user info.
Args:
code: GitHub authorization code.
Returns:
Dict with provider, provider_id, email, name, avatar_url.
"""
callback_url = f"{self._public_url}/auth/callback/github"
async with httpx.AsyncClient() as client:
# Exchange code for access token
token_resp = await client.post(
_GITHUB_TOKEN_URL,
data={
"client_id": self._github_client_id,
"client_secret": self._github_client_secret,
"code": code,
"redirect_uri": callback_url,
},
headers={"Accept": "application/json"},
)
if token_resp.status_code != 200:
raise ValueError(f"Failed to exchange GitHub code: {token_resp.text}")
token_data = token_resp.json()
access_token = token_data.get("access_token")
if not access_token:
raise ValueError(f"Failed to exchange GitHub code: {token_data}")
# Fetch user info
user_resp = await client.get(
_GITHUB_USER_URL,
headers={"Authorization": f"Bearer {access_token}"},
)
user_data = user_resp.json()
email = user_data.get("email")
if not email:
# Fetch from /user/emails endpoint (private email fallback)
emails_resp = await client.get(
_GITHUB_EMAILS_URL,
headers={"Authorization": f"Bearer {access_token}"},
)
if emails_resp.status_code == 200:
emails = emails_resp.json()
primary = next(
(e for e in emails if e.get("primary") and e.get("verified")),
None,
)
if primary:
email = primary["email"]
elif emails:
email = emails[0]["email"]
return {
"provider": "github",
"provider_id": str(user_data["id"]),
"email": email,
"name": user_data.get("name") or user_data.get("login"),
"avatar_url": user_data.get("avatar_url"),
}
async def _exchange_google(self, code: str) -> dict:
"""Exchange Google authorization code for user info.
Args:
code: Google authorization code.
Returns:
Dict with provider, provider_id, email, name, avatar_url.
"""
callback_url = f"{self._public_url}/auth/callback/google"
async with httpx.AsyncClient() as client:
# Exchange code for access token
token_resp = await client.post(
_GOOGLE_TOKEN_URL,
data={
"client_id": self._google_client_id,
"client_secret": self._google_client_secret,
"code": code,
"redirect_uri": callback_url,
"grant_type": "authorization_code",
},
)
if token_resp.status_code != 200:
raise ValueError(f"Failed to exchange Google code: {token_resp.text}")
token_data = token_resp.json()
access_token = token_data.get("access_token")
if not access_token:
raise ValueError(f"Failed to exchange Google code: {token_data}")
# Fetch user info
user_resp = await client.get(
_GOOGLE_USERINFO_URL,
headers={"Authorization": f"Bearer {access_token}"},
)
user_data = user_resp.json()
return {
"provider": "google",
"provider_id": str(user_data["sub"]),
"email": user_data.get("email"),
"name": user_data.get("name"),
"avatar_url": user_data.get("picture"),
}
# ── Registration rate limiting ────────────────────────────
def check_registration_rate(self, client_ip: str) -> bool:
"""Check if IP is within registration rate limit.
Allows up to 3 registrations per IP per hour. Expired records
are cleaned up automatically.
Args:
client_ip: Client IP address.
Returns:
True if registration is allowed, False if rate limited.
"""
now = time.time()
records = self._registration_records.get(client_ip, [])
# Keep only records within the window
records = [t for t in records if now - t < _REG_RATE_WINDOW]
self._registration_records[client_ip] = records
return len(records) < _REG_RATE_LIMIT
def record_registration(self, client_ip: str) -> None:
"""Record a registration event for rate limiting.
Args:
client_ip: Client IP address.
"""
if client_ip not in self._registration_records:
self._registration_records[client_ip] = []
self._registration_records[client_ip].append(time.time())
# ── Session management ────────────────────────────────────
def create_user_session(
self,
user_id: str,
email: str,
name: str | None,
role: str,
) -> str:
"""Create a JWT session token for an OAuth user.
Args:
user_id: User UUID from database.
email: User email.
name: User display name.
role: User role ('user' or 'admin').
Returns:
JWT token string.
"""
now = time.time()
payload = {
"uid": user_id,
"email": email,
"name": name or "",
"role": role,
"type": "oauth_user",
"iat": now,
"exp": now + self._session_expiry_hours * 3600,
}
return jwt.encode(payload, self._session_secret, algorithm="HS256")
def validate_user_session(self, token: str) -> dict | None:
"""Validate a user session JWT token.
Args:
token: JWT token string.
Returns:
Dict with user_id, email, name, role, type -- or None if invalid.
"""
try:
payload = jwt.decode(token, self._session_secret, algorithms=["HS256"])
return {
"user_id": payload["uid"],
"email": payload["email"],
"name": payload.get("name", ""),
"role": payload.get("role", "user"),
"type": payload.get("type", "oauth_user"),
}
except jwt.ExpiredSignatureError:
logger.debug("User session expired")
return None
except jwt.InvalidTokenError as e:
logger.debug("Invalid user session token: %s", e)
return None
# ── Singleton ─────────────────────────────────────────────────
_user_auth: UserAuth | None = None
def initialize_user_auth(
github_client_id: str | None = None,
github_client_secret: str | None = None,
google_client_id: str | None = None,
google_client_secret: str | None = None,
public_url: str | None = None,
**kwargs,
) -> UserAuth:
"""Initialize the global UserAuth singleton.
Reads from env vars if arguments are not provided:
GITHUB_CLIENT_ID, GITHUB_CLIENT_SECRET,
GOOGLE_CLIENT_ID, GOOGLE_CLIENT_SECRET,
PUBLIC_URL.
Args:
github_client_id: GitHub OAuth App client ID.
github_client_secret: GitHub OAuth App client secret.
google_client_id: Google OAuth client ID.
google_client_secret: Google OAuth client secret.
public_url: Public-facing URL of the server.
**kwargs: Additional keyword args passed to UserAuth.
Returns:
The initialized UserAuth instance.
"""
global _user_auth
_user_auth = UserAuth(
github_client_id=github_client_id or os.getenv("GITHUB_CLIENT_ID"),
github_client_secret=github_client_secret or os.getenv("GITHUB_CLIENT_SECRET"),
google_client_id=google_client_id or os.getenv("GOOGLE_CLIENT_ID"),
google_client_secret=google_client_secret or os.getenv("GOOGLE_CLIENT_SECRET"),
public_url=public_url or os.getenv("PUBLIC_URL", ""),
**kwargs,
)
return _user_auth
def get_user_auth() -> UserAuth:
"""Get the global UserAuth singleton.
Returns:
The UserAuth singleton instance.
Raises:
RuntimeError: If initialize_user_auth() has not been called.
"""
if _user_auth is None:
raise RuntimeError("UserAuth not initialized. Call initialize_user_auth() first.")
return _user_auth

555
core/user_endpoints.py Normal file
View File

@@ -0,0 +1,555 @@
"""Per-user MCP endpoint handler (Track E.3).
Handles MCP JSON-RPC requests for user-owned sites at
``/u/{user_id}/{alias}/mcp``. Implements the Streamable HTTP transport
protocol directly (no per-user FastMCP instances).
Flow:
1. Validate user API key (Bearer token)
2. Look up user's site from SQLite
3. Decrypt credentials
4. For tools/list: return plugin tools (without ``site`` param)
5. For tools/call: create plugin instance, call method, return result
Usage:
# In server.py route registration:
from core.user_endpoints import user_mcp_handler
Route("/u/{user_id}/{alias}/mcp", endpoint=user_mcp_handler, methods=["POST"])
"""
import json
import logging
import os
import time
from copy import deepcopy
from typing import Any
from starlette.requests import Request
from starlette.responses import JSONResponse, Response
from core.tool_registry import ToolDefinition
logger = logging.getLogger(__name__)
# Per-user rate limiting defaults
USER_RATE_LIMIT_PER_MIN = int(os.getenv("USER_RATE_LIMIT_PER_MIN", "30"))
USER_RATE_LIMIT_PER_HR = int(os.getenv("USER_RATE_LIMIT_PER_HR", "500"))
# In-memory rate limit tracking: user_id -> list of timestamps
_rate_limits: dict[str, list[float]] = {}
def _check_user_rate_limit(user_id: str) -> tuple[bool, str]:
"""Check per-user rate limits.
Args:
user_id: User UUID.
Returns:
Tuple of (allowed, error_message). error_message is empty if allowed.
"""
now = time.time()
timestamps = _rate_limits.setdefault(user_id, [])
# Prune old entries (older than 1 hour)
cutoff_hr = now - 3600
_rate_limits[user_id] = [t for t in timestamps if t > cutoff_hr]
timestamps = _rate_limits[user_id]
# Check per-minute limit
cutoff_min = now - 60
recent_min = sum(1 for t in timestamps if t > cutoff_min)
if recent_min >= USER_RATE_LIMIT_PER_MIN:
return False, f"Rate limit exceeded: {USER_RATE_LIMIT_PER_MIN} requests/minute"
# Check per-hour limit
if len(timestamps) >= USER_RATE_LIMIT_PER_HR:
return False, f"Rate limit exceeded: {USER_RATE_LIMIT_PER_HR} requests/hour"
timestamps.append(now)
return True, ""
def _tools_to_mcp_schema(
tools: list[ToolDefinition],
*,
configured_providers: list[str] | None = None,
) -> list[dict[str, Any]]:
"""Convert ToolDefinition objects into MCP ``tools/list`` response shape.
Strips the auto-injected ``site`` parameter, since user endpoints bind a
single site per alias.
F.X.fix-pass6 — when ``configured_providers`` is given (i.e. the
site has at least one AI provider key configured), narrow the
``provider`` enum on the AI image tool to that subset. The model
only sees providers that will actually succeed, instead of trying
OpenAI / Stability / Replicate and getting NO_PROVIDER_KEY when
only OpenRouter is configured.
"""
ai_image_tools = {
"wordpress_generate_and_upload_image",
"woocommerce_generate_and_upload_image",
}
result = []
for tool_def in tools:
schema = deepcopy(tool_def.input_schema)
if "properties" in schema:
schema["properties"].pop("site", None)
if "required" in schema and "site" in schema["required"]:
schema["required"] = [r for r in schema["required"] if r != "site"]
if (
configured_providers
and tool_def.name in ai_image_tools
and "properties" in schema
and "provider" in schema["properties"]
):
schema["properties"]["provider"] = {
**schema["properties"]["provider"],
"enum": list(configured_providers),
"description": (
"AI provider to use. This site has the following providers "
f"configured: {', '.join(configured_providers)}. Add more "
"in Connection Settings → AI Image Generation."
),
}
result.append(
{
"name": tool_def.name,
"description": tool_def.description,
"inputSchema": schema,
}
)
return result
async def _get_visible_tools_for_site(
site_id: str,
key_scopes: list[str],
plugin_type: str,
) -> list[dict[str, Any]]:
"""Return tools/list payload filtered by key scope + site scope + toggles (F.7b).
F.5a.9.x: additionally hide ``wordpress_generate_and_upload_image`` when
the site has no provider API key configured — the tool would fail at
call-time with ``NO_PROVIDER_KEY`` anyway, so hiding it keeps the
surface honest for AI clients.
"""
from core.tool_access import get_tool_access_manager
access = get_tool_access_manager()
tools = await access.get_visible_tools(
site_id=site_id, key_scopes=key_scopes, plugin_type=plugin_type
)
configured_providers: list[str] = []
if plugin_type in {"wordpress", "woocommerce"}:
from core.site_api import list_site_providers_set
configured = await list_site_providers_set(site_id)
if not configured:
tools = [
t
for t in tools
if t.name
not in {
"wordpress_generate_and_upload_image",
"woocommerce_generate_and_upload_image",
}
]
else:
# F.X.fix-pass6 — pass the configured set so the AI image
# tool's `provider` enum can be narrowed at /tools/list time.
configured_providers = sorted(configured)
return _tools_to_mcp_schema(tools, configured_providers=configured_providers)
async def _execute_tool(
tool_name: str,
arguments: dict[str, Any],
plugin_type: str,
config_dict: dict[str, Any],
) -> Any:
"""Execute a tool by creating a plugin instance and calling the method.
Uses the same pattern as unified_handler in tool_generator.py.
"""
from plugins import registry as plugin_registry
if not plugin_registry.is_registered(plugin_type):
return {"type": "text", "text": f"Error: Unknown plugin type '{plugin_type}'"}
method_name = tool_name
# Strip plugin_type prefix: "wordpress_list_posts" → "list_posts"
prefix = f"{plugin_type}_"
if method_name.startswith(prefix):
method_name = method_name[len(prefix) :]
try:
plugin_instance = plugin_registry.create_instance(
plugin_type,
project_id=f"user_{config_dict.get('alias', 'unknown')}",
config=config_dict,
)
if not hasattr(plugin_instance, method_name):
return {"type": "text", "text": f"Error: Method '{method_name}' not found"}
method = getattr(plugin_instance, method_name)
# Process arguments (parse JSON strings, filter None/empty)
filtered_args = {}
for key, value in arguments.items():
if value is None:
continue
if isinstance(value, str):
stripped = value.strip()
if stripped == "":
continue
if (stripped.startswith("{") and stripped.endswith("}")) or (
stripped.startswith("[") and stripped.endswith("]")
):
try:
value = json.loads(value)
except json.JSONDecodeError:
pass
filtered_args[key] = value
result = await method(**filtered_args)
return result
except Exception as e:
logger.error("Tool execution error %s: %s", tool_name, e, exc_info=True)
return {"type": "text", "text": f"Error: {type(e).__name__}: {e}"}
def _jsonrpc_error(req_id: Any, code: int, message: str) -> dict[str, Any]:
"""Build a JSON-RPC error response."""
return {
"jsonrpc": "2.0",
"id": req_id,
"error": {"code": code, "message": message},
}
def _jsonrpc_result(req_id: Any, result: Any) -> dict[str, Any]:
"""Build a JSON-RPC success response."""
return {
"jsonrpc": "2.0",
"id": req_id,
"result": result,
}
async def user_mcp_handler(request: Request) -> Response:
"""Handle MCP JSON-RPC requests for user endpoints.
Route: POST /u/{user_id}/{alias}/mcp
Validates user API key, looks up the site, and handles MCP protocol
methods (initialize, tools/list, tools/call).
"""
user_id = request.path_params.get("user_id", "")
alias = request.path_params.get("alias", "")
if not user_id or not alias:
return JSONResponse(
_jsonrpc_error(None, -32600, "Invalid endpoint path"),
status_code=400,
)
# --- Authentication ---
auth_header = request.headers.get("authorization", "")
if not auth_header.startswith("Bearer "):
return JSONResponse(
_jsonrpc_error(None, -32600, "Missing Authorization header"),
status_code=401,
)
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
key_mgr = get_user_key_manager()
key_info = await key_mgr.validate_key(api_key)
except RuntimeError:
return JSONResponse(
_jsonrpc_error(None, -32600, "Authentication service unavailable"),
status_code=503,
)
if key_info is None:
return JSONResponse(
_jsonrpc_error(None, -32600, "Invalid API key"),
status_code=401,
)
if key_info["user_id"] != user_id:
return JSONResponse(
_jsonrpc_error(None, -32600, "API key does not match user"),
status_code=403,
)
# Check site-scoped key: if key is scoped to a site, it can only
# be used for that specific site's alias.
key_site_id = key_info.get("site_id")
if key_site_id:
try:
from core.database import get_database
db = get_database()
site = await db.get_site(key_site_id, user_id)
if site is None or site.get("alias") != alias:
return JSONResponse(
_jsonrpc_error(
None,
-32600,
"API key is scoped to a different site",
),
status_code=403,
)
except RuntimeError:
pass # DB unavailable — allow through
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)
if not allowed:
return JSONResponse(
_jsonrpc_error(None, -32600, rate_msg),
status_code=429,
)
# --- Look up site ---
try:
from core.database import get_database
db = get_database()
site = await db.get_site_by_alias(user_id, alias)
except RuntimeError:
return JSONResponse(
_jsonrpc_error(None, -32600, "Database unavailable"),
status_code=503,
)
if site is None:
return JSONResponse(
_jsonrpc_error(None, -32600, f"Site '{alias}' not found"),
status_code=404,
)
if site["status"] == "disabled":
return JSONResponse(
_jsonrpc_error(None, -32600, "Site is disabled"),
status_code=403,
)
# --- Plugin visibility check ---
from core.plugin_visibility import is_plugin_public
if not is_plugin_public(site["plugin_type"]):
return JSONResponse(
_jsonrpc_error(
None, -32600, f"Plugin '{site['plugin_type']}' is not currently available"
),
status_code=403,
)
# --- Parse JSON-RPC body ---
try:
body = await request.json()
except Exception:
return JSONResponse(
_jsonrpc_error(None, -32700, "Parse error"),
status_code=400,
)
req_id = body.get("id")
method = body.get("method", "")
params = body.get("params", {})
# --- Handle MCP methods ---
if method == "initialize":
version = "3.1.0"
return JSONResponse(
_jsonrpc_result(
req_id,
{
"protocolVersion": "2024-11-05",
"capabilities": {"tools": {}},
"serverInfo": {
"name": f"mcphub-{alias}",
"version": version,
},
},
)
)
elif method == "notifications/initialized":
# Notification — no response needed
return Response(status_code=204)
elif method == "tools/list":
tools = await _get_visible_tools_for_site(site["id"], key_scopes, site["plugin_type"])
return JSONResponse(_jsonrpc_result(req_id, {"tools": tools}))
elif method == "tools/call":
tool_name = params.get("name", "")
arguments = params.get("arguments", {})
# Verify tool belongs to this plugin type
plugin_prefix = f"{site['plugin_type']}_"
if not tool_name.startswith(plugin_prefix):
return JSONResponse(
_jsonrpc_error(req_id, -32601, f"Tool '{tool_name}' not available for this site")
)
# Check required scope
from core.tool_registry import get_tool_registry
registry = get_tool_registry()
tool_def = registry.get_by_name(tool_name)
if not tool_def:
return JSONResponse(_jsonrpc_error(req_id, -32601, f"Tool '{tool_name}' not found"))
required_scope = tool_def.required_scope
# key_scopes is set during authentication (both mhu_ and JWT paths)
# F.7b: enforce category-based scope allowlist in addition to the
# legacy read/write/admin hierarchy. A tool is allowed only if BOTH
# (a) the legacy hierarchy grants it, AND
# (b) the tool's category is in BOTH the key-scope set AND the
# site's stored tool_scope set (the narrower layer wins).
from core.tool_access import KNOWN_CATEGORIES, SCOPE_CUSTOM, scopes_to_categories
scope_hierarchy = {"read": 1, "write": 2, "admin": 3}
required_level = scope_hierarchy.get(required_scope, 0)
key_level = max([scope_hierarchy.get(s, 0) for s in key_scopes] + [0])
legacy_ok = key_level >= required_level
key_cats = scopes_to_categories(key_scopes)
key_category_ok = tool_def.category not in KNOWN_CATEGORIES or tool_def.category in key_cats
# Site-level scope check (skipped for "custom" preset).
site_scope = site.get("tool_scope") or "admin"
if site_scope and site_scope != SCOPE_CUSTOM:
site_cats = scopes_to_categories([site_scope])
site_category_ok = (
tool_def.category not in KNOWN_CATEGORIES or tool_def.category in site_cats
)
else:
site_category_ok = True
if not (legacy_ok and key_category_ok and site_category_ok):
return JSONResponse(
_jsonrpc_error(
req_id,
-32600,
f"Insufficient scope. Tool '{tool_name}' requires "
f"scope '{required_scope}' (category '{tool_def.category}').",
)
)
# F.7b: honour per-site tool toggles — a disabled tool cannot be called
# even if scopes would otherwise allow it.
try:
toggles = await db.get_site_tool_toggles(site["id"])
if not toggles.get(tool_name, True):
return JSONResponse(
_jsonrpc_error(
req_id,
-32600,
f"Tool '{tool_name}' is disabled for this site.",
)
)
except Exception as exc: # non-fatal — fall through on DB errors
logger.warning("Failed to check site tool toggles for %s: %s", site["id"], exc)
# Decrypt credentials
try:
from core.encryption import get_credential_encryption
encryptor = get_credential_encryption()
credentials = encryptor.decrypt_credentials(site["credentials"], site["id"])
except Exception as e:
logger.error("Credential decryption failed for site %s: %s", site["id"], e)
return JSONResponse(
_jsonrpc_error(req_id, -32603, "Failed to decrypt site credentials")
)
# Build config dict for plugin instantiation
# F.5a.4: pass user_id so plugins can look up per-user secrets.
# F.5a.9.x: pass site_id so the WP AI-media handler can resolve
# per-site provider API keys (replaces per-user keys for
# wordpress_generate_and_upload_image).
config_dict = {
"site_url": site["url"],
"url": site["url"],
"alias": alias,
"user_id": user_id,
"site_id": site["id"],
**credentials,
}
result = await _execute_tool(tool_name, arguments, site["plugin_type"], config_dict)
# Format result as MCP content
if isinstance(result, str):
content = [{"type": "text", "text": result}]
elif isinstance(result, dict) and "type" in result:
content = [result]
elif isinstance(result, list):
content = result
else:
content = [{"type": "text", "text": json.dumps(result, default=str)}]
return JSONResponse(_jsonrpc_result(req_id, {"content": content}))
else:
return JSONResponse(_jsonrpc_error(req_id, -32601, f"Method '{method}' not supported"))

253
core/user_keys.py Normal file
View File

@@ -0,0 +1,253 @@
"""User API Key management for the Live Platform (Track E.3).
Provides bcrypt-hashed API keys for authenticating MCP client connections
to per-user endpoints (``/u/{user_id}/{alias}/mcp``).
Key format: ``mhu_<32 random urlsafe chars>``
Lookup: ``key_prefix`` column (first 8 chars after ``mhu_``) for indexed DB lookup,
then bcrypt verification on the matching row.
Usage:
from core.user_keys import initialize_user_key_manager, get_user_key_manager
initialize_user_key_manager()
mgr = get_user_key_manager()
result = await mgr.create_key(user_id, "Claude Desktop")
# result["key"] is shown once to the user
info = await mgr.validate_key("mhu_...")
"""
import logging
import secrets
import time
from datetime import UTC, datetime, timedelta
from typing import Any
import bcrypt
logger = logging.getLogger(__name__)
# Key format constants
KEY_PREFIX_TAG = "mhu_"
KEY_RANDOM_BYTES = 32 # urlsafe_b64 of 32 bytes = 43 chars
KEY_PREFIX_LEN = 8 # chars after "mhu_" stored for fast DB lookup
# Validation cache TTL
_CACHE_TTL_SECONDS = 300 # 5 minutes
class UserKeyManager:
"""Manages per-user API keys with bcrypt hashing.
Keys are stored in the ``user_api_keys`` SQLite table via
:class:`core.database.Database`. Each key is bcrypt-hashed;
a plaintext ``key_prefix`` column enables indexed lookup without
scanning all rows.
An in-memory cache avoids repeated bcrypt verification for the
same key within a 5-minute window.
"""
def __init__(self) -> None:
# Cache: raw_key -> (key_id, user_id, scopes, site_id, cached_at)
self._cache: dict[str, tuple[str, str, str, str | None, float]] = {}
async def create_key(
self,
user_id: str,
name: str,
scopes: str = "read write admin",
expires_in_days: int | None = None,
site_id: str | None = None,
) -> dict[str, Any]:
"""Create a new API key for a user.
Args:
user_id: Owner's UUID.
name: Human label (e.g. "Claude Desktop").
scopes: Access scopes (default: "read write admin" for full access).
expires_in_days: Optional expiry in days from now. None = never.
site_id: Optional site UUID to scope key to a single site.
Returns:
Dict with ``key`` (plaintext, shown once), ``key_id``, ``name``,
``scopes``, ``created_at``, ``expires_at``, ``site_id``.
"""
from core.database import get_database
raw_key = KEY_PREFIX_TAG + secrets.token_urlsafe(KEY_RANDOM_BYTES)
key_prefix = raw_key[len(KEY_PREFIX_TAG) : len(KEY_PREFIX_TAG) + KEY_PREFIX_LEN]
key_hash = bcrypt.hashpw(raw_key.encode(), bcrypt.gensalt()).decode()
expires_at = None
if expires_in_days is not None:
expires_at = (datetime.now(UTC) + timedelta(days=expires_in_days)).isoformat()
db = get_database()
row = await db.create_api_key(
user_id=user_id,
key_hash=key_hash,
key_prefix=key_prefix,
name=name,
scopes=scopes,
expires_at=expires_at,
site_id=site_id,
)
logger.info("Created user API key %s for user %s (site=%s)", row["id"], user_id, site_id)
return {
"key": raw_key, # shown once
"key_id": row["id"],
"name": row["name"],
"scopes": row["scopes"],
"created_at": row["created_at"],
"expires_at": row["expires_at"],
"site_id": row.get("site_id"),
}
async def validate_key(self, api_key: str) -> dict[str, Any] | None:
"""Validate an API key and return its metadata.
Uses an in-memory cache to avoid repeated bcrypt verification.
Args:
api_key: The raw API key string (e.g. ``mhu_...``).
Returns:
Dict with ``key_id``, ``user_id``, ``scopes`` if valid, else None.
"""
if not api_key or not api_key.startswith(KEY_PREFIX_TAG):
return None
# Check cache first
cached = self._cache.get(api_key)
if cached is not None:
key_id, user_id, scopes, site_id, cached_at = cached
if time.time() - cached_at < _CACHE_TTL_SECONDS:
# Update usage in background (fire-and-forget via DB)
try:
import asyncio
from core.database import get_database
db = get_database()
asyncio.create_task(db.update_api_key_usage(key_id))
except Exception:
pass # Non-critical
return {"key_id": key_id, "user_id": user_id, "scopes": scopes, "site_id": site_id}
else:
del self._cache[api_key]
# Extract prefix for DB lookup
key_prefix = api_key[len(KEY_PREFIX_TAG) : len(KEY_PREFIX_TAG) + KEY_PREFIX_LEN]
from core.database import get_database
db = get_database()
row = await db.get_api_key_by_prefix(key_prefix)
if row is None:
return None
# Verify bcrypt hash
if not bcrypt.checkpw(api_key.encode(), row["key_hash"].encode()):
return None
# Check expiry
if row["expires_at"] is not None:
expires = datetime.fromisoformat(row["expires_at"])
if expires < datetime.now(UTC):
return None
# Update usage
await db.update_api_key_usage(row["id"])
# Cache the result
site_id = row.get("site_id")
self._cache[api_key] = (
row["id"],
row["user_id"],
row["scopes"],
site_id,
time.time(),
)
return {
"key_id": row["id"],
"user_id": row["user_id"],
"scopes": row["scopes"],
"site_id": site_id,
}
async def list_keys(self, user_id: str) -> list[dict[str, Any]]:
"""List all API keys for a user (without hashes).
Args:
user_id: Owner's UUID.
Returns:
List of key metadata dicts.
"""
from core.database import get_database
db = get_database()
return await db.get_api_keys_by_user(user_id)
async def delete_key(self, key_id: str, user_id: str) -> bool:
"""Delete an API key.
Also invalidates the validation cache for any cached key matching
this key_id.
Args:
key_id: API key UUID.
user_id: Owner's UUID.
Returns:
True if deleted, False if not found.
"""
from core.database import get_database
db = get_database()
deleted = await db.delete_api_key(key_id, user_id)
if deleted:
# Purge from cache
to_remove = [k for k, v in self._cache.items() if v[0] == key_id]
for k in to_remove:
del self._cache[k]
logger.info("Deleted user API key %s for user %s", key_id, user_id)
return deleted
def clear_cache(self) -> None:
"""Clear the entire validation cache."""
self._cache.clear()
# Singleton
_manager: UserKeyManager | None = None
def initialize_user_key_manager() -> UserKeyManager:
"""Create and store the singleton UserKeyManager."""
global _manager
_manager = UserKeyManager()
logger.info("UserKeyManager initialized")
return _manager
def get_user_key_manager() -> UserKeyManager:
"""Get the singleton UserKeyManager.
Returns:
The UserKeyManager singleton.
Raises:
RuntimeError: If not initialized.
"""
if _manager is None:
raise RuntimeError(
"UserKeyManager not initialized. Call initialize_user_key_manager() first."
)
return _manager

View File

@@ -0,0 +1,84 @@
# ===================================
# MCP Hub — Docker Compose (Coolify, mirror + proxy-fallback variant)
# ===================================
#
# Identical to docker-compose.coolify.yaml except `dockerfile:` points
# to Dockerfile.mirror. That file pulls base images from mirror.gcr.io,
# uses a Yandex apk mirror, and falls back to a temporary HTTP proxy
# when direct apk/pip calls fail. Switch the Coolify Source back to
# docker-compose.coolify.yaml once the build host can reach Docker Hub,
# Alpine CDN, and pypi.org directly again.
#
# Source mirror note (2026-04-16):
# Primary repo: github.com/airano-ir/coolify-mcp-hub (public)
# Gitea mirror: gitea.palebluedot.live/atlatl/mcphub-internal (private)
#
# If Coolify cannot reach api.github.com (e.g. cURL error 28 on the
# api.github.com/zen health probe), point the Coolify Source at the
# Gitea mirror instead. Revert the source back to GitHub once the
# GitHub App connection is restored.
# ===================================
services:
mcp-server:
build:
context: .
dockerfile: Dockerfile.mirror
container_name: mcphub
restart: unless-stopped
# No 'ports' — Coolify reverse proxy routes traffic via EXPOSE 8000
environment:
- PYTHONUNBUFFERED=1
# === REQUIRED ===
- MASTER_API_KEY=${MASTER_API_KEY}
- ENCRYPTION_KEY=${ENCRYPTION_KEY}
- DASHBOARD_SESSION_SECRET=${DASHBOARD_SESSION_SECRET}
# === OAUTH SOCIAL LOGIN (for user registration) ===
- GITHUB_CLIENT_ID=${GITHUB_CLIENT_ID:-}
- GITHUB_CLIENT_SECRET=${GITHUB_CLIENT_SECRET:-}
- GOOGLE_CLIENT_ID=${GOOGLE_CLIENT_ID:-}
- GOOGLE_CLIENT_SECRET=${GOOGLE_CLIENT_SECRET:-}
- PUBLIC_URL=${PUBLIC_URL:-}
- SESSION_EXPIRY_HOURS=${SESSION_EXPIRY_HOURS:-168}
# === ADMIN SYSTEM (F.4) ===
- ADMIN_EMAILS=${ADMIN_EMAILS:-}
- DISABLE_MASTER_KEY_LOGIN=${DISABLE_MASTER_KEY_LOGIN:-false}
# === LOGGING ===
- LOG_LEVEL=${LOG_LEVEL:-INFO}
# === SITE MANAGEMENT (E.3) ===
- MAX_SITES_PER_USER=${MAX_SITES_PER_USER:-10}
- USER_RATE_LIMIT_PER_MIN=${USER_RATE_LIMIT_PER_MIN:-30}
- USER_RATE_LIMIT_PER_HR=${USER_RATE_LIMIT_PER_HR:-500}
# === SITES ===
# Sites are managed via the web dashboard (DB-based).
# After deployment, open the dashboard to add sites.
healthcheck:
test: ["CMD", "wget", "-qO-", "http://localhost:8000/health"]
interval: 30s
timeout: 10s
retries: 3
start_period: 40s
volumes:
- mcp-data:/app/data # SQLite DB, API keys, OAuth data
- mcp-logs:/app/logs # Audit logs, health reports
- /var/run/docker.sock:/var/run/docker.sock:ro
# Coolify uses GID 988 for Docker socket
group_add:
- "988"
volumes:
mcp-data:
driver: local
mcp-logs:
driver: local

View File

@@ -0,0 +1,94 @@
# ===================================
# MCP Hub — Docker Compose (Coolify, Debian-slim Plan-B variant)
# ===================================
#
# Plan B for restrictive networks. Use this when even
# docker-compose.coolify.mirror.yaml hangs at `apk add` because the build
# host cannot reach Alpine's package CDN (dl-cdn.alpinelinux.org). This
# variant uses Debian slim, whose apt mirrors are CDN-fronted and usually
# reachable from networks that block Alpine's CDN.
#
# WHEN TO USE:
# - registry-1.docker.io fails AND
# - Alpine apk repos (even via a Yandex mirror swap) hang or fail
#
# WHEN TO STOP USING:
# - Once one of the smaller images works reliably (Alpine = smaller)
# - Image bytes change (slim ≠ alpine), so the persistent volumes
# (mcp-data, mcp-logs) carry over but the build cache resets.
#
# OPTIONAL HTTP PROXY (Coolify build-args): same as the .mirror variant —
# BUILD_HTTP_PROXY=http://proxy.host:port
# BUILD_HTTPS_PROXY=http://proxy.host:port
# BUILD_NO_PROXY=localhost,127.0.0.1,mirror.gcr.io
# Used only inside RUN (apt + pip), never baked into the runtime image.
#
# Source mirror note (2026-04-16):
# Primary repo: github.com/airano-ir/coolify-mcp-hub (public)
# Gitea mirror: gitea.palebluedot.live/atlatl/mcphub-internal (private)
#
# ===================================
services:
mcp-server:
build:
context: .
dockerfile: Dockerfile.slim
container_name: mcphub
restart: unless-stopped
# No 'ports' — Coolify reverse proxy routes traffic via EXPOSE 8000
environment:
- PYTHONUNBUFFERED=1
# === REQUIRED ===
- MASTER_API_KEY=${MASTER_API_KEY}
- ENCRYPTION_KEY=${ENCRYPTION_KEY}
- DASHBOARD_SESSION_SECRET=${DASHBOARD_SESSION_SECRET}
# === OAUTH SOCIAL LOGIN (for user registration) ===
- GITHUB_CLIENT_ID=${GITHUB_CLIENT_ID:-}
- GITHUB_CLIENT_SECRET=${GITHUB_CLIENT_SECRET:-}
- GOOGLE_CLIENT_ID=${GOOGLE_CLIENT_ID:-}
- GOOGLE_CLIENT_SECRET=${GOOGLE_CLIENT_SECRET:-}
- PUBLIC_URL=${PUBLIC_URL:-}
- SESSION_EXPIRY_HOURS=${SESSION_EXPIRY_HOURS:-168}
# === ADMIN SYSTEM (F.4) ===
- ADMIN_EMAILS=${ADMIN_EMAILS:-}
- DISABLE_MASTER_KEY_LOGIN=${DISABLE_MASTER_KEY_LOGIN:-false}
# === LOGGING ===
- LOG_LEVEL=${LOG_LEVEL:-INFO}
# === SITE MANAGEMENT (E.3) ===
- MAX_SITES_PER_USER=${MAX_SITES_PER_USER:-10}
- USER_RATE_LIMIT_PER_MIN=${USER_RATE_LIMIT_PER_MIN:-30}
- USER_RATE_LIMIT_PER_HR=${USER_RATE_LIMIT_PER_HR:-500}
# === SITES ===
# Sites are managed via the web dashboard (DB-based).
# After deployment, open the dashboard to add sites.
healthcheck:
test: ["CMD", "wget", "-qO-", "http://localhost:8000/health"]
interval: 30s
timeout: 10s
retries: 3
start_period: 40s
volumes:
- mcp-data:/app/data # SQLite DB, API keys, OAuth data
- mcp-logs:/app/logs # Audit logs, health reports
- /var/run/docker.sock:/var/run/docker.sock:ro
# Coolify uses GID 988 for Docker socket
group_add:
- "988"
volumes:
mcp-data:
driver: local
mcp-logs:
driver: local

View File

@@ -0,0 +1,86 @@
# ===================================
# MCP Hub — Docker Compose (Coolify)
# ===================================
#
# Coolify auto-reads the 'environment' block below and shows
# each variable in the UI for editing. Just deploy and fill values.
#
# Setup:
# 1. New Resource → Docker Compose → point to this file
# 2. Fill environment variables in Coolify UI
# 3. Set domain (e.g., mcp.yourdomain.com)
# 4. Deploy
#
# Source mirror note (2026-04-16):
# Primary repo: github.com/airano-ir/coolify-mcp-hub (public)
# Gitea mirror: gitea.palebluedot.live/atlatl/mcphub-internal (private)
#
# If Coolify cannot reach api.github.com (e.g. cURL error 28 on the
# api.github.com/zen health probe), point the Coolify Source at the
# Gitea mirror instead. Revert the source back to GitHub once the
# GitHub App connection is restored.
# ===================================
services:
mcp-server:
build:
context: .
dockerfile: Dockerfile
container_name: mcphub
restart: unless-stopped
# No 'ports' — Coolify reverse proxy routes traffic via EXPOSE 8000
environment:
- PYTHONUNBUFFERED=1
# === REQUIRED ===
- MASTER_API_KEY=${MASTER_API_KEY}
- ENCRYPTION_KEY=${ENCRYPTION_KEY}
- DASHBOARD_SESSION_SECRET=${DASHBOARD_SESSION_SECRET}
# === OAUTH SOCIAL LOGIN (for user registration) ===
- GITHUB_CLIENT_ID=${GITHUB_CLIENT_ID:-}
- GITHUB_CLIENT_SECRET=${GITHUB_CLIENT_SECRET:-}
- GOOGLE_CLIENT_ID=${GOOGLE_CLIENT_ID:-}
- GOOGLE_CLIENT_SECRET=${GOOGLE_CLIENT_SECRET:-}
- PUBLIC_URL=${PUBLIC_URL:-}
- SESSION_EXPIRY_HOURS=${SESSION_EXPIRY_HOURS:-168}
# === ADMIN SYSTEM (F.4) ===
- ADMIN_EMAILS=${ADMIN_EMAILS:-}
- DISABLE_MASTER_KEY_LOGIN=${DISABLE_MASTER_KEY_LOGIN:-false}
# === LOGGING ===
- LOG_LEVEL=${LOG_LEVEL:-INFO}
# === SITE MANAGEMENT (E.3) ===
- MAX_SITES_PER_USER=${MAX_SITES_PER_USER:-10}
- USER_RATE_LIMIT_PER_MIN=${USER_RATE_LIMIT_PER_MIN:-30}
- USER_RATE_LIMIT_PER_HR=${USER_RATE_LIMIT_PER_HR:-500}
# === SITES ===
# Sites are managed via the web dashboard (DB-based).
# After deployment, open the dashboard to add sites.
healthcheck:
test: ["CMD", "wget", "-qO-", "http://localhost:8000/health"]
interval: 30s
timeout: 10s
retries: 3
start_period: 40s
volumes:
- mcp-data:/app/data # SQLite DB, API keys, OAuth data
- mcp-logs:/app/logs # Audit logs, health reports
- /var/run/docker.sock:/var/run/docker.sock:ro
# Coolify uses GID 988 for Docker socket
group_add:
- "988"
volumes:
mcp-data:
driver: local
mcp-logs:
driver: local

View File

@@ -1,16 +1,14 @@
# ===================================
# MCP Hub — Docker Compose Configuration
# MCP Hub — Docker Compose (Standalone)
# ===================================
#
# After starting:
# Usage:
# cp env.example .env # edit with your values
# docker compose up -d
# curl http://localhost:8000/health # verify server is running
# open http://localhost:8000/dashboard # web dashboard
# curl http://localhost:8000/health
# open http://localhost:8000/dashboard
#
# Port mapping:
# - Standalone Docker: ports "8000:8000" works out-of-the-box.
# - Coolify: Comment out or remove the 'ports' section below.
# Coolify's reverse proxy handles routing — 'expose' is sufficient.
# For Coolify deployment, use docker-compose.coolify.yaml instead.
# ===================================
services:
@@ -18,90 +16,40 @@ services:
build:
context: .
dockerfile: Dockerfile
image: airano/mcphub:latest
container_name: mcphub
restart: unless-stopped
ports:
- "8000:8000"
# Coolify users: Remove the 'ports' section above.
# Coolify's reverse proxy routes traffic to the container's exposed port.
# Environment variables
environment:
# Master API key
- MASTER_API_KEY=${MASTER_API_KEY}
env_file:
- .env
# Logging
- LOG_LEVEL=${LOG_LEVEL:-INFO}
environment:
- PYTHONUNBUFFERED=1
# === OAuth 2.1 (OPTIONAL) ===
# Only needed for ChatGPT auto-registration or third-party OAuth clients.
# For Claude Desktop/Code/Cursor with Bearer token auth, skip these entirely.
# - OAUTH_JWT_SECRET_KEY=${OAUTH_JWT_SECRET_KEY}
# - OAUTH_BASE_URL=${OAUTH_BASE_URL}
# - OAUTH_JWT_ALGORITHM=${OAUTH_JWT_ALGORITHM:-HS256}
# - OAUTH_ACCESS_TOKEN_TTL=${OAUTH_ACCESS_TOKEN_TTL:-3600}
# - OAUTH_REFRESH_TOKEN_TTL=${OAUTH_REFRESH_TOKEN_TTL:-604800}
- OAUTH_STORAGE_TYPE=${OAUTH_STORAGE_TYPE:-json}
- OAUTH_STORAGE_PATH=${OAUTH_STORAGE_PATH:-/app/data}
# === WordPress Projects ===
# Configure WordPress sites in Coolify environment variables
# Format: WORDPRESS_{SITE_ID}_{CONFIG_KEY}
#
# Required variables for each site:
# WORDPRESS_SITE1_URL=https://your-site.com
# WORDPRESS_SITE1_USERNAME=admin
# WORDPRESS_SITE1_APP_PASSWORD=xxxx xxxx xxxx xxxx
#
# Optional (for WP-CLI tools):
# WORDPRESS_SITE1_CONTAINER=wordpress_container_name
# WORDPRESS_SITE1_ALIAS=myblog
#
# ⚠️ DO NOT add example values here - configure in Coolify!
# The server will auto-discover all WORDPRESS_* variables at startup.
# === Other Plugins ===
# Gitea, n8n, Supabase, OpenPanel, Appwrite, and Directus plugins
# auto-discover their environment variables at startup.
# Format: {PLUGIN_TYPE}_{SITE_ID}_{CONFIG_KEY}
# No need to pre-define them here.
# Health check
healthcheck:
test: ["CMD", "wget", "-qO-", "http://localhost:8000/health"]
interval: 30s
timeout: 10s
retries: 3
start_period: 40s
# Docker socket access for WP-CLI tools
# Required for: wp_cache_flush, wp_cache_type, wp_transient_*, etc.
volumes:
- mcp-data:/app/data # SQLite DB, API keys, OAuth data
- mcp-logs:/app/logs # Audit logs, health reports
# Docker socket — only needed for WP-CLI tools (WordPress Advanced plugin)
# Remove if you don't use wp_cache_flush, wp_transient_*, etc.
- /var/run/docker.sock:/var/run/docker.sock:ro
- mcp-data:/app/data # Persistent storage for API keys
- mcp-logs:/app/logs # Persistent logs (audit, health)
# Grant access to Docker socket by adding user to docker group
# The GID varies by host (988, 999, 133 are common)
# Coolify typically uses 988
# Docker socket GID — adjust to match your host's docker group
# Check with: getent group docker | cut -d: -f3
group_add:
- "988" # Docker group GID (adjust if needed)
- "999"
# Network - Coolify will handle this
# networks:
# - coolify
# Named volumes for persistent data
volumes:
mcp-data:
driver: local
mcp-logs:
driver: local
# Networks managed by Coolify
# networks:
# coolify:
# external: true

View File

@@ -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-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
# ============================================
@@ -212,12 +248,11 @@ Without Docker socket:
- WordPress Advanced database/system tools are unavailable
- All REST API tools (bulk operations, content management) work normally
### Environment Variable Reference
### Site Configuration
All site configuration follows the pattern: `{PLUGIN_TYPE}_{SITE_ID}_{CONFIG_KEY}=value`
Sites are managed via the web dashboard. After starting the server, open the dashboard to add sites.
- `SITE_ID` can be any alphanumeric identifier (e.g., `SITE1`, `PROD`, `MYBLOG`)
- Multiple sites: change `SITE1` to `SITE2`, `SITE3`, etc.
Each plugin requires specific credentials (see below for reference).
#### WordPress
```env
@@ -322,18 +357,20 @@ After starting (via Docker or locally), wait ~30 seconds for the server to initi
```bash
curl http://localhost:8000/health
# Expected: {"status": "ok", "tools_loaded": 596, ...}
# Expected: {"status": "ok", "tools_loaded": 565, ...}
```
**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):**
@@ -354,7 +391,7 @@ docker compose logs -f mcphub
| Port 8000 already in use | Change port in docker-compose.yaml: `"8001:8000"` |
| Health check shows "unhealthy" | Wait 60 seconds, then check logs for startup errors |
| Dashboard login fails | Make sure you're using the `MASTER_API_KEY` value from your `.env` |
| Sites not showing up | Restart after adding new env vars: `docker compose restart` |
| Sites not showing up | Add sites via the web dashboard, then check the connect page |
---
@@ -444,7 +481,7 @@ MCP Hub supports **Open Dynamic Client Registration** (RFC 7591). ChatGPT can au
## Using MCP Tools
### 596 Tools Across 9 Plugins
### 565 Tools Across 9 Plugins
| Plugin | Tools | Env Prefix |
|--------|-------|------------|
@@ -454,7 +491,7 @@ MCP Hub supports **Open Dynamic Client Registration** (RFC 7591). ChatGPT can au
| Gitea | 56 | `GITEA_` |
| n8n | 56 | `N8N_` |
| Supabase | 70 | `SUPABASE_` |
| OpenPanel | 73 | `OPENPANEL_` |
| OpenPanel | 42 | `OPENPANEL_` |
| Appwrite | 100 | `APPWRITE_` |
| Directus | 100 | `DIRECTUS_` |
| System | 24 | (no config needed) |
@@ -481,9 +518,9 @@ Use the most specific endpoint for your use case to minimize token usage:
| `/project/{alias}/mcp` | Single-site workflow | 22-100 | No (pre-scoped) |
| `/{plugin}/mcp` | Multi-site management | 23-101 | Yes |
| `/system/mcp` | System administration | 24 | N/A |
| `/mcp` | Admin & discovery only | 596 | Yes |
| `/mcp` | Admin & discovery only | 565 | Yes |
> **Recommendation**: Always use the most specific endpoint. Using `/mcp` (596 tools) wastes context tokens and degrades AI response quality.
> **Recommendation**: Always use the most specific endpoint. Using `/mcp` (565 tools) wastes context tokens and degrades AI response quality.
**Available plugin endpoints:**
@@ -495,7 +532,7 @@ Use the most specific endpoint for your use case to minimize token usage:
| `/gitea/mcp` | Gitea | 56 |
| `/n8n/mcp` | n8n | 56 |
| `/supabase/mcp` | Supabase | 70 |
| `/openpanel/mcp` | OpenPanel | 73 |
| `/openpanel/mcp` | OpenPanel | 42 |
| `/appwrite/mcp` | Appwrite | 100 |
| `/directus/mcp` | Directus | 100 |
| `/system/mcp` | System Management | 24 |
@@ -572,18 +609,16 @@ docker compose up --build -d
### Step 3: Configure Environment Variables
Add all required environment variables in Coolify's environment variable UI:
Add the required environment variables in Coolify's environment variable UI:
```
MASTER_API_KEY=your-secure-key-here
OAUTH_JWT_SECRET_KEY=your-jwt-secret
OAUTH_BASE_URL=https://your-domain.com
WORDPRESS_SITE1_URL=https://example.com
WORDPRESS_SITE1_USERNAME=admin
WORDPRESS_SITE1_APP_PASSWORD=xxxx xxxx xxxx xxxx
ENCRYPTION_KEY=your-base64-encryption-key
```
The server auto-discovers all `WORDPRESS_*`, `WOOCOMMERCE_*`, `GITEA_*`, and other plugin environment variables at startup.
After deployment, add sites via the web dashboard at `https://your-domain.com/dashboard`.
### Step 4: Configure Health Check
@@ -603,7 +638,7 @@ The server auto-discovers all `WORDPRESS_*`, `WOOCOMMERCE_*`, `GITEA_*`, and oth
## Next Steps
1. **Explore the full tool list**: See the [README](../README.md) for all 596 tools
1. **Explore the full tool list**: See the [README](../README.md) for all 565 tools
2. **Set up API keys**: [API Keys Guide](API_KEYS_GUIDE.md) for per-project access control
3. **Configure OAuth**: [OAuth Guide](OAUTH_GUIDE.md) for Claude/ChatGPT auto-registration
4. **Monitor health**: Use `check_all_projects_health` tool or visit the web dashboard

97
docs/media-error-codes.md Normal file
View File

@@ -0,0 +1,97 @@
# Media Upload Error Codes (F.5a.6.2)
All media-upload tools return structured JSON errors shaped as:
```json
{
"error_code": "TOO_LARGE",
"message": "File is 12345678 bytes; limit is 10485760 bytes ...",
"details": { "size": 12345678, "max": 10485760 }
}
```
The `error_code` values below are **stable**: renaming or removing one is
a breaking change. The source of truth is
[`core/media_error_codes.py`](../core/media_error_codes.py) (the set
`MEDIA_ERROR_CODES`) and the stability test in
[`tests/plugins/wordpress/test_media_error_taxonomy.py`](../tests/plugins/wordpress/test_media_error_taxonomy.py).
## Input / validation
| Code | When it fires |
| ----------------- | ------------------------------------------------------------------------------------- |
| `BAD_BASE64` | The supplied base64 payload (full upload or single chunk) fails to decode. |
| `BAD_MODE` | `mode` argument to attach tools is not `append` or `replace`. |
| `BAD_ROLE` | `role` argument to attach tools is not `main` or `gallery`. |
| `BAD_SIZE` | Chunked `total_bytes` is `<= 0`. |
| `BAD_SOURCE` | Attach-upload helper given a `source` other than `base64` / `url`. |
| `EMPTY_FILE` | Decoded payload is zero bytes. |
| `MEDIA_NOT_FOUND` | A supplied `media_id` does not exist in the WP media library. |
| `MIME_REJECTED` | Sniffed MIME is not in the allow-list (`ALLOWED_MIMES`). |
| `MISSING_FIELD` | A required field is missing (e.g. `data` for base64, `url` for URL sideload). |
| `SSRF` | URL resolves to private/loopback/link-local/metadata IP or is on the host blocklist. |
| `TOO_LARGE` | Payload exceeds `WP_MEDIA_MAX_MB` / streamed download exceeds the byte cap. |
| `URL_FETCH_FAILED`| Remote URL returned `>= 400` while downloading. |
## WordPress REST upstream
| Code | When it fires |
| ----------------- | ------------------------------------------------------------------------------------- |
| `WP_413` | WordPress rejected the upload with HTTP 413 (server `upload_max_filesize` too low). |
| `WP_AUTH` | WordPress rejected auth (401/403). Application Password likely invalid/expired. |
| `WP_BAD_RESPONSE` | Upload accepted but WP returned a non-JSON body. |
| `WP_CREDENTIALS_MISSING` | (WC sites only) Tool needs a WP Application Password to hit `/wp/v2/media`. WC Consumer Key + Secret do not authenticate the WP core REST. Add `wp_username` + `wp_app_password` in Connection Settings → advanced. |
| `WP_<status>` | Any other non-2xx status from WP — e.g. `WP_400`, `WP_500`. Dynamic. |
## Companion plugin `upload-chunk` route (F.5a.7)
These only fire when MCPHub chose the companion `/airano-mcp/v1/upload-chunk` route
(because the probe advertises the helper and the payload exceeds `upload_max_filesize`).
A companion failure is **non-fatal**: MCPHub falls back to the standard `/wp/v2/media`
route on any error here, so these codes usually surface in logs, not to end users.
| Code | When it fires |
| ------------------------ | --------------------------------------------------------------------- |
| `COMPANION_BAD_RESPONSE` | Companion route returned 2xx but the body was not parseable JSON. |
| `COMPANION_<status>` | Any non-2xx status from the companion route — e.g. `COMPANION_500`. |
## Chunked upload session
| Code | When it fires |
| ------------------- | ---------------------------------------------------------------------------- |
| `BAD_STATE` | Session exists but is not in `open` state (already finalized/aborted). |
| `CHECKSUM_MISMATCH` | Assembled sha256 does not match value supplied at `start`. |
| `CHUNK_CHECKSUM` | Per-chunk sha256 does not match the value supplied with the chunk. |
| `CHUNK_ORDER` | Chunk index does not match `next_chunk`. |
| `CHUNK_OVERFLOW` | Appending this chunk would exceed declared `total_bytes`. |
| `EXPIRED` | Session's TTL has elapsed. |
| `INCOMPLETE` | Finalize called before all declared bytes arrived. |
| `NO_SESSION` | `session_id` is unknown. |
| `QUOTA_EXCEEDED` | User already has `MCPHUB_UPLOAD_MAX_CONCURRENT` open sessions. |
| `SESSION_TOO_LARGE` | Declared `total_bytes` exceeds the hard session cap (default 500 MB). |
## AI generation providers
| Code | When it fires |
| ----------------------- | ---------------------------------------------------------------------- |
| `GENERATION_FAILED` | Generic provider failure not covered by a more specific code. |
| `NO_PROVIDER_KEY` | No per-user key stored and no env fallback for the selected provider. |
| `PROVIDER_AUTH` | Provider rejected the supplied API key. |
| `PROVIDER_BAD_REQUEST` | Provider returned 4xx — prompt or size was invalid. |
| `PROVIDER_BAD_RESPONSE` | Provider returned a 2xx with an unexpected shape. |
| `PROVIDER_QUOTA` | Provider returned 429 / quota error. |
| `PROVIDER_TIMEOUT` | Provider timed out. |
| `PROVIDER_UNAVAILABLE` | Provider returned 5xx / circuit open. |
| `PROVIDER_UNKNOWN` | `provider` argument is not one of the registered providers. |
## Rate / policy
| Code | When it fires |
| -------------------- | --------------------------------------------------------------------- |
| `TOOL_RATE_LIMITED` | Per-tool, per-user cap exceeded (see `core/tool_rate_limiter.py`). |
## Catchall
| Code | When it fires |
| ---------- | ---------------------------------------------------------------------------------- |
| `INTERNAL` | Unexpected exception reached the top of a tool handler. Details in `message`. |

View File

@@ -75,12 +75,11 @@ SUPABASE_SITE1_ANON_KEY=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...
SUPABASE_SITE1_SERVICE_ROLE_KEY=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...
SUPABASE_SITE1_ALIAS=mysupabase
# Optional: Direct database access for postgres-meta
SUPABASE_SITE1_DB_HOST=db.supabase.example.com
SUPABASE_SITE1_DB_PORT=5432
SUPABASE_SITE1_DB_NAME=postgres
SUPABASE_SITE1_DB_USER=postgres
SUPABASE_SITE1_DB_PASSWORD=your-db-password
# Optional: Direct postgres-meta URL (when /pg/ is not exposed via Kong)
# SUPABASE_SITE1_META_URL=http://supabase-meta:8080
#
# Optional: Basic Auth for postgres-meta (when exposed via public URL)
# SUPABASE_SITE1_META_AUTH=admin:secretpassword
# Multiple Instances
SUPABASE_SITE2_URL=https://supabase-staging.example.com
@@ -704,6 +703,40 @@ EndpointType.SUPABASE: EndpointConfig(
---
## Security: postgres-meta
**postgres-meta has NO built-in authentication.** It is designed as an internal service
behind Kong API Gateway. When accessed through Kong's `/pg/` route, Kong validates JWT tokens.
### Risk: Public Exposure
If postgres-meta is exposed via a public URL (e.g., on Coolify with a public domain),
**anyone with the URL can execute SQL as `supabase_admin`**.
### Deployment Scenarios
| Deployment | meta_url needed? | meta_auth needed? | Notes |
|-----------|:---:|:---:|-------|
| supabase.com cloud | N/A | N/A | postgres-meta tools disabled |
| Docker Compose (standard) | No | No | Kong handles JWT via `/pg/` |
| Docker Compose (no /pg/) | Yes (internal URL) | No | Internal network is secure |
| Coolify (internal URL) | Yes | No | Internal Docker network |
| Coolify (public domain) | Yes | **Yes** | Exposed without auth |
| MCP Hub hosted users | Yes (public URL) | **Yes** | Must use public URL |
### Recommendations
1. **Prefer internal Docker network URLs** (`http://supabase-meta:8080`) when possible
2. **If public URL required**: Configure Basic Auth via reverse proxy and set `META_AUTH`
3. **Never expose postgres-meta publicly without authentication**
### MCP Hub Support
MCP Hub supports `META_AUTH` (format: `username:password`) for Basic Auth with postgres-meta.
When configured, requests to postgres-meta use `Authorization: Basic ...` instead of JWT.
---
## References
- [Supabase Self-Hosting Docker](https://supabase.com/docs/guides/self-hosting/docker)

View File

@@ -2,7 +2,7 @@
# MCP Hub — Environment Configuration
# ===================================
#
# MINIMUM TO START: Just set MASTER_API_KEY and one site below.
# MINIMUM TO START: Set MASTER_API_KEY, then add sites via the web dashboard.
# Full docs: https://github.com/airano-ir/mcphub/blob/main/docs/getting-started.md
#
# After editing, run:
@@ -19,99 +19,44 @@
# Generate with: python -c "import secrets; print(secrets.token_urlsafe(32))"
MASTER_API_KEY=your-secure-key-here
# Encryption key for user credentials (REQUIRED for Live Platform / Track E)
# Generate with: python -c "import os, base64; print(base64.b64encode(os.urandom(32)).decode())"
ENCRYPTION_KEY=
# Dashboard session secret (REQUIRED for production)
# Generate with: python -c "import secrets; print(secrets.token_hex(32))"
DASHBOARD_SESSION_SECRET=
# ============================================
# WORDPRESS SITES
# PLUGIN VISIBILITY (Track F.1)
# ============================================
# Pattern: WORDPRESS_{SITE_ID}_{CONFIG_KEY}
# Add as many sites as you want (SITE1, SITE2, SITE3, ...)
# Comma-separated list of plugins visible to public (OAuth) users.
# Admin users always see all plugins regardless of this setting.
# Default (if not set): wordpress,woocommerce,supabase,openpanel,gitea
# ENABLED_PLUGINS=wordpress,woocommerce,supabase,openpanel,gitea
# ============================================
# ADMIN SYSTEM (Track F.4)
# ============================================
# Comma-separated list of emails that get admin role on OAuth login.
# These users see the full admin dashboard (projects, API keys, health, etc.)
# If not set, OAuth users only get the standard user dashboard.
# ADMIN_EMAILS=admin@example.com,boss@company.com
# Disable Master Key login to the web dashboard (default: false).
# When true, only OAuth users (with admin email) can access admin dashboard.
# Master Key still works for MCP endpoints — only dashboard login is affected.
# DISABLE_MASTER_KEY_LOGIN=false
# ============================================
# SITES
# ============================================
# Sites are managed via the web dashboard (DB-based).
# After starting the server, open the dashboard to add sites:
# http://localhost:8000/dashboard
#
# How to create a WordPress Application Password:
# 1. Log into WordPress admin → Users → Your Profile
# 2. Scroll to "Application Passwords" section
# 3. Enter name "MCP Hub" → click "Add New"
# 4. Copy the generated password (format: xxxx xxxx xxxx xxxx)
WORDPRESS_SITE1_URL=https://your-site.com
WORDPRESS_SITE1_USERNAME=admin
WORDPRESS_SITE1_APP_PASSWORD=xxxx xxxx xxxx xxxx
WORDPRESS_SITE1_ALIAS=mysite
# Optional: Docker container name for WP-CLI tools (cache flush, transients, etc.)
# Find your container name with: docker ps --filter name=wordpress
# This lets MCP Hub run wp-cli commands inside your WordPress container.
# WORDPRESS_SITE1_CONTAINER=wordpress-container-name
# Second site example (uncomment to use):
# WORDPRESS_SITE2_URL=https://another-site.com
# WORDPRESS_SITE2_USERNAME=admin
# WORDPRESS_SITE2_APP_PASSWORD=yyyy yyyy yyyy yyyy
# WORDPRESS_SITE2_ALIAS=blog
# ============================================
# WORDPRESS ADVANCED (database, bulk, system)
# ============================================
# Pattern: WORDPRESS_ADVANCED_{SITE_ID}_{CONFIG_KEY}
# Provides database operations, bulk operations, and system management.
# REQUIRES Docker socket AND container name — all tools use WP-CLI.
#
# Uses the same credentials as WordPress, but the CONTAINER key is REQUIRED.
# WORDPRESS_ADVANCED_SITE1_URL=https://your-site.com
# WORDPRESS_ADVANCED_SITE1_USERNAME=admin
# WORDPRESS_ADVANCED_SITE1_APP_PASSWORD=xxxx xxxx xxxx xxxx
# WORDPRESS_ADVANCED_SITE1_CONTAINER=wordpress-container-name
# WORDPRESS_ADVANCED_SITE1_ALIAS=mysite-advanced
# ============================================
# WOOCOMMERCE STORES
# ============================================
# Pattern: WOOCOMMERCE_{STORE_ID}_{CONFIG_KEY}
# WooCommerce is a separate plugin — uses Consumer Key/Secret, not App Passwords.
#
# Create keys: WooCommerce → Settings → Advanced → REST API → Add Key
# WOOCOMMERCE_STORE1_URL=https://your-store.com
# WOOCOMMERCE_STORE1_CONSUMER_KEY=ck_xxxxx
# WOOCOMMERCE_STORE1_CONSUMER_SECRET=cs_xxxxx
# WOOCOMMERCE_STORE1_ALIAS=mystore
# ============================================
# OTHER PLUGINS (uncomment to use)
# ============================================
# All plugins auto-discover their environment variables at startup.
# Pattern: {PLUGIN_TYPE}_{INSTANCE_ID}_{CONFIG_KEY}
# --- Gitea ---
# GITEA_REPO1_URL=https://git.example.com
# GITEA_REPO1_TOKEN=your_gitea_token
# GITEA_REPO1_ALIAS=mygitea
# --- n8n ---
# N8N_INSTANCE1_URL=https://n8n.example.com
# 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_PROJECT1_ALIAS=mysupabase
# --- OpenPanel ---
# OPENPANEL_INSTANCE1_URL=https://openpanel.example.com
# OPENPANEL_INSTANCE1_CLIENT_ID=your_client_id
# OPENPANEL_INSTANCE1_CLIENT_SECRET=your_client_secret
# OPENPANEL_INSTANCE1_ALIAS=myopenpanel
# --- Appwrite ---
# APPWRITE_PROJECT1_URL=https://appwrite.example.com
# APPWRITE_PROJECT1_API_KEY=your_appwrite_api_key
# APPWRITE_PROJECT1_PROJECT_ID=your_project_id
# APPWRITE_PROJECT1_ALIAS=myappwrite
# --- Directus ---
# DIRECTUS_INSTANCE1_URL=https://directus.example.com
# DIRECTUS_INSTANCE1_TOKEN=your_directus_token
# DIRECTUS_INSTANCE1_ALIAS=mydirectus
# Supported plugins: WordPress, WooCommerce, Supabase, OpenPanel,
# Gitea, n8n, Appwrite, Directus, WordPress Advanced
# ============================================
# OAUTH (optional — for ChatGPT/Claude auto-registration)
@@ -122,6 +67,36 @@ WORDPRESS_SITE1_ALIAS=mysite
# OAUTH_JWT_SECRET_KEY=your-jwt-secret
# OAUTH_BASE_URL=https://your-public-domain.com
# ============================================
# OAUTH SOCIAL LOGIN (optional — for user registration)
# ============================================
# Enable GitHub and/or Google login for the Live Platform.
# Create OAuth apps:
# GitHub: https://github.com/settings/developers → New OAuth App
# - Callback URL: https://your-domain.com/auth/callback/github
# Google: https://console.cloud.google.com/apis/credentials → Create OAuth Client
# - Callback URL: https://your-domain.com/auth/callback/google
# GITHUB_CLIENT_ID=
# GITHUB_CLIENT_SECRET=
# GOOGLE_CLIENT_ID=
# GOOGLE_CLIENT_SECRET=
# PUBLIC_URL=https://mcp.example.com
# SESSION_EXPIRY_HOURS=168
# === SITE MANAGEMENT (E.3) ===
# MAX_SITES_PER_USER=10
# USER_RATE_LIMIT_PER_MIN=30
# USER_RATE_LIMIT_PER_HR=500
# ============================================
# COOLIFY (admin-only — F.17)
# ============================================
# Coolify instance for AI-driven deployment management.
# Create an API token in Coolify: Keys & Tokens → API tokens
# COOLIFY_URL=https://coolify.example.com
# COOLIFY_TOKEN=your-api-token-here
# ============================================
# ADVANCED (optional — defaults are fine)
# ============================================
@@ -129,6 +104,9 @@ WORDPRESS_SITE1_ALIAS=mysite
# Logging
LOG_LEVEL=INFO
# Database path (default: data/mcphub.db)
# DATABASE_PATH=/app/data/mcphub.db
# Rate limits (per client)
# RATE_LIMIT_PER_MINUTE=60
# RATE_LIMIT_PER_HOUR=1000

36
glama.json Normal file
View File

@@ -0,0 +1,36 @@
{
"$schema": "https://glama.ai/mcp/schemas/server.json",
"maintainers": ["airano-ir"],
"name": "mcphub",
"display_name": "MCP Hub",
"description": "Unified MCP server for managing WordPress, WooCommerce, Gitea, n8n, Supabase, OpenPanel, Appwrite, and Directus with 567 tools, multi-site support, and OAuth 2.1 authentication.",
"repository": "https://github.com/airano-ir/mcphub",
"homepage": "https://mcp.palebluedot.live",
"version": "3.6.0",
"license": "MIT",
"runtime": "python",
"transport": ["stdio", "streamable-http"],
"categories": ["developer-tools", "cms", "databases", "automation"],
"keywords": ["mcp", "wordpress", "woocommerce", "supabase", "gitea", "n8n", "openpanel", "appwrite", "directus", "self-hosted", "multi-site"],
"author": {
"name": "airano-ir",
"url": "https://github.com/airano-ir"
},
"capabilities": {
"tools": true,
"prompts": false,
"resources": false
},
"relatedServers": [
{
"name": "WordPress MCP",
"url": "https://glama.ai/mcp/servers/developer-in-world/wordpress-mcp",
"description": "WordPress REST API MCP server"
},
{
"name": "Supabase MCP",
"url": "https://glama.ai/mcp/servers/supabase-community/supabase-mcp",
"description": "Official Supabase MCP server"
}
]
}

View File

@@ -11,6 +11,7 @@ v2.3.0 (Phase G): Supabase Self-Hosted Plugin added
from plugins.appwrite.plugin import AppwritePlugin
from plugins.base import BasePlugin, PluginRegistry
from plugins.coolify.plugin import CoolifyPlugin
from plugins.directus.plugin import DirectusPlugin
from plugins.gitea.plugin import GiteaPlugin
from plugins.n8n.plugin import N8nPlugin
@@ -33,6 +34,7 @@ registry.register("supabase", SupabasePlugin)
registry.register("openpanel", OpenPanelPlugin)
registry.register("appwrite", AppwritePlugin)
registry.register("directus", DirectusPlugin)
registry.register("coolify", CoolifyPlugin)
__all__ = [
"BasePlugin",
@@ -47,4 +49,5 @@ __all__ = [
"OpenPanelPlugin",
"AppwritePlugin",
"DirectusPlugin",
"CoolifyPlugin",
]

View File

@@ -0,0 +1,33 @@
"""AI image generation provider library (F.5a.4).
Not a registered MCP plugin — this package exposes provider
implementations and a lookup registry consumed by media-upload tools in
other plugins (currently ``wordpress_generate_and_upload_image``).
Typical usage::
from plugins.ai_image.registry import get_provider
provider = get_provider("openai")
result = await provider.generate(
api_key=key, prompt="a red cube", size="1024x1024"
)
image_bytes, mime, meta = result.bytes, result.mime, result.meta
"""
from plugins.ai_image.providers.base import (
BaseImageProvider,
GenerationRequest,
GenerationResult,
ProviderError,
)
from plugins.ai_image.registry import get_provider, list_providers
__all__ = [
"BaseImageProvider",
"GenerationRequest",
"GenerationResult",
"ProviderError",
"get_provider",
"list_providers",
]

View File

@@ -0,0 +1 @@
"""AI image provider implementations (F.5a.4)."""

View File

@@ -0,0 +1,72 @@
"""Base types for AI image providers (F.5a.4).
Each concrete provider subclasses :class:`BaseImageProvider` and implements
``generate()``. Providers return raw image bytes + MIME so the caller (e.g.
the WordPress upload pipeline) can reuse the existing raw-upload path.
"""
from __future__ import annotations
from abc import ABC, abstractmethod
from dataclasses import dataclass, field
from typing import Any
class ProviderError(Exception):
"""Stable-coded error for provider failures.
Codes follow the F.5a error taxonomy (``PROVIDER_QUOTA``,
``PROVIDER_AUTH``, ``PROVIDER_BAD_REQUEST``, ``PROVIDER_UNAVAILABLE``,
``PROVIDER_TIMEOUT``). ``details`` is JSON-serialisable.
"""
def __init__(self, code: str, message: str, details: dict[str, Any] | None = None):
super().__init__(message)
self.code = code
self.message = message
self.details = details or {}
def to_dict(self) -> dict[str, Any]:
return {"error_code": self.code, "message": self.message, "details": self.details}
@dataclass
class GenerationRequest:
"""Normalised generation parameters.
Not every provider uses every field — unknown fields are ignored.
"""
prompt: str
size: str = "1024x1024"
quality: str = "standard"
model: str | None = None
negative_prompt: str | None = None
extras: dict[str, Any] = field(default_factory=dict)
@dataclass
class GenerationResult:
"""Raw bytes + metadata returned by a provider."""
data: bytes
mime: str
filename: str
meta: dict[str, Any] = field(default_factory=dict)
cost_usd: float | None = None
class BaseImageProvider(ABC):
"""Abstract provider: turns ``(api_key, request)`` into image bytes."""
name: str = "base"
@abstractmethod
async def generate(self, api_key: str, request: GenerationRequest) -> GenerationResult:
"""Call the provider API and return image bytes + metadata.
Implementations should raise :class:`ProviderError` with a stable
code for predictable client handling. Network retries for transient
failures (429 / 5xx) should happen inside the implementation.
"""
raise NotImplementedError

View File

@@ -0,0 +1,184 @@
"""OpenAI image-generation provider (DALL-E 3 + gpt-image-1).
DALL-E 3 returns a time-limited URL (~1h TTL). gpt-image-1 can return
``b64_json`` directly. In both cases we return the raw bytes immediately
to the caller so the 1h URL expiry is never a concern downstream.
"""
from __future__ import annotations
import asyncio
import base64
import logging
from typing import Any
import aiohttp
from plugins.ai_image.providers.base import (
BaseImageProvider,
GenerationRequest,
GenerationResult,
ProviderError,
)
_logger = logging.getLogger("mcphub.ai_image.openai")
_API_URL = "https://api.openai.com/v1/images/generations"
_DEFAULT_MODEL = "dall-e-3"
_RETRY_STATUS = {429, 500, 502, 503, 504}
_MAX_RETRIES = 3
_REQUEST_TIMEOUT = 90
# Rough per-image pricing (USD) for audit logging. These are documented
# public list prices at the time of writing and may drift — treat as
# ballpark for cost dashboards, not billing.
_COST_TABLE: dict[tuple[str, str, str], float] = {
("dall-e-3", "1024x1024", "standard"): 0.040,
("dall-e-3", "1024x1024", "hd"): 0.080,
("dall-e-3", "1792x1024", "standard"): 0.080,
("dall-e-3", "1024x1792", "standard"): 0.080,
("dall-e-3", "1792x1024", "hd"): 0.120,
("dall-e-3", "1024x1792", "hd"): 0.120,
("gpt-image-1", "1024x1024", "low"): 0.011,
("gpt-image-1", "1024x1024", "medium"): 0.042,
("gpt-image-1", "1024x1024", "high"): 0.167,
}
def _estimate_cost(model: str, size: str, quality: str) -> float | None:
return _COST_TABLE.get((model, size, quality))
class OpenAIProvider(BaseImageProvider):
name = "openai"
async def generate(self, api_key: str, request: GenerationRequest) -> GenerationResult:
if not api_key:
raise ProviderError("PROVIDER_AUTH", "OpenAI API key is missing.")
model = request.model or _DEFAULT_MODEL
payload: dict[str, Any] = {
"model": model,
"prompt": request.prompt,
"size": request.size,
"n": 1,
}
if model == "dall-e-3":
payload["quality"] = request.quality or "standard"
payload["response_format"] = "url"
else:
payload["response_format"] = "b64_json"
if request.quality:
payload["quality"] = request.quality
data, meta = await self._post_with_retry(api_key, payload)
items = data.get("data") or []
if not items:
raise ProviderError("PROVIDER_BAD_RESPONSE", "OpenAI returned no images.")
first = items[0]
if first.get("b64_json"):
image_bytes = base64.b64decode(first["b64_json"])
elif first.get("url"):
image_bytes = await _fetch_url(first["url"])
else:
raise ProviderError(
"PROVIDER_BAD_RESPONSE",
"OpenAI response contained no b64_json or url.",
)
meta = dict(meta)
if first.get("revised_prompt"):
meta["revised_prompt"] = first["revised_prompt"]
meta["model"] = model
meta["size"] = request.size
return GenerationResult(
data=image_bytes,
mime="image/png",
filename=f"openai-{model}.png",
meta=meta,
cost_usd=_estimate_cost(model, request.size, request.quality or "standard"),
)
async def _post_with_retry(
self, api_key: str, payload: dict[str, Any]
) -> tuple[dict[str, Any], dict[str, Any]]:
headers = {
"Authorization": f"Bearer {api_key}",
"Content-Type": "application/json",
}
last_error: str = ""
delay = 1.0
timeout = aiohttp.ClientTimeout(total=_REQUEST_TIMEOUT)
async with aiohttp.ClientSession(timeout=timeout) as session:
for attempt in range(1, _MAX_RETRIES + 1):
try:
async with session.post(_API_URL, json=payload, headers=headers) as resp:
if resp.status == 200:
body = await resp.json()
return body, {"attempt": attempt, "status": 200}
text = await resp.text()
last_error = text[:500]
if resp.status == 401:
raise ProviderError(
"PROVIDER_AUTH",
"OpenAI rejected the API key (401).",
{"body": last_error},
)
if resp.status == 400:
raise ProviderError(
"PROVIDER_BAD_REQUEST",
f"OpenAI rejected request (400): {last_error}",
{"body": last_error},
)
if resp.status == 429 and attempt < _MAX_RETRIES:
_logger.warning("OpenAI 429, retry %d", attempt)
await asyncio.sleep(delay)
delay *= 2
continue
if resp.status in _RETRY_STATUS and attempt < _MAX_RETRIES:
_logger.warning("OpenAI %d, retry %d", resp.status, attempt)
await asyncio.sleep(delay)
delay *= 2
continue
if resp.status == 429:
raise ProviderError(
"PROVIDER_QUOTA",
"OpenAI quota/rate-limit hit after retries.",
{"status": 429, "body": last_error},
)
raise ProviderError(
"PROVIDER_UNAVAILABLE",
f"OpenAI upstream error HTTP {resp.status}.",
{"status": resp.status, "body": last_error},
)
except TimeoutError as exc:
last_error = f"timeout: {exc}"
if attempt < _MAX_RETRIES:
await asyncio.sleep(delay)
delay *= 2
continue
raise ProviderError(
"PROVIDER_TIMEOUT",
"OpenAI request timed out after retries.",
) from exc
raise ProviderError(
"PROVIDER_UNAVAILABLE",
f"OpenAI call failed after {_MAX_RETRIES} attempts: {last_error}",
)
async def _fetch_url(url: str) -> bytes:
"""Fetch a DALL-E URL immediately. URLs expire in ~1h, so no caching."""
timeout = aiohttp.ClientTimeout(total=_REQUEST_TIMEOUT)
async with aiohttp.ClientSession(timeout=timeout) as session:
async with session.get(url) as resp:
if resp.status != 200:
raise ProviderError(
"PROVIDER_BAD_RESPONSE",
f"Failed to fetch OpenAI image URL (HTTP {resp.status}).",
{"status": resp.status},
)
return await resp.read()

View File

@@ -0,0 +1,474 @@
"""OpenRouter image-generation provider.
OpenRouter is an aggregator that routes requests to many model vendors.
For image generation, the usable path is their chat-completions endpoint
with ``modalities=["image","text"]`` — models like
``google/gemini-2.5-flash-image`` (a.k.a. "Nano Banana") return the
generated image as a ``data:``-prefixed base64 URL inside the message
``images`` array; other models may return ``image_url``-style pointers.
Why this provider is worth adding:
* Unlocks Gemini image models without needing a Google Cloud project.
* Lets users concentrate AI spend on a single OpenRouter key rather
than managing separate OpenAI / Stability / Replicate accounts.
* Supported models (as of F.X.fix 2026-04-18):
- ``google/gemini-2.5-flash-image`` (default, GA)
- ``google/gemini-2.5-flash-image-preview`` (DEPRECATED; 404 on
fresh OpenRouter accounts — kept as a recognised alias so we can
emit ``PROVIDER_MODEL_DEPRECATED`` with a clear hint)
- Any other OpenRouter model that returns image parts in
``message.images[]`` — the parser is tolerant of newer entries.
The default model is picked to cover the most common use case (hero
images for WordPress / WooCommerce posts); callers can override via
``GenerationRequest.model``.
"""
from __future__ import annotations
import asyncio
import base64
import logging
import os
import time
from typing import Any
import aiohttp
from plugins.ai_image.providers.base import (
BaseImageProvider,
GenerationRequest,
GenerationResult,
ProviderError,
)
_logger = logging.getLogger("mcphub.ai_image.openrouter")
_API_URL = "https://openrouter.ai/api/v1/chat/completions"
_MODELS_URL = "https://openrouter.ai/api/v1/models"
# GA endpoint. The preview alias (...-image-preview) was deprecated after
# Google promoted the model to GA — new OpenRouter accounts get 404 on
# the preview ID.
_DEFAULT_MODEL = "google/gemini-2.5-flash-image"
_DEPRECATED_MODELS: dict[str, str] = {
"google/gemini-2.5-flash-image-preview": "google/gemini-2.5-flash-image",
}
_RETRY_STATUS = {429, 500, 502, 503, 504}
_MAX_RETRIES = 3
_REQUEST_TIMEOUT = 120
# USD-per-image price table. Figures are conservative upper bounds from
# public OpenRouter pricing (2026-04); operators who negotiate custom
# rates can override via ``OPENROUTER_PRICING_OVERRIDE`` (JSON map of
# model_id -> price USD).
_MODEL_PRICING: dict[str, float] = {
"google/gemini-2.5-flash-image": 0.00039,
"google/gemini-2.5-flash-image-preview": 0.00039,
"google/imagen-3.0-generate": 0.04,
"google/imagen-3.0-fast": 0.02,
"openai/dall-e-3": 0.04,
"openai/dall-e-3-hd": 0.08,
"openai/gpt-image-1": 0.017,
"black-forest-labs/flux-1.1-pro": 0.04,
"black-forest-labs/flux-pro": 0.055,
"black-forest-labs/flux-schnell": 0.003,
"stability-ai/stable-diffusion-3.5-large": 0.065,
}
def _pricing_table() -> dict[str, float]:
"""Return the effective price table, merging env override if present."""
import json as _json
override = os.environ.get("OPENROUTER_PRICING_OVERRIDE", "").strip()
if not override:
return _MODEL_PRICING
try:
parsed = _json.loads(override)
except Exception as exc: # noqa: BLE001
_logger.warning("OPENROUTER_PRICING_OVERRIDE is not valid JSON: %s", exc)
return _MODEL_PRICING
if not isinstance(parsed, dict):
return _MODEL_PRICING
merged = dict(_MODEL_PRICING)
for k, v in parsed.items():
try:
merged[str(k)] = float(v)
except (TypeError, ValueError):
continue
return merged
def _cost_for(model: str) -> float | None:
"""Return USD cost for a known model, else None (logs at debug)."""
price = _pricing_table().get(model)
if price is None:
_logger.debug("openrouter: no pricing entry for model %r", model)
return price
# Module-level cache for list_image_models() — 1h TTL is plenty; model
# catalog drift is measured in days, not minutes.
_MODELS_CACHE_TTL_SECONDS = 3600
_models_cache: dict[str, Any] = {"fetched_at": 0.0, "payload": None}
class OpenRouterProvider(BaseImageProvider):
name = "openrouter"
async def generate(self, api_key: str, request: GenerationRequest) -> GenerationResult:
if not api_key:
raise ProviderError("PROVIDER_AUTH", "OpenRouter API key is missing.")
requested_model = request.model or _DEFAULT_MODEL
replacement = _DEPRECATED_MODELS.get(requested_model)
if replacement is not None:
raise ProviderError(
"PROVIDER_MODEL_DEPRECATED",
(
f"OpenRouter model '{requested_model}' is deprecated. "
f"Use '{replacement}' instead."
),
{"requested_model": requested_model, "replacement_model": replacement},
)
model = requested_model
# Chat-completions shape with image modality declared. The
# prompt is sent as the single user message; size hints are
# advisory — Gemini picks an output size internally.
user_content: str = request.prompt
if request.negative_prompt:
user_content = f"{user_content}\n\nAvoid: {request.negative_prompt}"
payload: dict[str, Any] = {
"model": model,
"messages": [{"role": "user", "content": user_content}],
"modalities": ["image", "text"],
}
body = await self._post_with_retry(api_key, payload)
image_url = _extract_image_url(body)
if not image_url:
raise ProviderError(
"PROVIDER_BAD_RESPONSE",
"OpenRouter response contained no image data. Is the "
f"model '{model}' configured for image output?",
{"model": model},
)
image_bytes, mime = await _materialise_image(image_url)
if not image_bytes:
raise ProviderError(
"PROVIDER_BAD_RESPONSE",
"OpenRouter image URL could not be fetched / decoded.",
{"model": model},
)
# Derive a useful filename suffix from the mime type.
ext = {"image/png": "png", "image/jpeg": "jpg", "image/webp": "webp"}.get(mime, "png")
meta: dict[str, Any] = {"model": model, "size": request.size}
# Attach OpenRouter usage if present so audit logs can correlate.
usage = body.get("usage") or {}
if isinstance(usage, dict):
for k in ("prompt_tokens", "completion_tokens", "total_tokens"):
if k in usage:
meta[k] = usage[k]
return GenerationResult(
data=image_bytes,
mime=mime,
filename=f"openrouter-{model.replace('/', '-')}.{ext}",
meta=meta,
cost_usd=_cost_for(model),
)
async def _post_with_retry(self, api_key: str, payload: dict[str, Any]) -> dict[str, Any]:
headers = {
"Authorization": f"Bearer {api_key}",
"Content-Type": "application/json",
# OpenRouter recommends identifying the calling app for
# observability. Using a stable string so admins can spot
# traffic from MCPHub in their OpenRouter dashboard.
"HTTP-Referer": "https://github.com/airano-ir/mcphub",
"X-Title": "MCPHub",
}
last_error: str = ""
delay = 1.0
timeout = aiohttp.ClientTimeout(total=_REQUEST_TIMEOUT)
async with aiohttp.ClientSession(timeout=timeout) as session:
for attempt in range(1, _MAX_RETRIES + 1):
try:
async with session.post(_API_URL, json=payload, headers=headers) as resp:
if resp.status == 200:
return await resp.json()
text = await resp.text()
last_error = text[:500]
if resp.status == 401:
raise ProviderError(
"PROVIDER_AUTH",
"OpenRouter rejected the API key (401).",
{"body": last_error},
)
if resp.status == 400:
raise ProviderError(
"PROVIDER_BAD_REQUEST",
f"OpenRouter rejected request (400): {last_error}",
{"body": last_error},
)
if resp.status in _RETRY_STATUS and attempt < _MAX_RETRIES:
_logger.warning(
"OpenRouter %d, retry %d/%d", resp.status, attempt, _MAX_RETRIES
)
await asyncio.sleep(delay)
delay *= 2
continue
if resp.status == 429:
raise ProviderError(
"PROVIDER_QUOTA",
"OpenRouter quota/rate-limit hit after retries.",
{"status": 429, "body": last_error},
)
raise ProviderError(
"PROVIDER_UNAVAILABLE",
f"OpenRouter upstream error HTTP {resp.status}.",
{"status": resp.status, "body": last_error},
)
except TimeoutError as exc:
last_error = f"timeout: {exc}"
if attempt < _MAX_RETRIES:
await asyncio.sleep(delay)
delay *= 2
continue
raise ProviderError(
"PROVIDER_TIMEOUT",
"OpenRouter request timed out after retries.",
) from exc
raise ProviderError(
"PROVIDER_UNAVAILABLE",
f"OpenRouter call failed after {_MAX_RETRIES} attempts: {last_error}",
)
async def list_image_models(
self, api_key: str | None = None, *, force: bool = False
) -> list[dict[str, Any]]:
"""Return OpenRouter catalog entries whose modality includes image.
The catalog drifts slowly (days) so we cache the whole filtered
list at module scope for 1h. ``api_key`` is optional — the
``/v1/models`` endpoint is public, but authenticated callers see
per-account availability flags. On any upstream error the
previous cache (if any) is returned; otherwise an empty list.
"""
now = time.time()
payload = _models_cache.get("payload")
fetched_at = _models_cache.get("fetched_at", 0.0)
if not force and payload is not None and (now - fetched_at) < _MODELS_CACHE_TTL_SECONDS:
return list(payload)
headers = {
"HTTP-Referer": "https://github.com/airano-ir/mcphub",
"X-Title": "MCPHub",
}
if api_key:
headers["Authorization"] = f"Bearer {api_key}"
timeout = aiohttp.ClientTimeout(total=_REQUEST_TIMEOUT)
try:
async with aiohttp.ClientSession(timeout=timeout) as session:
async with session.get(_MODELS_URL, headers=headers) as resp:
if resp.status != 200:
_logger.warning("openrouter /v1/models HTTP %s", resp.status)
return list(payload or [])
body = await resp.json()
except Exception as exc: # noqa: BLE001
_logger.warning("openrouter /v1/models fetch failed: %s", exc)
return list(payload or [])
raw_models = body.get("data") or []
pricing = _pricing_table()
filtered: list[dict[str, Any]] = []
for item in raw_models:
if not isinstance(item, dict):
continue
if not _model_is_image(item):
continue
model_id = str(item.get("id") or "")
if not model_id or model_id in _DEPRECATED_MODELS:
continue
filtered.append(
{
"id": model_id,
"name": item.get("name") or model_id,
"description": (item.get("description") or "")[:400],
"context_length": item.get("context_length"),
"input_modalities": _modalities_of(item, "input"),
"output_modalities": _modalities_of(item, "output"),
"price_per_image_usd": pricing.get(model_id),
}
)
filtered.sort(key=lambda m: m["id"])
_models_cache["fetched_at"] = now
_models_cache["payload"] = filtered
return list(filtered)
def _modalities_of(item: dict[str, Any], side: str) -> list[str]:
"""Extract input/output modality list from an OpenRouter model entry."""
arch = item.get("architecture") or {}
if isinstance(arch, dict):
key = "input_modalities" if side == "input" else "output_modalities"
mods = arch.get(key)
if isinstance(mods, list):
return [str(m) for m in mods if isinstance(m, str)]
modality = arch.get("modality")
if isinstance(modality, str):
parts = modality.split("->")
if len(parts) == 2:
idx = 0 if side == "input" else 1
return [p.strip() for p in parts[idx].split("+") if p.strip()]
return []
def _model_is_image(item: dict[str, Any]) -> bool:
"""True when an OpenRouter model entry emits image output."""
out = _modalities_of(item, "output")
if any("image" in m.lower() for m in out):
return True
# Legacy catalog rows without structured modalities — fall back to a
# name/description heuristic so we don't silently drop valid models.
blob = " ".join(str(item.get(k, "") or "") for k in ("id", "name", "description")).lower()
return "image" in blob and "gen" in blob
def _extract_image_url(body: dict[str, Any]) -> str | None:
"""Pull the first image URL out of an OpenRouter chat response.
The wire format varies by model vendor. We recognise:
* ``choices[0].message.images[i].image_url.url`` — a string that
may be a ``data:image/...;base64,...`` URL or an https URL.
* ``choices[0].message.content`` when it is a list of parts, each
of shape ``{type: "image_url", image_url: {url: ...}}`` —
OpenAI-compatible multimodal reply shape.
Returns None if no usable reference was found.
"""
choices = body.get("choices") or []
if not isinstance(choices, list) or not choices:
return None
message = (choices[0] or {}).get("message") or {}
# Shape 1: message.images (Gemini via OpenRouter).
for entry in message.get("images") or []:
url = _image_url_from_entry(entry)
if url:
return url
# Shape 2: message.content as list of parts.
content = message.get("content")
if isinstance(content, list):
for part in content:
if not isinstance(part, dict):
continue
if part.get("type") == "image_url":
url = _image_url_from_entry(part)
if url:
return url
return None
def _image_url_from_entry(entry: Any) -> str | None:
"""Handle both ``{image_url: {url: ...}}`` and ``{image_url: "..."}``."""
if not isinstance(entry, dict):
return None
iu = entry.get("image_url")
if isinstance(iu, str):
return iu
if isinstance(iu, dict):
u = iu.get("url")
return u if isinstance(u, str) else None
# Some providers emit a bare "url" key.
u = entry.get("url")
return u if isinstance(u, str) else None
async def _materialise_image(url: str) -> tuple[bytes, str]:
"""Turn an image URL (data: or https) into raw bytes + MIME.
Returns ``(b"", "")`` on any failure so the caller can emit a
uniform ``PROVIDER_BAD_RESPONSE`` error.
"""
if url.startswith("data:"):
try:
header, payload = url.split(",", 1)
except ValueError:
return b"", ""
mime = "image/png"
rest = header[len("data:") :] if header.startswith("data:") else header
if ";" in rest:
mime = rest.split(";", 1)[0] or mime
elif rest:
mime = rest
if "base64" in header:
try:
return base64.b64decode(payload), mime
except Exception:
return b"", mime
return payload.encode("latin-1", errors="ignore"), mime
if url.startswith("http://") or url.startswith("https://"):
timeout = aiohttp.ClientTimeout(total=_REQUEST_TIMEOUT)
async with aiohttp.ClientSession(timeout=timeout) as session:
async with session.get(url) as resp:
if resp.status != 200:
return b"", ""
mime = resp.headers.get("Content-Type", "image/png").split(";", 1)[0].strip()
return await resp.read(), mime or "image/png"
return b"", ""
# ---------------------------------------------------------------------------
# Starlette handler — GET /api/providers/openrouter/models
# ---------------------------------------------------------------------------
async def api_openrouter_models(request: Any) -> Any:
"""Return the cached OpenRouter image-model catalog.
Auth: same OAuth user session guard as the other /api/ endpoints.
Query params:
* ``force=1`` — bypass the 1h module cache.
* ``site_id=<uuid>`` — if present, the user's OpenRouter key for
that site is used as bearer so availability flags reflect the
operator's account; otherwise the endpoint is fetched unauthed.
"""
from starlette.responses import JSONResponse
from core.dashboard.routes import _require_user_session
user_session, redirect = _require_user_session(request)
if redirect or user_session is None:
return JSONResponse({"ok": False, "error": "unauthorized"}, status_code=401)
force = request.query_params.get("force") in {"1", "true", "True"}
api_key: str | None = None
site_id = (request.query_params.get("site_id") or "").strip()
if site_id:
try:
from core.database import get_database
from core.site_api import get_site_provider_key
db = get_database()
site = await db.get_site(site_id, user_session["user_id"])
if site is not None:
api_key = await get_site_provider_key(site_id, "openrouter")
except Exception as exc: # noqa: BLE001
_logger.debug("openrouter models: site key lookup skipped: %s", exc)
provider = OpenRouterProvider()
models = await provider.list_image_models(api_key=api_key, force=force)
return JSONResponse({"ok": True, "provider": "openrouter", "models": models})

View File

@@ -0,0 +1,204 @@
"""Replicate provider (Flux family + other image models).
Replicate runs predictions asynchronously: POST to ``/v1/predictions``
returns a job id, and we poll ``/v1/predictions/{id}`` until status is
``succeeded`` or ``failed``. The final output is a URL (or list of URLs)
that must be fetched to get bytes.
"""
from __future__ import annotations
import asyncio
import logging
from typing import Any
import aiohttp
from plugins.ai_image.providers.base import (
BaseImageProvider,
GenerationRequest,
GenerationResult,
ProviderError,
)
_logger = logging.getLogger("mcphub.ai_image.replicate")
_PREDICTIONS_URL = "https://api.replicate.com/v1/predictions"
_DEFAULT_MODEL = "black-forest-labs/flux-schnell"
_MAX_RETRIES = 3
_POLL_INTERVAL = 2.0
_POLL_TIMEOUT = 180
_REQUEST_TIMEOUT = 60
_RETRY_STATUS = {429, 500, 502, 503, 504}
_COST_TABLE: dict[str, float] = {
"black-forest-labs/flux-schnell": 0.003,
"black-forest-labs/flux-dev": 0.025,
"black-forest-labs/flux-pro": 0.055,
}
def _aspect_from_size(size: str) -> str:
"""Replicate's Flux models accept an aspect_ratio enum, not WxH."""
mapping = {
"1024x1024": "1:1",
"1344x768": "16:9",
"768x1344": "9:16",
"1152x896": "3:2",
"896x1152": "2:3",
}
return mapping.get(size, "1:1")
class ReplicateProvider(BaseImageProvider):
name = "replicate"
async def generate(self, api_key: str, request: GenerationRequest) -> GenerationResult:
if not api_key:
raise ProviderError("PROVIDER_AUTH", "Replicate API token is missing.")
model = request.model or _DEFAULT_MODEL
payload: dict[str, Any] = {
"model": model,
"input": {
"prompt": request.prompt,
"aspect_ratio": _aspect_from_size(request.size),
**(request.extras or {}),
},
}
if request.negative_prompt:
payload["input"]["negative_prompt"] = request.negative_prompt
prediction = await self._create_prediction(api_key, payload)
final = await self._poll_until_done(api_key, prediction)
output = final.get("output")
url = _first_url(output)
if not url:
raise ProviderError(
"PROVIDER_BAD_RESPONSE",
"Replicate prediction finished without an image URL.",
{"prediction": final},
)
image_bytes = await _fetch_url(url)
meta = {
"model": model,
"prediction_id": final.get("id"),
"status": final.get("status"),
"metrics": final.get("metrics", {}),
}
return GenerationResult(
data=image_bytes,
mime="image/webp" if url.endswith(".webp") else "image/png",
filename=f"replicate-{model.split('/')[-1]}.png",
meta=meta,
cost_usd=_COST_TABLE.get(model),
)
async def _create_prediction(self, api_key: str, payload: dict[str, Any]) -> dict[str, Any]:
headers = {
"Authorization": f"Bearer {api_key}",
"Content-Type": "application/json",
"Prefer": "wait=0",
}
delay = 1.0
last_error = ""
timeout = aiohttp.ClientTimeout(total=_REQUEST_TIMEOUT)
async with aiohttp.ClientSession(timeout=timeout) as session:
for attempt in range(1, _MAX_RETRIES + 1):
async with session.post(_PREDICTIONS_URL, json=payload, headers=headers) as resp:
if resp.status in (200, 201):
return await resp.json()
text = await resp.text()
last_error = text[:500]
if resp.status in (401, 403):
raise ProviderError(
"PROVIDER_AUTH",
f"Replicate rejected auth ({resp.status}).",
{"body": last_error},
)
if resp.status == 422:
raise ProviderError(
"PROVIDER_BAD_REQUEST",
f"Replicate rejected request: {last_error}",
{"body": last_error},
)
if resp.status in _RETRY_STATUS and attempt < _MAX_RETRIES:
_logger.warning("Replicate %d, retry %d", resp.status, attempt)
await asyncio.sleep(delay)
delay *= 2
continue
if resp.status == 429:
raise ProviderError(
"PROVIDER_QUOTA",
"Replicate rate-limit hit.",
{"status": 429, "body": last_error},
)
raise ProviderError(
"PROVIDER_UNAVAILABLE",
f"Replicate HTTP {resp.status}.",
{"status": resp.status, "body": last_error},
)
raise ProviderError(
"PROVIDER_UNAVAILABLE",
f"Replicate create_prediction failed after retries: {last_error}",
)
async def _poll_until_done(self, api_key: str, prediction: dict[str, Any]) -> dict[str, Any]:
headers = {"Authorization": f"Bearer {api_key}"}
poll_url = (prediction.get("urls") or {}).get("get") or (
f"{_PREDICTIONS_URL}/{prediction.get('id')}"
)
timeout = aiohttp.ClientTimeout(total=_REQUEST_TIMEOUT)
deadline = asyncio.get_event_loop().time() + _POLL_TIMEOUT
async with aiohttp.ClientSession(timeout=timeout) as session:
while True:
if asyncio.get_event_loop().time() > deadline:
raise ProviderError(
"PROVIDER_TIMEOUT",
"Replicate prediction did not finish within timeout.",
{"prediction_id": prediction.get("id")},
)
async with session.get(poll_url, headers=headers) as resp:
if resp.status != 200:
text = await resp.text()
raise ProviderError(
"PROVIDER_UNAVAILABLE",
f"Replicate poll HTTP {resp.status}.",
{"body": text[:500]},
)
body = await resp.json()
status = body.get("status")
if status in ("succeeded",):
return body
if status in ("failed", "canceled"):
raise ProviderError(
"PROVIDER_BAD_RESPONSE",
f"Replicate prediction {status}: {body.get('error')}",
{"prediction": body},
)
await asyncio.sleep(_POLL_INTERVAL)
def _first_url(output: Any) -> str | None:
if isinstance(output, str):
return output
if isinstance(output, list) and output:
first = output[0]
if isinstance(first, str):
return first
return None
async def _fetch_url(url: str) -> bytes:
timeout = aiohttp.ClientTimeout(total=_REQUEST_TIMEOUT)
async with aiohttp.ClientSession(timeout=timeout) as session:
async with session.get(url) as resp:
if resp.status != 200:
raise ProviderError(
"PROVIDER_BAD_RESPONSE",
f"Failed to fetch Replicate output (HTTP {resp.status}).",
{"status": resp.status},
)
return await resp.read()

View File

@@ -0,0 +1,154 @@
"""Stability AI image generation provider (Stable Image Core / Ultra).
Uses the v2beta generate endpoint with ``Accept: image/*`` to get raw
bytes back directly (no JSON indirection).
"""
from __future__ import annotations
import asyncio
import logging
from typing import Any
import aiohttp
from plugins.ai_image.providers.base import (
BaseImageProvider,
GenerationRequest,
GenerationResult,
ProviderError,
)
_logger = logging.getLogger("mcphub.ai_image.stability")
_MODEL_ENDPOINTS = {
"core": "https://api.stability.ai/v2beta/stable-image/generate/core",
"ultra": "https://api.stability.ai/v2beta/stable-image/generate/ultra",
"sd3": "https://api.stability.ai/v2beta/stable-image/generate/sd3",
}
_DEFAULT_MODEL = "core"
_MAX_RETRIES = 3
_REQUEST_TIMEOUT = 120
_RETRY_STATUS = {429, 500, 502, 503, 504}
_COST_TABLE: dict[str, float] = {
"core": 0.03,
"ultra": 0.08,
"sd3": 0.065,
}
def _size_to_aspect(size: str) -> str:
"""Map a WxH size string to the Stability aspect_ratio enum."""
mapping = {
"1024x1024": "1:1",
"1152x896": "21:9",
"1216x832": "3:2",
"1344x768": "16:9",
"768x1344": "9:16",
"832x1216": "2:3",
"1024x576": "16:9",
"512x512": "1:1",
}
return mapping.get(size, "1:1")
class StabilityProvider(BaseImageProvider):
name = "stability"
async def generate(self, api_key: str, request: GenerationRequest) -> GenerationResult:
if not api_key:
raise ProviderError("PROVIDER_AUTH", "Stability API key is missing.")
model = (request.model or _DEFAULT_MODEL).lower()
endpoint = _MODEL_ENDPOINTS.get(model)
if endpoint is None:
raise ProviderError(
"PROVIDER_BAD_REQUEST",
f"Unknown Stability model '{model}'. " f"Allowed: {', '.join(_MODEL_ENDPOINTS)}.",
)
form = aiohttp.FormData()
form.add_field("prompt", request.prompt)
form.add_field("output_format", "png")
form.add_field("aspect_ratio", _size_to_aspect(request.size))
if request.negative_prompt:
form.add_field("negative_prompt", request.negative_prompt)
headers = {
"Authorization": f"Bearer {api_key}",
"Accept": "image/*",
}
data, meta = await self._post_with_retry(endpoint, form, headers)
meta = dict(meta)
meta["model"] = model
meta["aspect_ratio"] = _size_to_aspect(request.size)
return GenerationResult(
data=data,
mime="image/png",
filename=f"stability-{model}.png",
meta=meta,
cost_usd=_COST_TABLE.get(model),
)
async def _post_with_retry(
self,
endpoint: str,
form: aiohttp.FormData,
headers: dict[str, str],
) -> tuple[bytes, dict[str, Any]]:
last_error: str = ""
delay = 1.0
timeout = aiohttp.ClientTimeout(total=_REQUEST_TIMEOUT)
async with aiohttp.ClientSession(timeout=timeout) as session:
for attempt in range(1, _MAX_RETRIES + 1):
try:
async with session.post(endpoint, data=form, headers=headers) as resp:
if resp.status == 200:
body = await resp.read()
return body, {"attempt": attempt, "status": 200}
text = await resp.text()
last_error = text[:500]
if resp.status == 401 or resp.status == 403:
raise ProviderError(
"PROVIDER_AUTH",
f"Stability rejected auth ({resp.status}).",
{"body": last_error},
)
if resp.status == 400:
raise ProviderError(
"PROVIDER_BAD_REQUEST",
f"Stability rejected request: {last_error}",
{"body": last_error},
)
if resp.status in _RETRY_STATUS and attempt < _MAX_RETRIES:
_logger.warning("Stability %d, retry %d", resp.status, attempt)
await asyncio.sleep(delay)
delay *= 2
continue
if resp.status == 429:
raise ProviderError(
"PROVIDER_QUOTA",
"Stability quota/rate-limit hit.",
{"status": 429, "body": last_error},
)
raise ProviderError(
"PROVIDER_UNAVAILABLE",
f"Stability upstream error HTTP {resp.status}.",
{"status": resp.status, "body": last_error},
)
except TimeoutError as exc:
last_error = f"timeout: {exc}"
if attempt < _MAX_RETRIES:
await asyncio.sleep(delay)
delay *= 2
continue
raise ProviderError(
"PROVIDER_TIMEOUT",
"Stability request timed out after retries.",
) from exc
raise ProviderError(
"PROVIDER_UNAVAILABLE",
f"Stability call failed after {_MAX_RETRIES} attempts: {last_error}",
)

View File

@@ -0,0 +1,36 @@
"""Provider registry for AI image generation (F.5a.4).
Lookup by name; raises :class:`ProviderError` with code
``PROVIDER_UNKNOWN`` if the caller supplies a non-existent provider.
"""
from __future__ import annotations
from plugins.ai_image.providers.base import BaseImageProvider, ProviderError
from plugins.ai_image.providers.openai import OpenAIProvider
from plugins.ai_image.providers.openrouter import OpenRouterProvider
from plugins.ai_image.providers.replicate import ReplicateProvider
from plugins.ai_image.providers.stability import StabilityProvider
_PROVIDERS: dict[str, BaseImageProvider] = {
"openai": OpenAIProvider(),
"stability": StabilityProvider(),
"replicate": ReplicateProvider(),
"openrouter": OpenRouterProvider(),
}
def get_provider(name: str) -> BaseImageProvider:
"""Return the registered provider singleton by name."""
try:
return _PROVIDERS[name]
except KeyError as exc:
raise ProviderError(
"PROVIDER_UNKNOWN",
f"Unknown provider '{name}'. Allowed: {', '.join(_PROVIDERS)}.",
) from exc
def list_providers() -> list[str]:
"""Return all registered provider names in registration order."""
return list(_PROVIDERS.keys())

View File

@@ -165,6 +165,40 @@ class BasePlugin(ABC):
"""
return {"healthy": True, "message": "Health check not implemented for this plugin"}
async def probe_credential_capabilities(self) -> dict[str, Any]:
"""F.7e — report what the saved credential can actually do.
Plugins should override this to call the upstream service
(WordPress companion ``/capabilities``, WooCommerce
``system_status``, Gitea ``/user`` header scopes, Coolify
``/api/v1/teams/0``, etc.) and return the subset of capability
names the caller's token actually grants.
The base implementation returns a well-shaped "probe not
available" payload so callers can handle all plugin types
uniformly without special-casing the ones that haven't
implemented a probe yet.
Returns:
Dict with:
probe_available: bool — True if the plugin knows how to
query its upstream for capabilities.
granted: list[str] — the capabilities the credential
actually grants (subset of the universe the plugin
understands). Empty when ``probe_available=False``.
source: str — which endpoint / header the list came from;
``"unavailable"`` when no probe is wired.
reason: str | None — optional human-readable diagnostic
when ``probe_available=False`` or when the call
failed in a way that still produced a usable answer.
"""
return {
"probe_available": False,
"granted": [],
"source": "unavailable",
"reason": "probe_not_implemented",
}
def get_project_info(self) -> dict[str, Any]:
"""
Return basic information about this project instance.

View File

@@ -0,0 +1,5 @@
"""
Coolify Plugin — AI-driven deployment management for Coolify instances.
F.17: Phase 1 MVP — Applications, Deployments, Servers (~30 tools)
"""

429
plugins/coolify/client.py Normal file
View File

@@ -0,0 +1,429 @@
"""
Coolify REST API Client
Handles all HTTP communication with Coolify REST API.
Separates API communication from business logic.
"""
import logging
from typing import Any
import aiohttp
class CoolifyClient:
"""
Coolify REST API client for HTTP communication.
Handles Bearer token authentication, request formatting,
and error handling for all Coolify API v1 endpoints.
"""
def __init__(self, site_url: str, token: str):
"""
Initialize Coolify API client.
Args:
site_url: Coolify instance URL (e.g., https://coolify.example.com)
token: API token for Bearer authentication
"""
self.site_url = site_url.rstrip("/")
self.api_base = f"{self.site_url}/api/v1"
self.token = token
self.logger = logging.getLogger(f"CoolifyClient.{site_url}")
def _get_headers(self) -> dict[str, str]:
"""Get request headers with Bearer authentication."""
return {
"Authorization": f"Bearer {self.token}",
"Content-Type": "application/json",
"Accept": "application/json",
}
async def request(
self,
method: str,
endpoint: str,
params: dict | None = None,
json_data: dict | None = None,
) -> Any:
"""
Make authenticated request to Coolify REST API.
Args:
method: HTTP method (GET, POST, PATCH, DELETE)
endpoint: API endpoint (without base URL)
params: Query parameters
json_data: JSON body data
Returns:
API response (dict, list, or None)
Raises:
Exception: On API errors with status code and message
"""
url = f"{self.api_base}/{endpoint.lstrip('/')}"
if params:
params = {k: v for k, v in params.items() if v is not None}
if json_data:
json_data = {k: v for k, v in json_data.items() if v is not None}
self.logger.debug(f"{method} {url}")
async with (
aiohttp.ClientSession() as session,
session.request(
method=method,
url=url,
params=params,
json=json_data,
headers=self._get_headers(),
) as response,
):
self.logger.debug(f"Response status: {response.status}")
if response.status == 204:
return {"success": True, "message": "Operation completed successfully"}
try:
response_data = await response.json()
except Exception:
response_text = await response.text()
if response.status >= 400:
raise Exception(
f"Coolify API error (status {response.status}): {response_text}"
)
return {"success": True, "message": response_text}
if response.status >= 400:
error_msg = response_data.get("message", "Unknown error")
raise Exception(f"Coolify API error (status {response.status}): {error_msg}")
return response_data
# --- Applications ---
async def list_applications(self, tag: str | None = None) -> list[dict]:
"""List all applications."""
params = {"tag": tag} if tag else {}
return await self.request("GET", "applications", params=params)
async def get_application(self, uuid: str) -> dict:
"""Get application by UUID."""
return await self.request("GET", f"applications/{uuid}")
async def create_application_public(self, data: dict) -> dict:
"""Create application from public repository."""
return await self.request("POST", "applications/public", json_data=data)
async def create_application_dockerfile(self, data: dict) -> dict:
"""Create application from Dockerfile."""
return await self.request("POST", "applications/dockerfile", json_data=data)
async def create_application_docker_image(self, data: dict) -> dict:
"""Create application from Docker image."""
return await self.request("POST", "applications/dockerimage", json_data=data)
async def create_application_docker_compose(self, data: dict) -> dict:
"""Create application from Docker Compose."""
return await self.request("POST", "applications/dockercompose", json_data=data)
async def update_application(self, uuid: str, data: dict) -> dict:
"""Update application by UUID."""
return await self.request("PATCH", f"applications/{uuid}", json_data=data)
async def delete_application(
self,
uuid: str,
delete_configurations: bool = True,
delete_volumes: bool = True,
docker_cleanup: bool = True,
delete_connected_networks: bool = True,
) -> dict:
"""Delete application by UUID."""
params = {
"delete_configurations": str(delete_configurations).lower(),
"delete_volumes": str(delete_volumes).lower(),
"docker_cleanup": str(docker_cleanup).lower(),
"delete_connected_networks": str(delete_connected_networks).lower(),
}
return await self.request("DELETE", f"applications/{uuid}", params=params)
async def start_application(
self, uuid: str, force: bool = False, instant_deploy: bool = False
) -> dict:
"""Deploy/start application."""
params = {}
if force:
params["force"] = "true"
if instant_deploy:
params["instant_deploy"] = "true"
return await self.request("GET", f"applications/{uuid}/start", params=params)
async def stop_application(self, uuid: str, docker_cleanup: bool = True) -> dict:
"""Stop application."""
params = {"docker_cleanup": str(docker_cleanup).lower()}
return await self.request("GET", f"applications/{uuid}/stop", params=params)
async def restart_application(self, uuid: str) -> dict:
"""Restart application."""
return await self.request("GET", f"applications/{uuid}/restart")
async def get_application_logs(self, uuid: str, lines: int = 100) -> dict:
"""Get application logs."""
return await self.request("GET", f"applications/{uuid}/logs", params={"lines": lines})
# --- Application Environment Variables ---
async def list_application_envs(self, uuid: str) -> list[dict]:
"""List application environment variables."""
return await self.request("GET", f"applications/{uuid}/envs")
async def create_application_env(self, uuid: str, data: dict) -> dict:
"""Create application environment variable."""
return await self.request("POST", f"applications/{uuid}/envs", json_data=data)
async def update_application_env(self, uuid: str, data: dict) -> dict:
"""Update application environment variable."""
return await self.request("PATCH", f"applications/{uuid}/envs", json_data=data)
async def update_application_envs_bulk(self, uuid: str, data: list[dict]) -> dict:
"""Bulk update application environment variables."""
return await self.request(
"PATCH", f"applications/{uuid}/envs/bulk", json_data={"data": data}
)
async def delete_application_env(self, uuid: str, env_uuid: str) -> dict:
"""Delete application environment variable."""
return await self.request("DELETE", f"applications/{uuid}/envs/{env_uuid}")
# --- Deployments ---
async def list_deployments(self) -> list[dict]:
"""List running deployments."""
return await self.request("GET", "deployments")
async def get_deployment(self, uuid: str) -> dict:
"""Get deployment by UUID."""
return await self.request("GET", f"deployments/{uuid}")
async def cancel_deployment(self, uuid: str) -> dict:
"""Cancel a deployment."""
return await self.request("POST", f"deployments/{uuid}/cancel")
async def deploy(
self,
tag: str | None = None,
uuid: str | None = None,
force: bool = False,
) -> dict:
"""Deploy by tag or UUID."""
params = {}
if tag:
params["tag"] = tag
if uuid:
params["uuid"] = uuid
if force:
params["force"] = "true"
return await self.request("GET", "deploy", params=params)
async def list_app_deployments(self, uuid: str, skip: int = 0, take: int = 10) -> list[dict]:
"""List deployments for a specific application."""
params = {"skip": skip, "take": take}
return await self.request("GET", f"deployments/applications/{uuid}", params=params)
# --- Servers ---
async def list_servers(self) -> list[dict]:
"""List all servers."""
return await self.request("GET", "servers")
async def get_server(self, uuid: str) -> dict:
"""Get server by UUID."""
return await self.request("GET", f"servers/{uuid}")
async def create_server(self, data: dict) -> dict:
"""Create a new server."""
return await self.request("POST", "servers", json_data=data)
async def update_server(self, uuid: str, data: dict) -> dict:
"""Update server by UUID."""
return await self.request("PATCH", f"servers/{uuid}", json_data=data)
async def delete_server(self, uuid: str) -> dict:
"""Delete server by UUID."""
return await self.request("DELETE", f"servers/{uuid}")
async def get_server_resources(self, uuid: str) -> list[dict]:
"""Get resources on a server."""
return await self.request("GET", f"servers/{uuid}/resources")
async def get_server_domains(self, uuid: str) -> list[dict]:
"""Get domains configured on a server."""
return await self.request("GET", f"servers/{uuid}/domains")
async def validate_server(self, uuid: str) -> dict:
"""Validate server connectivity and configuration."""
return await self.request("GET", f"servers/{uuid}/validate")
# --- Projects ---
async def list_projects(self) -> list[dict]:
"""List all projects."""
return await self.request("GET", "projects")
async def get_project(self, uuid: str) -> dict:
"""Get project by UUID."""
return await self.request("GET", f"projects/{uuid}")
async def create_project(self, data: dict) -> dict:
"""Create a new project."""
return await self.request("POST", "projects", json_data=data)
async def update_project(self, uuid: str, data: dict) -> dict:
"""Update project by UUID."""
return await self.request("PATCH", f"projects/{uuid}", json_data=data)
async def delete_project(self, uuid: str) -> dict:
"""Delete project by UUID."""
return await self.request("DELETE", f"projects/{uuid}")
# --- Environments ---
async def list_environments(self, project_uuid: str) -> list[dict]:
"""List environments in a project."""
return await self.request("GET", f"projects/{project_uuid}/environments")
async def get_environment(self, project_uuid: str, environment_name: str) -> dict:
"""Get environment by name."""
return await self.request("GET", f"projects/{project_uuid}/environments/{environment_name}")
async def create_environment(self, project_uuid: str, data: dict) -> dict:
"""Create environment in a project."""
return await self.request("POST", f"projects/{project_uuid}/environments", json_data=data)
# --- Databases ---
async def list_databases(self) -> list[dict]:
"""List all databases."""
return await self.request("GET", "databases")
async def get_database(self, uuid: str) -> dict:
"""Get database by UUID."""
return await self.request("GET", f"databases/{uuid}")
async def update_database(self, uuid: str, data: dict) -> dict:
"""Update database by UUID."""
return await self.request("PATCH", f"databases/{uuid}", json_data=data)
async def delete_database(
self,
uuid: str,
delete_configurations: bool = True,
delete_volumes: bool = True,
docker_cleanup: bool = True,
) -> dict:
"""Delete database by UUID."""
params = {
"delete_configurations": str(delete_configurations).lower(),
"delete_volumes": str(delete_volumes).lower(),
"docker_cleanup": str(docker_cleanup).lower(),
}
return await self.request("DELETE", f"databases/{uuid}", params=params)
async def start_database(self, uuid: str) -> dict:
"""Start database."""
return await self.request("GET", f"databases/{uuid}/start")
async def stop_database(self, uuid: str) -> dict:
"""Stop database."""
return await self.request("GET", f"databases/{uuid}/stop")
async def restart_database(self, uuid: str) -> dict:
"""Restart database."""
return await self.request("GET", f"databases/{uuid}/restart")
async def create_database(self, db_type: str, data: dict) -> dict:
"""Create a database of given type (postgresql, mysql, mariadb, mongodb, redis, clickhouse)."""
return await self.request("POST", f"databases/{db_type}", json_data=data)
async def get_database_backups(self, uuid: str) -> dict:
"""Get database backups."""
return await self.request("GET", f"databases/{uuid}/backups")
async def create_database_backup(self, uuid: str) -> dict:
"""Create a manual database backup."""
return await self.request("POST", f"databases/{uuid}/backups")
async def list_backup_executions(self) -> list[dict]:
"""List all backup executions."""
return await self.request("GET", "databases/backup-executions")
# --- Services ---
async def list_services(self) -> list[dict]:
"""List all services."""
return await self.request("GET", "services")
async def get_service(self, uuid: str) -> dict:
"""Get service by UUID."""
return await self.request("GET", f"services/{uuid}")
async def create_service(self, data: dict) -> dict:
"""Create a service from template."""
return await self.request("POST", "services", json_data=data)
async def update_service(self, uuid: str, data: dict) -> dict:
"""Update service by UUID."""
return await self.request("PATCH", f"services/{uuid}", json_data=data)
async def delete_service(
self,
uuid: str,
delete_configurations: bool = True,
delete_volumes: bool = True,
docker_cleanup: bool = True,
) -> dict:
"""Delete service by UUID."""
params = {
"delete_configurations": str(delete_configurations).lower(),
"delete_volumes": str(delete_volumes).lower(),
"docker_cleanup": str(docker_cleanup).lower(),
}
return await self.request("DELETE", f"services/{uuid}", params=params)
async def start_service(self, uuid: str) -> dict:
"""Start service."""
return await self.request("GET", f"services/{uuid}/start")
async def stop_service(self, uuid: str) -> dict:
"""Stop service."""
return await self.request("GET", f"services/{uuid}/stop")
async def restart_service(self, uuid: str) -> dict:
"""Restart service."""
return await self.request("GET", f"services/{uuid}/restart")
# --- Service Environment Variables ---
async def list_service_envs(self, uuid: str) -> list[dict]:
"""List service environment variables."""
return await self.request("GET", f"services/{uuid}/envs")
async def create_service_env(self, uuid: str, data: dict) -> dict:
"""Create service environment variable."""
return await self.request("POST", f"services/{uuid}/envs", json_data=data)
async def update_service_env(self, uuid: str, data: dict) -> dict:
"""Update service environment variable."""
return await self.request("PATCH", f"services/{uuid}/envs", json_data=data)
async def update_service_envs_bulk(self, uuid: str, data: list[dict]) -> dict:
"""Bulk update service environment variables."""
return await self.request("PATCH", f"services/{uuid}/envs/bulk", json_data={"data": data})
async def delete_service_env(self, uuid: str, env_uuid: str) -> dict:
"""Delete service environment variable."""
return await self.request("DELETE", f"services/{uuid}/envs/{env_uuid}")

View File

@@ -0,0 +1,16 @@
"""
Coolify Plugin Handlers
All tool handlers for Coolify operations.
"""
from . import applications, databases, deployments, projects, servers, services
__all__ = [
"applications",
"databases",
"deployments",
"projects",
"servers",
"services",
]

View File

@@ -0,0 +1,883 @@
"""Application Handler — manages Coolify applications, lifecycle, and env vars."""
import json
from typing import Any
from plugins.coolify.client import CoolifyClient
def get_tool_specifications() -> list[dict[str, Any]]:
"""Return tool specifications for ToolGenerator."""
return [
{
"name": "list_applications",
"category": "read",
"method_name": "list_applications",
"description": "List all Coolify applications. Optionally filter by tag name.",
"schema": {
"type": "object",
"properties": {
"tag": {
"anyOf": [{"type": "string"}, {"type": "null"}],
"description": "Filter by tag name",
},
},
},
"scope": "read",
},
{
"name": "get_application",
"category": "read",
"method_name": "get_application",
"description": "Get details of a specific Coolify application by UUID.",
"schema": {
"type": "object",
"properties": {
"uuid": {
"type": "string",
"description": "Application UUID",
"minLength": 1,
},
},
"required": ["uuid"],
},
"scope": "read",
},
{
"name": "create_application_public",
"category": "crud",
"method_name": "create_application_public",
"description": (
"Create a Coolify application from a public Git repository. "
"Requires project_uuid, server_uuid, environment, git_repository, "
"git_branch, build_pack, and ports_exposes."
),
"schema": {
"type": "object",
"properties": {
"project_uuid": {
"type": "string",
"description": "Project UUID",
},
"server_uuid": {
"type": "string",
"description": "Server UUID",
},
"environment_name": {
"type": "string",
"description": "Environment name (e.g., 'production')",
},
"git_repository": {
"type": "string",
"description": "Git repository URL",
},
"git_branch": {
"type": "string",
"description": "Git branch name",
},
"build_pack": {
"type": "string",
"description": "Build pack type",
"enum": ["nixpacks", "static", "dockerfile", "dockercompose"],
},
"ports_exposes": {
"type": "string",
"description": "Exposed ports (e.g., '3000')",
},
"name": {
"anyOf": [{"type": "string"}, {"type": "null"}],
"description": "Application name",
},
"description": {
"anyOf": [{"type": "string"}, {"type": "null"}],
"description": "Application description",
},
"domains": {
"anyOf": [{"type": "string"}, {"type": "null"}],
"description": "Comma-separated domain URLs",
},
"instant_deploy": {
"type": "boolean",
"description": "Deploy immediately after creation",
"default": False,
},
},
"required": [
"project_uuid",
"server_uuid",
"environment_name",
"git_repository",
"git_branch",
"build_pack",
"ports_exposes",
],
},
"scope": "write",
},
{
"name": "create_application_dockerfile",
"category": "crud",
"method_name": "create_application_dockerfile",
"description": (
"Create a Coolify application from a Dockerfile (without git). "
"Requires project_uuid, server_uuid, environment, and dockerfile content."
),
"schema": {
"type": "object",
"properties": {
"project_uuid": {
"type": "string",
"description": "Project UUID",
},
"server_uuid": {
"type": "string",
"description": "Server UUID",
},
"environment_name": {
"type": "string",
"description": "Environment name",
},
"dockerfile": {
"type": "string",
"description": "Dockerfile content",
},
"name": {
"anyOf": [{"type": "string"}, {"type": "null"}],
"description": "Application name",
},
"description": {
"anyOf": [{"type": "string"}, {"type": "null"}],
"description": "Application description",
},
"domains": {
"anyOf": [{"type": "string"}, {"type": "null"}],
"description": "Comma-separated domain URLs",
},
"ports_exposes": {
"anyOf": [{"type": "string"}, {"type": "null"}],
"description": "Exposed ports",
},
"instant_deploy": {
"type": "boolean",
"description": "Deploy immediately",
"default": False,
},
},
"required": [
"project_uuid",
"server_uuid",
"environment_name",
"dockerfile",
],
},
"scope": "write",
},
{
"name": "create_application_docker_image",
"category": "crud",
"method_name": "create_application_docker_image",
"description": (
"Create a Coolify application from a Docker image. "
"Requires project_uuid, server_uuid, environment, "
"docker_registry_image_name, and ports_exposes."
),
"schema": {
"type": "object",
"properties": {
"project_uuid": {
"type": "string",
"description": "Project UUID",
},
"server_uuid": {
"type": "string",
"description": "Server UUID",
},
"environment_name": {
"type": "string",
"description": "Environment name",
},
"docker_registry_image_name": {
"type": "string",
"description": "Docker image name (e.g., 'nginx')",
},
"ports_exposes": {
"type": "string",
"description": "Exposed ports",
},
"docker_registry_image_tag": {
"anyOf": [{"type": "string"}, {"type": "null"}],
"description": "Docker image tag (default: latest)",
},
"name": {
"anyOf": [{"type": "string"}, {"type": "null"}],
"description": "Application name",
},
"description": {
"anyOf": [{"type": "string"}, {"type": "null"}],
"description": "Application description",
},
"domains": {
"anyOf": [{"type": "string"}, {"type": "null"}],
"description": "Comma-separated domain URLs",
},
"instant_deploy": {
"type": "boolean",
"description": "Deploy immediately",
"default": False,
},
},
"required": [
"project_uuid",
"server_uuid",
"environment_name",
"docker_registry_image_name",
"ports_exposes",
],
},
"scope": "write",
},
{
"name": "create_application_compose",
"category": "crud",
"method_name": "create_application_compose",
"description": (
"Create a Coolify application from Docker Compose. "
"Note: Deprecated by Coolify — prefer using services instead."
),
"schema": {
"type": "object",
"properties": {
"project_uuid": {
"type": "string",
"description": "Project UUID",
},
"server_uuid": {
"type": "string",
"description": "Server UUID",
},
"environment_name": {
"type": "string",
"description": "Environment name",
},
"docker_compose_raw": {
"type": "string",
"description": "Raw Docker Compose YAML content",
},
"name": {
"anyOf": [{"type": "string"}, {"type": "null"}],
"description": "Application name",
},
"instant_deploy": {
"type": "boolean",
"description": "Deploy immediately",
"default": False,
},
},
"required": [
"project_uuid",
"server_uuid",
"environment_name",
"docker_compose_raw",
],
},
"scope": "write",
},
{
"name": "update_application",
"category": "crud",
"method_name": "update_application",
"description": (
"Update a Coolify application settings. Supports name, description, "
"domains, build settings, health checks, resource limits, and more."
),
"schema": {
"type": "object",
"properties": {
"uuid": {
"type": "string",
"description": "Application UUID",
"minLength": 1,
},
"name": {
"anyOf": [{"type": "string"}, {"type": "null"}],
"description": "Application name",
},
"description": {
"anyOf": [{"type": "string"}, {"type": "null"}],
"description": "Application description",
},
"domains": {
"anyOf": [{"type": "string"}, {"type": "null"}],
"description": "Comma-separated domain URLs",
},
"git_repository": {
"anyOf": [{"type": "string"}, {"type": "null"}],
"description": "Git repository URL",
},
"git_branch": {
"anyOf": [{"type": "string"}, {"type": "null"}],
"description": "Git branch",
},
"build_pack": {
"anyOf": [
{
"type": "string",
"enum": [
"nixpacks",
"static",
"dockerfile",
"dockercompose",
],
},
{"type": "null"},
],
"description": "Build pack type",
},
"install_command": {
"anyOf": [{"type": "string"}, {"type": "null"}],
"description": "Install command",
},
"build_command": {
"anyOf": [{"type": "string"}, {"type": "null"}],
"description": "Build command",
},
"start_command": {
"anyOf": [{"type": "string"}, {"type": "null"}],
"description": "Start command",
},
"ports_exposes": {
"anyOf": [{"type": "string"}, {"type": "null"}],
"description": "Exposed ports",
},
"is_auto_deploy_enabled": {
"anyOf": [{"type": "boolean"}, {"type": "null"}],
"description": "Enable auto-deploy on push",
},
"instant_deploy": {
"anyOf": [{"type": "boolean"}, {"type": "null"}],
"description": "Deploy instantly",
},
},
"required": ["uuid"],
},
"scope": "write",
},
{
"name": "delete_application",
"category": "system",
"method_name": "delete_application",
"description": (
"Delete a Coolify application permanently. "
"Optionally clean up configs, volumes, and networks."
),
"schema": {
"type": "object",
"properties": {
"uuid": {
"type": "string",
"description": "Application UUID",
"minLength": 1,
},
"delete_configurations": {
"type": "boolean",
"description": "Delete configurations",
"default": True,
},
"delete_volumes": {
"type": "boolean",
"description": "Delete volumes",
"default": True,
},
"docker_cleanup": {
"type": "boolean",
"description": "Run Docker cleanup",
"default": True,
},
"delete_connected_networks": {
"type": "boolean",
"description": "Delete connected networks",
"default": True,
},
},
"required": ["uuid"],
},
"scope": "admin",
},
{
"name": "start_application",
"category": "lifecycle",
"method_name": "start_application",
"description": "Deploy/start a Coolify application. Triggers a new deployment.",
"schema": {
"type": "object",
"properties": {
"uuid": {
"type": "string",
"description": "Application UUID",
"minLength": 1,
},
"force": {
"type": "boolean",
"description": "Force rebuild without cache",
"default": False,
},
"instant_deploy": {
"type": "boolean",
"description": "Skip deployment queue",
"default": False,
},
},
"required": ["uuid"],
},
"scope": "write",
},
{
"name": "stop_application",
"category": "lifecycle",
"method_name": "stop_application",
"description": "Stop a running Coolify application.",
"schema": {
"type": "object",
"properties": {
"uuid": {
"type": "string",
"description": "Application UUID",
"minLength": 1,
},
"docker_cleanup": {
"type": "boolean",
"description": "Prune networks and volumes",
"default": True,
},
},
"required": ["uuid"],
},
"scope": "write",
},
{
"name": "restart_application",
"category": "lifecycle",
"method_name": "restart_application",
"description": "Restart a Coolify application.",
"schema": {
"type": "object",
"properties": {
"uuid": {
"type": "string",
"description": "Application UUID",
"minLength": 1,
},
},
"required": ["uuid"],
},
"scope": "write",
},
{
"name": "get_application_logs",
"category": "read_sensitive",
"sensitivity": "sensitive",
"method_name": "get_application_logs",
"description": "Get logs for a Coolify application.",
"schema": {
"type": "object",
"properties": {
"uuid": {
"type": "string",
"description": "Application UUID",
"minLength": 1,
},
"lines": {
"type": "integer",
"description": "Number of log lines to retrieve",
"default": 100,
"minimum": 1,
"maximum": 10000,
},
},
"required": ["uuid"],
},
"scope": "read",
},
{
"name": "list_application_envs",
"category": "read_sensitive",
"sensitivity": "sensitive",
"method_name": "list_application_envs",
"description": "List environment variables for a Coolify application.",
"schema": {
"type": "object",
"properties": {
"uuid": {
"type": "string",
"description": "Application UUID",
"minLength": 1,
},
},
"required": ["uuid"],
},
"scope": "read",
},
{
"name": "create_application_env",
"category": "env",
"sensitivity": "sensitive",
"method_name": "create_application_env",
"description": "Create an environment variable for a Coolify application.",
"schema": {
"type": "object",
"properties": {
"uuid": {
"type": "string",
"description": "Application UUID",
"minLength": 1,
},
"key": {
"type": "string",
"description": "Environment variable key",
"minLength": 1,
},
"value": {
"type": "string",
"description": "Environment variable value",
},
"is_preview": {
"type": "boolean",
"description": "Preview deployment only",
"default": False,
},
"is_literal": {
"type": "boolean",
"description": "Treat as literal (no variable interpolation)",
"default": False,
},
"is_multiline": {
"type": "boolean",
"description": "Allow multiline value",
"default": False,
},
"is_shown_once": {
"type": "boolean",
"description": "Show value only once",
"default": False,
},
},
"required": ["uuid", "key", "value"],
},
"scope": "write",
},
{
"name": "update_application_env",
"category": "env",
"sensitivity": "sensitive",
"method_name": "update_application_env",
"description": "Update an environment variable for a Coolify application.",
"schema": {
"type": "object",
"properties": {
"uuid": {
"type": "string",
"description": "Application UUID",
"minLength": 1,
},
"key": {
"type": "string",
"description": "Environment variable key",
"minLength": 1,
},
"value": {
"type": "string",
"description": "New value",
},
"is_preview": {
"anyOf": [{"type": "boolean"}, {"type": "null"}],
"description": "Preview deployment only",
},
"is_literal": {
"anyOf": [{"type": "boolean"}, {"type": "null"}],
"description": "Treat as literal",
},
"is_multiline": {
"anyOf": [{"type": "boolean"}, {"type": "null"}],
"description": "Allow multiline",
},
"is_shown_once": {
"anyOf": [{"type": "boolean"}, {"type": "null"}],
"description": "Show only once",
},
},
"required": ["uuid", "key", "value"],
},
"scope": "write",
},
{
"name": "update_application_envs_bulk",
"category": "env",
"sensitivity": "sensitive",
"method_name": "update_application_envs_bulk",
"description": "Bulk update environment variables for a Coolify application.",
"schema": {
"type": "object",
"properties": {
"uuid": {
"type": "string",
"description": "Application UUID",
"minLength": 1,
},
"data": {
"type": "array",
"description": "Array of env var objects with key, value, and flags",
"items": {
"type": "object",
"properties": {
"key": {"type": "string"},
"value": {"type": "string"},
"is_preview": {"type": "boolean"},
"is_literal": {"type": "boolean"},
"is_multiline": {"type": "boolean"},
"is_shown_once": {"type": "boolean"},
},
"required": ["key", "value"],
},
},
},
"required": ["uuid", "data"],
},
"scope": "write",
},
{
"name": "delete_application_env",
"category": "env",
"sensitivity": "sensitive",
"method_name": "delete_application_env",
"description": "Delete an environment variable from a Coolify application.",
"schema": {
"type": "object",
"properties": {
"uuid": {
"type": "string",
"description": "Application UUID",
"minLength": 1,
},
"env_uuid": {
"type": "string",
"description": "Environment variable UUID",
"minLength": 1,
},
},
"required": ["uuid", "env_uuid"],
},
"scope": "write",
},
]
# --- Handler Functions ---
async def list_applications(client: CoolifyClient, tag: str | None = None) -> str:
"""List all applications."""
apps = await client.list_applications(tag=tag)
result = {"success": True, "count": len(apps), "applications": apps}
return json.dumps(result, indent=2, ensure_ascii=False)
async def get_application(client: CoolifyClient, uuid: str) -> str:
"""Get application details."""
app = await client.get_application(uuid)
result = {"success": True, "application": app}
return json.dumps(result, indent=2, ensure_ascii=False)
async def create_application_public(client: CoolifyClient, **kwargs) -> str:
"""Create application from public repository."""
app = await client.create_application_public(kwargs)
result = {
"success": True,
"message": "Application created successfully",
"application": app,
}
return json.dumps(result, indent=2, ensure_ascii=False)
async def create_application_dockerfile(client: CoolifyClient, **kwargs) -> str:
"""Create application from Dockerfile."""
app = await client.create_application_dockerfile(kwargs)
result = {
"success": True,
"message": "Application created from Dockerfile",
"application": app,
}
return json.dumps(result, indent=2, ensure_ascii=False)
async def create_application_docker_image(client: CoolifyClient, **kwargs) -> str:
"""Create application from Docker image."""
app = await client.create_application_docker_image(kwargs)
result = {
"success": True,
"message": "Application created from Docker image",
"application": app,
}
return json.dumps(result, indent=2, ensure_ascii=False)
async def create_application_compose(client: CoolifyClient, **kwargs) -> str:
"""Create application from Docker Compose."""
app = await client.create_application_docker_compose(kwargs)
result = {
"success": True,
"message": "Application created from Docker Compose",
"application": app,
}
return json.dumps(result, indent=2, ensure_ascii=False)
async def update_application(client: CoolifyClient, uuid: str, **kwargs) -> str:
"""Update application settings."""
data = {k: v for k, v in kwargs.items() if v is not None and k != "uuid"}
app = await client.update_application(uuid, data)
result = {
"success": True,
"message": f"Application '{uuid}' updated successfully",
"application": app,
}
return json.dumps(result, indent=2, ensure_ascii=False)
async def delete_application(
client: CoolifyClient,
uuid: str,
delete_configurations: bool = True,
delete_volumes: bool = True,
docker_cleanup: bool = True,
delete_connected_networks: bool = True,
) -> str:
"""Delete an application."""
result_data = await client.delete_application(
uuid,
delete_configurations=delete_configurations,
delete_volumes=delete_volumes,
docker_cleanup=docker_cleanup,
delete_connected_networks=delete_connected_networks,
)
result = {"success": True, "message": f"Application '{uuid}' deleted", "data": result_data}
return json.dumps(result, indent=2, ensure_ascii=False)
async def start_application(
client: CoolifyClient,
uuid: str,
force: bool = False,
instant_deploy: bool = False,
) -> str:
"""Deploy/start application."""
result_data = await client.start_application(uuid, force=force, instant_deploy=instant_deploy)
result = {
"success": True,
"message": f"Application '{uuid}' deployment queued",
"data": result_data,
}
return json.dumps(result, indent=2, ensure_ascii=False)
async def stop_application(client: CoolifyClient, uuid: str, docker_cleanup: bool = True) -> str:
"""Stop application."""
result_data = await client.stop_application(uuid, docker_cleanup=docker_cleanup)
result = {"success": True, "message": f"Application '{uuid}' stopping", "data": result_data}
return json.dumps(result, indent=2, ensure_ascii=False)
async def restart_application(client: CoolifyClient, uuid: str) -> str:
"""Restart application."""
result_data = await client.restart_application(uuid)
result = {"success": True, "message": f"Application '{uuid}' restarting", "data": result_data}
return json.dumps(result, indent=2, ensure_ascii=False)
async def get_application_logs(client: CoolifyClient, uuid: str, lines: int = 100) -> str:
"""Get application logs."""
logs = await client.get_application_logs(uuid, lines=lines)
result = {"success": True, "logs": logs}
return json.dumps(result, indent=2, ensure_ascii=False)
async def list_application_envs(client: CoolifyClient, uuid: str) -> str:
"""List application environment variables."""
envs = await client.list_application_envs(uuid)
result = {"success": True, "count": len(envs), "envs": envs}
return json.dumps(result, indent=2, ensure_ascii=False)
async def create_application_env(
client: CoolifyClient,
uuid: str,
key: str,
value: str,
is_preview: bool = False,
is_literal: bool = False,
is_multiline: bool = False,
is_shown_once: bool = False,
) -> str:
"""Create application environment variable."""
data = {
"key": key,
"value": value,
"is_preview": is_preview,
"is_literal": is_literal,
"is_multiline": is_multiline,
"is_shown_once": is_shown_once,
}
result_data = await client.create_application_env(uuid, data)
result = {
"success": True,
"message": f"Env var '{key}' created",
"data": result_data,
}
return json.dumps(result, indent=2, ensure_ascii=False)
async def update_application_env(
client: CoolifyClient,
uuid: str,
key: str,
value: str,
is_preview: bool | None = None,
is_literal: bool | None = None,
is_multiline: bool | None = None,
is_shown_once: bool | None = None,
) -> str:
"""Update application environment variable."""
data = {"key": key, "value": value}
if is_preview is not None:
data["is_preview"] = is_preview
if is_literal is not None:
data["is_literal"] = is_literal
if is_multiline is not None:
data["is_multiline"] = is_multiline
if is_shown_once is not None:
data["is_shown_once"] = is_shown_once
result_data = await client.update_application_env(uuid, data)
result = {
"success": True,
"message": f"Env var '{key}' updated",
"data": result_data,
}
return json.dumps(result, indent=2, ensure_ascii=False)
async def update_application_envs_bulk(client: CoolifyClient, uuid: str, data: list[dict]) -> str:
"""Bulk update application environment variables."""
result_data = await client.update_application_envs_bulk(uuid, data)
result = {
"success": True,
"message": f"Bulk update {len(data)} env vars",
"data": result_data,
}
return json.dumps(result, indent=2, ensure_ascii=False)
async def delete_application_env(client: CoolifyClient, uuid: str, env_uuid: str) -> str:
"""Delete application environment variable."""
await client.delete_application_env(uuid, env_uuid)
result = {"success": True, "message": f"Env var '{env_uuid}' deleted"}
return json.dumps(result, indent=2, ensure_ascii=False)

View File

@@ -0,0 +1,420 @@
"""Database Handler — manages Coolify databases, lifecycle, and backups."""
import json
from typing import Any
from plugins.coolify.client import CoolifyClient
def _create_db_spec(db_type: str, description: str) -> dict[str, Any]:
"""Generate a create_* tool spec for a database type."""
return {
"name": f"create_{db_type}",
"category": "crud",
"method_name": f"create_{db_type}",
"description": description,
"schema": {
"type": "object",
"properties": {
"project_uuid": {
"type": "string",
"description": "Project UUID",
"minLength": 1,
},
"server_uuid": {
"type": "string",
"description": "Server UUID",
"minLength": 1,
},
"environment_name": {
"type": "string",
"description": "Environment name (e.g., 'production')",
"minLength": 1,
},
"name": {
"anyOf": [{"type": "string"}, {"type": "null"}],
"description": "Database name",
},
"description": {
"anyOf": [{"type": "string"}, {"type": "null"}],
"description": "Database description",
},
"instant_deploy": {
"type": "boolean",
"description": "Deploy immediately after creation",
"default": False,
},
},
"required": ["project_uuid", "server_uuid", "environment_name"],
},
"scope": "write",
}
def get_tool_specifications() -> list[dict[str, Any]]:
"""Return tool specifications for ToolGenerator."""
specs = [
{
"name": "list_databases",
"category": "read",
"method_name": "list_databases",
"description": "List all Coolify databases.",
"schema": {
"type": "object",
"properties": {},
},
"scope": "read",
},
{
"name": "get_database",
"category": "read_sensitive",
"sensitivity": "sensitive",
"method_name": "get_database",
"description": "Get details of a specific Coolify database by UUID.",
"schema": {
"type": "object",
"properties": {
"uuid": {
"type": "string",
"description": "Database UUID",
"minLength": 1,
},
},
"required": ["uuid"],
},
"scope": "read",
},
{
"name": "update_database",
"category": "crud",
"method_name": "update_database",
"description": "Update a Coolify database settings.",
"schema": {
"type": "object",
"properties": {
"uuid": {
"type": "string",
"description": "Database UUID",
"minLength": 1,
},
"name": {
"anyOf": [{"type": "string"}, {"type": "null"}],
"description": "Database name",
},
"description": {
"anyOf": [{"type": "string"}, {"type": "null"}],
"description": "Database description",
},
"image": {
"anyOf": [{"type": "string"}, {"type": "null"}],
"description": "Docker image",
},
"is_public": {
"anyOf": [{"type": "boolean"}, {"type": "null"}],
"description": "Make database publicly accessible",
},
"public_port": {
"anyOf": [{"type": "integer"}, {"type": "null"}],
"description": "Public port number",
},
},
"required": ["uuid"],
},
"scope": "write",
},
{
"name": "delete_database",
"category": "system",
"method_name": "delete_database",
"description": (
"Delete a Coolify database permanently. "
"Optionally clean up volumes and Docker resources."
),
"schema": {
"type": "object",
"properties": {
"uuid": {
"type": "string",
"description": "Database UUID",
"minLength": 1,
},
"delete_configurations": {
"type": "boolean",
"description": "Delete configurations",
"default": True,
},
"delete_volumes": {
"type": "boolean",
"description": "Delete volumes",
"default": True,
},
"docker_cleanup": {
"type": "boolean",
"description": "Run Docker cleanup",
"default": True,
},
},
"required": ["uuid"],
},
"scope": "admin",
},
{
"name": "start_database",
"category": "lifecycle",
"method_name": "start_database",
"description": "Start a Coolify database.",
"schema": {
"type": "object",
"properties": {
"uuid": {
"type": "string",
"description": "Database UUID",
"minLength": 1,
},
},
"required": ["uuid"],
},
"scope": "write",
},
{
"name": "stop_database",
"category": "lifecycle",
"method_name": "stop_database",
"description": "Stop a running Coolify database.",
"schema": {
"type": "object",
"properties": {
"uuid": {
"type": "string",
"description": "Database UUID",
"minLength": 1,
},
},
"required": ["uuid"],
},
"scope": "write",
},
{
"name": "restart_database",
"category": "lifecycle",
"method_name": "restart_database",
"description": "Restart a Coolify database.",
"schema": {
"type": "object",
"properties": {
"uuid": {
"type": "string",
"description": "Database UUID",
"minLength": 1,
},
},
"required": ["uuid"],
},
"scope": "write",
},
]
# Add create specs for each database type
db_types = [
("postgresql", "Create a PostgreSQL database on Coolify."),
("mysql", "Create a MySQL database on Coolify."),
("mariadb", "Create a MariaDB database on Coolify."),
("mongodb", "Create a MongoDB database on Coolify."),
("redis", "Create a Redis database on Coolify."),
("clickhouse", "Create a ClickHouse database on Coolify."),
]
for db_type, desc in db_types:
specs.append(_create_db_spec(db_type, desc))
# Backup tools
specs.extend(
[
{
"name": "get_database_backups",
"category": "backup",
"sensitivity": "sensitive",
"method_name": "get_database_backups",
"description": "Get backup configuration and history for a Coolify database.",
"schema": {
"type": "object",
"properties": {
"uuid": {
"type": "string",
"description": "Database UUID",
"minLength": 1,
},
},
"required": ["uuid"],
},
"scope": "read",
},
{
"name": "create_database_backup",
"category": "backup",
"sensitivity": "sensitive",
"method_name": "create_database_backup",
"description": "Create a manual backup of a Coolify database.",
"schema": {
"type": "object",
"properties": {
"uuid": {
"type": "string",
"description": "Database UUID",
"minLength": 1,
},
},
"required": ["uuid"],
},
"scope": "write",
},
{
"name": "list_backup_executions",
"category": "backup",
"sensitivity": "sensitive",
"method_name": "list_backup_executions",
"description": "List all backup executions across all databases.",
"schema": {
"type": "object",
"properties": {},
},
"scope": "read",
},
]
)
return specs
# --- Handler Functions ---
async def list_databases(client: CoolifyClient) -> str:
"""List all databases."""
databases = await client.list_databases()
result = {"success": True, "count": len(databases), "databases": databases}
return json.dumps(result, indent=2, ensure_ascii=False)
async def get_database(client: CoolifyClient, uuid: str) -> str:
"""Get database details."""
db = await client.get_database(uuid)
result = {"success": True, "database": db}
return json.dumps(result, indent=2, ensure_ascii=False)
async def update_database(client: CoolifyClient, uuid: str, **kwargs) -> str:
"""Update database settings."""
data = {k: v for k, v in kwargs.items() if v is not None and k != "uuid"}
db = await client.update_database(uuid, data)
result = {
"success": True,
"message": f"Database '{uuid}' updated successfully",
"database": db,
}
return json.dumps(result, indent=2, ensure_ascii=False)
async def delete_database(
client: CoolifyClient,
uuid: str,
delete_configurations: bool = True,
delete_volumes: bool = True,
docker_cleanup: bool = True,
) -> str:
"""Delete a database."""
result_data = await client.delete_database(
uuid,
delete_configurations=delete_configurations,
delete_volumes=delete_volumes,
docker_cleanup=docker_cleanup,
)
result = {"success": True, "message": f"Database '{uuid}' deleted", "data": result_data}
return json.dumps(result, indent=2, ensure_ascii=False)
async def start_database(client: CoolifyClient, uuid: str) -> str:
"""Start a database."""
result_data = await client.start_database(uuid)
result = {"success": True, "message": f"Database '{uuid}' starting", "data": result_data}
return json.dumps(result, indent=2, ensure_ascii=False)
async def stop_database(client: CoolifyClient, uuid: str) -> str:
"""Stop a database."""
result_data = await client.stop_database(uuid)
result = {"success": True, "message": f"Database '{uuid}' stopping", "data": result_data}
return json.dumps(result, indent=2, ensure_ascii=False)
async def restart_database(client: CoolifyClient, uuid: str) -> str:
"""Restart a database."""
result_data = await client.restart_database(uuid)
result = {"success": True, "message": f"Database '{uuid}' restarting", "data": result_data}
return json.dumps(result, indent=2, ensure_ascii=False)
async def _create_database(client: CoolifyClient, db_type: str, **kwargs) -> str:
"""Create a database of given type."""
data = {k: v for k, v in kwargs.items() if v is not None}
db = await client.create_database(db_type, data)
result = {
"success": True,
"message": f"{db_type.title()} database created successfully",
"database": db,
}
return json.dumps(result, indent=2, ensure_ascii=False)
async def create_postgresql(client: CoolifyClient, **kwargs) -> str:
"""Create a PostgreSQL database."""
return await _create_database(client, "postgresql", **kwargs)
async def create_mysql(client: CoolifyClient, **kwargs) -> str:
"""Create a MySQL database."""
return await _create_database(client, "mysql", **kwargs)
async def create_mariadb(client: CoolifyClient, **kwargs) -> str:
"""Create a MariaDB database."""
return await _create_database(client, "mariadb", **kwargs)
async def create_mongodb(client: CoolifyClient, **kwargs) -> str:
"""Create a MongoDB database."""
return await _create_database(client, "mongodb", **kwargs)
async def create_redis(client: CoolifyClient, **kwargs) -> str:
"""Create a Redis database."""
return await _create_database(client, "redis", **kwargs)
async def create_clickhouse(client: CoolifyClient, **kwargs) -> str:
"""Create a ClickHouse database."""
return await _create_database(client, "clickhouse", **kwargs)
async def get_database_backups(client: CoolifyClient, uuid: str) -> str:
"""Get database backups."""
backups = await client.get_database_backups(uuid)
result = {"success": True, "backups": backups}
return json.dumps(result, indent=2, ensure_ascii=False)
async def create_database_backup(client: CoolifyClient, uuid: str) -> str:
"""Create a manual database backup."""
result_data = await client.create_database_backup(uuid)
result = {
"success": True,
"message": f"Backup created for database '{uuid}'",
"data": result_data,
}
return json.dumps(result, indent=2, ensure_ascii=False)
async def list_backup_executions(client: CoolifyClient) -> str:
"""List all backup executions."""
executions = await client.list_backup_executions()
result = {"success": True, "count": len(executions), "executions": executions}
return json.dumps(result, indent=2, ensure_ascii=False)

Some files were not shown because too many files have changed in this diff Show More