Initial commit: MCP Hub Community Edition v3.0.0
Community edition generated from private repo via sync pipeline. Includes 9 plugins (WordPress, WooCommerce, WP Advanced, Gitea, n8n, Supabase, OpenPanel, Appwrite, Directus) with ~587 tools. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
69
.dockerignore
Normal file
69
.dockerignore
Normal file
@@ -0,0 +1,69 @@
|
||||
# Git
|
||||
.git
|
||||
.gitignore
|
||||
.gitattributes
|
||||
|
||||
# Python
|
||||
__pycache__
|
||||
*.py[cod]
|
||||
*$py.class
|
||||
*.so
|
||||
.Python
|
||||
build/
|
||||
develop-eggs/
|
||||
dist/
|
||||
downloads/
|
||||
eggs/
|
||||
.eggs/
|
||||
lib/
|
||||
lib64/
|
||||
parts/
|
||||
sdist/
|
||||
var/
|
||||
wheels/
|
||||
*.egg-info/
|
||||
.installed.cfg
|
||||
*.egg
|
||||
MANIFEST
|
||||
.pytest_cache/
|
||||
.coverage
|
||||
htmlcov/
|
||||
.tox/
|
||||
.venv
|
||||
venv/
|
||||
ENV/
|
||||
env/
|
||||
|
||||
# IDE
|
||||
.vscode/
|
||||
.idea/
|
||||
*.swp
|
||||
*.swo
|
||||
*~
|
||||
.DS_Store
|
||||
|
||||
# Environment
|
||||
.env
|
||||
.env.local
|
||||
.env.*.local
|
||||
|
||||
# Documentation
|
||||
*.md
|
||||
docs/
|
||||
README*
|
||||
|
||||
# Tests
|
||||
tests/
|
||||
test_*.py
|
||||
*_test.py
|
||||
|
||||
# CI/CD
|
||||
.github/
|
||||
.gitlab-ci.yml
|
||||
.travis.yml
|
||||
|
||||
# Misc
|
||||
*.log
|
||||
.cache/
|
||||
tmp/
|
||||
temp/
|
||||
816
.env.example
Normal file
816
.env.example
Normal file
@@ -0,0 +1,816 @@
|
||||
# ==============================================================================
|
||||
# MCP Hub - Environment Configuration
|
||||
# ==============================================================================
|
||||
#
|
||||
# Version: 3.0.0
|
||||
# Last Updated: 2026-02-17
|
||||
#
|
||||
# This file contains all environment variables needed to run the MCP server.
|
||||
# Copy this file to .env and fill in your actual values.
|
||||
#
|
||||
# SECURITY NOTE: Never commit .env file to version control!
|
||||
#
|
||||
# Multi-Endpoint Architecture (v3.0.0):
|
||||
# - /mcp → Admin (all 587 tools, Master API Key required)
|
||||
# - /system/mcp → System (17 tools, Master Key)
|
||||
# - /wordpress/mcp → WordPress Core (65 tools)
|
||||
# - /woocommerce/mcp → WooCommerce (28 tools)
|
||||
# - /wordpress-advanced/mcp → WordPress Advanced (22 tools)
|
||||
# - /gitea/mcp → Gitea (56 tools)
|
||||
# - /n8n/mcp → n8n Automation (56 tools)
|
||||
# - /supabase/mcp → Supabase Self-Hosted (70 tools)
|
||||
# - /openpanel/mcp → OpenPanel Analytics (73 tools)
|
||||
# - /appwrite/mcp → Appwrite Backend (100 tools)
|
||||
# - /directus/mcp → Directus CMS (100 tools)
|
||||
# - /project/{alias}/mcp → Project-specific (site-locked tools)
|
||||
#
|
||||
# For deployment guide, see: docs/DEPLOYMENT_GUIDE.md
|
||||
# For testing guide, see: docs/TESTING_GUIDE.md
|
||||
# ==============================================================================
|
||||
|
||||
# ==============================================================================
|
||||
# AUTHENTICATION
|
||||
# ==============================================================================
|
||||
|
||||
# Master API Key (required)
|
||||
# This key has full access to all projects and admin operations.
|
||||
# Generate a secure random key (32+ characters recommended):
|
||||
# python -c "import secrets; print(secrets.token_urlsafe(32))"
|
||||
MASTER_API_KEY=your_secure_master_key_here
|
||||
|
||||
# API Keys Storage Path (optional)
|
||||
# Where to store per-project API keys JSON file
|
||||
# Default: data/api_keys.json
|
||||
#API_KEYS_STORAGE=data/api_keys.json
|
||||
|
||||
# ==============================================================================
|
||||
# OAUTH 2.1 CONFIGURATION
|
||||
# ==============================================================================
|
||||
|
||||
# OAuth JWT Secret Key (required for OAuth)
|
||||
# Used to sign and verify JWT access tokens
|
||||
# Generate a secure random key:
|
||||
# python -c "import secrets; print(secrets.token_urlsafe(64))"
|
||||
# OR: openssl rand -base64 64
|
||||
#OAUTH_JWT_SECRET_KEY=your_jwt_secret_key_here
|
||||
|
||||
# Dashboard Session Secret (recommended)
|
||||
# Used for signing dashboard session cookies
|
||||
# If not set, falls back to OAUTH_JWT_SECRET_KEY, then generates a random key (lost on restart)
|
||||
# Generate: python -c "import secrets; print(secrets.token_hex(32))"
|
||||
#DASHBOARD_SESSION_SECRET=your_dashboard_session_secret_here
|
||||
|
||||
# OAuth JWT Algorithm (optional)
|
||||
# Algorithm for signing JWTs
|
||||
# Options: HS256 (default), HS384, HS512, RS256, RS384, RS512
|
||||
# Default: HS256
|
||||
#OAUTH_JWT_ALGORITHM=HS256
|
||||
|
||||
# OAuth Access Token TTL (optional)
|
||||
# How long access tokens are valid (in seconds)
|
||||
# Default: 3600 (1 hour)
|
||||
#OAUTH_ACCESS_TOKEN_TTL=3600
|
||||
|
||||
# OAuth Refresh Token TTL (optional)
|
||||
# How long refresh tokens are valid (in seconds)
|
||||
# Default: 604800 (7 days)
|
||||
#OAUTH_REFRESH_TOKEN_TTL=604800
|
||||
|
||||
# OAuth Storage Configuration (optional)
|
||||
# Where to store OAuth tokens and authorization codes
|
||||
# Type: json (default) | redis (future)
|
||||
# Default: json
|
||||
#OAUTH_STORAGE_TYPE=json
|
||||
#OAUTH_STORAGE_PATH=/app/data
|
||||
|
||||
# OAuth Base URL (optional)
|
||||
# Used for reverse proxy/Coolify deployments
|
||||
# If not set, will be auto-detected from X-Forwarded headers
|
||||
#OAUTH_BASE_URL=https://mcp.example.com
|
||||
|
||||
# ==============================================================================
|
||||
# OAUTH AUTHORIZATION SECURITY
|
||||
# ==============================================================================
|
||||
#
|
||||
# Controls how OAuth authorization endpoint validates users
|
||||
#
|
||||
# Options:
|
||||
#
|
||||
# 1. required (Recommended for Production) 🔒
|
||||
# - API Key is ALWAYS required in authorization URL
|
||||
# - OAuth tokens inherit API Key's scope and project access
|
||||
# - Use: Production environments with custom OAuth clients
|
||||
# - Example: ?api_key=cmp_xxx is mandatory
|
||||
#
|
||||
# 2. optional (ChatGPT OAuth Manual) ⚠️
|
||||
# - API Key is optional in authorization URL
|
||||
# - If provided: OAuth token inherits API Key permissions
|
||||
# - If missing: OAuth token has full access
|
||||
# - Use: ChatGPT OAuth (manual) integration only
|
||||
# - ⚠️ SECURITY WARNING: Anyone with authorization URL can connect
|
||||
# - 🔒 MITIGATION: Use minimal scopes (e.g., "read" only) when registering client
|
||||
#
|
||||
# Note: trusted_domains mode is DEPRECATED - no longer needed with OAuth (manual)
|
||||
#
|
||||
# Default for Production: required
|
||||
# For ChatGPT OAuth (manual): optional
|
||||
OAUTH_AUTH_MODE=required
|
||||
|
||||
# ⚠️ For ChatGPT Integration: Uncomment below and use minimal scopes
|
||||
# OAUTH_AUTH_MODE=optional
|
||||
|
||||
# OAuth Trusted Domains (DEPRECATED)
|
||||
# This setting is no longer needed with OAuth (manual) integration
|
||||
# API Key is always required regardless of domain
|
||||
# OAUTH_TRUSTED_DOMAINS=chatgpt.com,chat.openai.com,openai.com,platform.openai.com
|
||||
|
||||
# ==============================================================================
|
||||
# OAUTH SECURITY NOTES (Updated for OAuth Manual)
|
||||
# ==============================================================================
|
||||
#
|
||||
# Security Model (Updated):
|
||||
# ─────────────────────────
|
||||
# - Client Registration (/oauth/register): Master API Key required (protected)
|
||||
# - Authorization (/oauth/authorize): API Key ALWAYS required (OAUTH_AUTH_MODE=required)
|
||||
# - Token Exchange (/oauth/token): Requires client_id + client_secret
|
||||
#
|
||||
# ChatGPT OAuth (manual) Integration:
|
||||
# ───────────────────────────────────
|
||||
# 1. Admin registers OAuth client with Master API Key
|
||||
# 2. Admin configures ChatGPT with client_id + client_secret (OAuth manual)
|
||||
# 3. Users authorize with their personal API Key
|
||||
# 4. OAuth token inherits user's API Key permissions
|
||||
#
|
||||
# Recommended Settings:
|
||||
# ────────────────────
|
||||
# Development: OAUTH_AUTH_MODE=optional (testing only)
|
||||
# Production: OAUTH_AUTH_MODE=required (recommended)
|
||||
#
|
||||
# Permission Inheritance:
|
||||
# ──────────────────────
|
||||
# OAuth tokens inherit the authorizing API Key's permissions:
|
||||
# - Master API Key → OAuth token with full access
|
||||
# - Per-project API Key → OAuth token limited to that project
|
||||
# - Read-only API Key → OAuth token with read-only scope
|
||||
#
|
||||
# Security: Users control their own access via API Keys
|
||||
#
|
||||
# ==============================================================================
|
||||
|
||||
# ==============================================================================
|
||||
# WORDPRESS PROJECTS
|
||||
# ==============================================================================
|
||||
#
|
||||
# Format: WORDPRESS_{SITE_ID}_{CONFIG_KEY}=value
|
||||
# Required keys: URL, USERNAME, APP_PASSWORD
|
||||
# Optional keys: ALIAS, CONTAINER
|
||||
#
|
||||
# Example: Configure wordpress_site1
|
||||
#
|
||||
|
||||
# Site 1 Configuration
|
||||
WORDPRESS_SITE1_URL=https://example1.com
|
||||
WORDPRESS_SITE1_USERNAME=admin
|
||||
WORDPRESS_SITE1_APP_PASSWORD=your_app_password_here
|
||||
WORDPRESS_SITE1_ALIAS=myblog
|
||||
WORDPRESS_SITE1_CONTAINER=coolify-wp-site1 # For WP-CLI access (optional)
|
||||
|
||||
# Site 2 Configuration
|
||||
#WORDPRESS_SITE2_URL=https://example2.com
|
||||
#WORDPRESS_SITE2_USERNAME=admin
|
||||
#WORDPRESS_SITE2_APP_PASSWORD=your_app_password_here
|
||||
#WORDPRESS_SITE2_ALIAS=mystore
|
||||
#WORDPRESS_SITE2_CONTAINER=coolify-wp-site2
|
||||
|
||||
# Site 3 Configuration
|
||||
#WORDPRESS_SITE3_URL=https://example3.com
|
||||
#WORDPRESS_SITE3_USERNAME=admin
|
||||
#WORDPRESS_SITE3_APP_PASSWORD=your_app_password_here
|
||||
|
||||
# Add more sites as needed (wordpress_site4, wordpress_site5, etc.)
|
||||
|
||||
# ==============================================================================
|
||||
# WOOCOMMERCE PLUGIN
|
||||
# ==============================================================================
|
||||
#
|
||||
# WooCommerce provides 28 e-commerce tools:
|
||||
# - Products (12 tools): CRUD, categories, tags, attributes, variations
|
||||
# - Orders (5 tools): list, get, create, update_status, delete
|
||||
# - Customers (4 tools): list, get, create, update
|
||||
# - Coupons (4 tools): list, create, update, delete
|
||||
# - Reports (3 tools): sales, top_sellers, customer_report
|
||||
#
|
||||
# ⭐ IMPORTANT: WooCommerce uses the SAME site configurations as WordPress!
|
||||
# You don't need to configure separate WOOCOMMERCE_* environment variables.
|
||||
# Simply configure your WordPress sites above, and WooCommerce tools will
|
||||
# automatically work with those same sites.
|
||||
#
|
||||
# Example:
|
||||
# - Configure WORDPRESS_SITE1_URL, WORDPRESS_SITE1_USERNAME, etc.
|
||||
# - WooCommerce tools will work with site1 if WooCommerce is installed
|
||||
#
|
||||
# Endpoint: /woocommerce/mcp
|
||||
# Tools are prefixed with "woocommerce_" (e.g., woocommerce_list_products)
|
||||
#
|
||||
# ==============================================================================
|
||||
|
||||
# ==============================================================================
|
||||
# WORDPRESS ADVANCED PLUGIN
|
||||
# ==============================================================================
|
||||
#
|
||||
# WordPress Advanced provides 22 advanced management tools:
|
||||
# - Database operations (7 tools): export, import, search, query, repair
|
||||
# - Bulk operations (8 tools): batch updates/deletes for posts, products, media
|
||||
# - System operations (7 tools): system info, cache, cron, error logs
|
||||
#
|
||||
# REQUIRED: Docker container name for WP-CLI access
|
||||
# NOTE: WordPress Advanced has the SAME configuration pattern as WordPress
|
||||
# but requires 'container' configuration for WP-CLI access.
|
||||
#
|
||||
# Security Benefits:
|
||||
# - Separate API keys for advanced vs basic operations
|
||||
# - Better tool visibility (basic users don't see advanced tools)
|
||||
# - Granular access control
|
||||
#
|
||||
|
||||
# Site 1 Advanced Configuration
|
||||
WORDPRESS_ADVANCED_SITE1_URL=https://example1.com
|
||||
WORDPRESS_ADVANCED_SITE1_USERNAME=admin
|
||||
WORDPRESS_ADVANCED_SITE1_APP_PASSWORD=your_app_password_here
|
||||
WORDPRESS_ADVANCED_SITE1_ALIAS=myblog
|
||||
WORDPRESS_ADVANCED_SITE1_CONTAINER=coolify-wp-site1 # REQUIRED for advanced features
|
||||
|
||||
# Site 2 Advanced Configuration
|
||||
#WORDPRESS_ADVANCED_SITE2_URL=https://example2.com
|
||||
#WORDPRESS_ADVANCED_SITE2_USERNAME=admin
|
||||
#WORDPRESS_ADVANCED_SITE2_APP_PASSWORD=your_app_password_here
|
||||
#WORDPRESS_ADVANCED_SITE2_ALIAS=mystore
|
||||
#WORDPRESS_ADVANCED_SITE2_CONTAINER=coolify-wp-site2
|
||||
|
||||
# Add more sites as needed (wordpress_advanced_site3, wordpress_advanced_site4, etc.)
|
||||
|
||||
# ==============================================================================
|
||||
# LOGGING & MONITORING
|
||||
# ==============================================================================
|
||||
|
||||
# Log Level
|
||||
# Options: DEBUG, INFO, WARNING, ERROR, CRITICAL
|
||||
# Default: INFO
|
||||
LOG_LEVEL=INFO
|
||||
|
||||
# Audit Log Configuration
|
||||
# GDPR-compliant JSON logging for all tool calls
|
||||
AUDIT_LOG_RETENTION_DAYS=90 # How long to keep audit logs
|
||||
AUDIT_LOG_PATH=logs/audit.log # Path to audit log file
|
||||
AUDIT_LOG_MAX_SIZE_MB=10 # Max file size before rotation
|
||||
AUDIT_LOG_BACKUP_COUNT=5 # Number of backup files to keep
|
||||
|
||||
# ==============================================================================
|
||||
# HEALTH MONITORING
|
||||
# ==============================================================================
|
||||
|
||||
# Metrics Retention
|
||||
METRICS_RETENTION_HOURS=24 # How long to keep metrics in memory
|
||||
|
||||
# Alert Thresholds
|
||||
HEALTH_ALERT_RESPONSE_TIME_MS=5000 # Alert if response time > 5 seconds
|
||||
HEALTH_ALERT_ERROR_RATE_PERCENT=10 # Alert if error rate > 10%
|
||||
|
||||
# ==============================================================================
|
||||
# RATE LIMITING
|
||||
# ==============================================================================
|
||||
|
||||
# Rate Limits (per client)
|
||||
RATE_LIMIT_PER_MINUTE=60 # Max requests per minute
|
||||
RATE_LIMIT_PER_HOUR=1000 # Max requests per hour
|
||||
RATE_LIMIT_PER_DAY=10000 # Max requests per day
|
||||
|
||||
# Rate Limit Window Sizes (in seconds)
|
||||
#RATE_LIMIT_WINDOW_MINUTE=60
|
||||
#RATE_LIMIT_WINDOW_HOUR=3600
|
||||
#RATE_LIMIT_WINDOW_DAY=86400
|
||||
|
||||
# ==============================================================================
|
||||
# SERVER CONFIGURATION
|
||||
# ==============================================================================
|
||||
|
||||
# Server Host & Port (for SSE transport)
|
||||
#MCP_HOST=0.0.0.0
|
||||
#MCP_PORT=8000
|
||||
|
||||
# Transport Protocol
|
||||
# Options: stdio (Claude Desktop), sse (HTTP server)
|
||||
#MCP_TRANSPORT=sse
|
||||
|
||||
# Multi-Endpoint Configuration
|
||||
# Enable/disable multi-endpoint architecture
|
||||
# Default: true
|
||||
MULTI_ENDPOINT=true
|
||||
|
||||
# ==============================================================================
|
||||
# GITEA PROJECTS
|
||||
# ==============================================================================
|
||||
#
|
||||
# Format: GITEA_{SITE_ID}_{CONFIG_KEY}=value
|
||||
# Required keys: URL
|
||||
# Optional keys: TOKEN, ALIAS, OAUTH_ENABLED
|
||||
#
|
||||
# NOTE: Token is optional - can use OAuth instead (recommended for ChatGPT)
|
||||
#
|
||||
# Example: Configure gitea_site1 with token authentication
|
||||
|
||||
# Site 1 Configuration (with token)
|
||||
GITEA_SITE1_URL=https://gitea.example.com
|
||||
GITEA_SITE1_TOKEN=your_gitea_personal_access_token_here
|
||||
GITEA_SITE1_ALIAS=mygitea
|
||||
#GITEA_SITE1_OAUTH_ENABLED=false # Use token instead of OAuth
|
||||
|
||||
# Site 2 Configuration (with OAuth - for ChatGPT integration)
|
||||
#GITEA_SITE2_URL=https://gitea.example.com
|
||||
#GITEA_SITE2_ALIAS=workgitea
|
||||
#GITEA_SITE2_OAUTH_ENABLED=true # Use OAuth instead of token
|
||||
# Note: When using OAuth, TOKEN can be omitted
|
||||
|
||||
# Site 3 Configuration
|
||||
#GITEA_SITE3_URL=https://git.company.com
|
||||
#GITEA_SITE3_TOKEN=your_token_here
|
||||
#GITEA_SITE3_ALIAS=companygit
|
||||
|
||||
# Add more sites as needed (gitea_site4, gitea_site5, etc.)
|
||||
|
||||
# ==============================================================================
|
||||
# GITEA PERSONAL ACCESS TOKEN GENERATION
|
||||
# ==============================================================================
|
||||
#
|
||||
# To generate a Personal Access Token for Gitea:
|
||||
#
|
||||
# 1. Log in to your Gitea instance
|
||||
# 2. Go to: Settings → Applications → Generate New Token
|
||||
# 3. Enter a token name (e.g., "MCP Server")
|
||||
# 4. Select permissions (recommended: repo, write:org, read:user, write:issue)
|
||||
# 5. Click "Generate Token"
|
||||
# 6. Copy the token (it will only be shown once!)
|
||||
# 7. Use it as GITEA_SITEX_TOKEN value
|
||||
#
|
||||
# Recommended Permissions:
|
||||
# - repo (all): Full repository access
|
||||
# - write:org: Manage organizations and teams
|
||||
# - read:user: Read user information
|
||||
# - write:issue: Create and edit issues/PRs
|
||||
#
|
||||
# ==============================================================================
|
||||
|
||||
# ==============================================================================
|
||||
# N8N AUTOMATION PLUGIN
|
||||
# ==============================================================================
|
||||
#
|
||||
# n8n provides 56 workflow automation tools:
|
||||
# - Workflows (14 tools): CRUD, activate, execute, duplicate, export/import
|
||||
# - Executions (8 tools): list, get, delete, stop, retry, wait
|
||||
# - Credentials (5 tools): get, create, delete, schema, transfer [Enterprise]
|
||||
# - Tags (6 tools): CRUD + bulk delete
|
||||
# - Users (5 tools): list, get, create, delete, change_role
|
||||
# - Projects (8 tools): project management [Enterprise]
|
||||
# - Variables (6 tools): environment variables [Enterprise]
|
||||
# - System (4 tools): audit, source control, health, info
|
||||
#
|
||||
# Format: N8N_{SITE_ID}_{CONFIG_KEY}=value
|
||||
# Required keys: URL, API_KEY
|
||||
# Optional keys: ALIAS
|
||||
#
|
||||
|
||||
# Site 1 Configuration
|
||||
N8N_SITE1_URL=https://n8n.example.com
|
||||
N8N_SITE1_API_KEY=your_n8n_api_key_here
|
||||
N8N_SITE1_ALIAS=automation
|
||||
|
||||
# Site 2 Configuration
|
||||
#N8N_SITE2_URL=https://n8n-staging.example.com
|
||||
#N8N_SITE2_API_KEY=your_n8n_api_key_here
|
||||
#N8N_SITE2_ALIAS=staging-automation
|
||||
|
||||
# Add more sites as needed (n8n_site3, n8n_site4, etc.)
|
||||
|
||||
# ==============================================================================
|
||||
# N8N API KEY GENERATION
|
||||
# ==============================================================================
|
||||
#
|
||||
# To generate an API Key for n8n:
|
||||
#
|
||||
# 1. Log in to your n8n instance
|
||||
# 2. Go to: Settings → API → Create an API Key
|
||||
# 3. Enter a label (e.g., "MCP Server")
|
||||
# 4. Copy the generated key (it will only be shown once!)
|
||||
# 5. Use it as N8N_SITEX_API_KEY value
|
||||
#
|
||||
# Note: n8n API requires n8n version 0.215.0 or later
|
||||
# Some features (Projects, Variables, Source Control) require Enterprise license
|
||||
#
|
||||
# ==============================================================================
|
||||
|
||||
# ==============================================================================
|
||||
# SUPABASE PLUGIN (Self-Hosted)
|
||||
# ==============================================================================
|
||||
#
|
||||
# Supabase Self-Hosted provides 70 tools:
|
||||
# - Database (18 tools): PostgREST CRUD, SQL, bulk operations
|
||||
# - Auth (14 tools): GoTrue user management, MFA, invitations
|
||||
# - Storage (12 tools): buckets, files, upload/download
|
||||
# - Functions (8 tools): Edge Functions invoke/deploy
|
||||
# - Admin (12 tools): postgres-meta DB administration
|
||||
# - System (6 tools): health, config, stats
|
||||
#
|
||||
# ⚠️ NOTE: This is for SELF-HOSTED Supabase on Coolify, NOT Supabase Cloud!
|
||||
# Management API (projects, organizations, branches) is NOT available in self-hosted.
|
||||
#
|
||||
# Format: SUPABASE_{SITE_ID}_{CONFIG_KEY}=value
|
||||
# Required keys: URL, ANON_KEY, SERVICE_ROLE_KEY
|
||||
# Optional keys: ALIAS, DB_HOST, DB_PORT, DB_NAME, DB_USER, DB_PASSWORD
|
||||
#
|
||||
|
||||
# Site 1 Configuration (Self-Hosted Instance)
|
||||
SUPABASE_SITE1_URL=https://supabase.example.com
|
||||
SUPABASE_SITE1_ANON_KEY=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...
|
||||
SUPABASE_SITE1_SERVICE_ROLE_KEY=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...
|
||||
SUPABASE_SITE1_ALIAS=mysupabase
|
||||
|
||||
# Site 1 Direct DB Access (Optional - for postgres-meta admin operations)
|
||||
#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
|
||||
|
||||
# Site 2 Configuration (Another Self-Hosted Instance)
|
||||
#SUPABASE_SITE2_URL=https://supabase-staging.example.com
|
||||
#SUPABASE_SITE2_ANON_KEY=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...
|
||||
#SUPABASE_SITE2_SERVICE_ROLE_KEY=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...
|
||||
#SUPABASE_SITE2_ALIAS=staging
|
||||
|
||||
# Add more instances as needed (supabase_site3, supabase_site4, etc.)
|
||||
|
||||
# ==============================================================================
|
||||
# SUPABASE SELF-HOSTED CREDENTIALS
|
||||
# ==============================================================================
|
||||
#
|
||||
# Finding your credentials in Coolify:
|
||||
#
|
||||
# 1. URL:
|
||||
# - Coolify Dashboard → Project → Supabase → Domain
|
||||
# - Usually: https://supabase.yourdomain.com
|
||||
#
|
||||
# 2. ANON_KEY & SERVICE_ROLE_KEY:
|
||||
# - Coolify Dashboard → Project → Supabase → Environment Variables
|
||||
# - Or in your .env file used for Supabase deployment
|
||||
# - These are JWT tokens signed with JWT_SECRET
|
||||
#
|
||||
# 3. Kong API Gateway:
|
||||
# - All APIs accessed through single URL (Kong on port 8000)
|
||||
# - /rest/v1/ → Database (PostgREST)
|
||||
# - /auth/v1/ → Authentication (GoTrue)
|
||||
# - /storage/v1/ → File Storage
|
||||
# - /functions/v1/ → Edge Functions
|
||||
# - /pg/ → Database Admin (postgres-meta)
|
||||
#
|
||||
# Security Notes:
|
||||
# - ANON_KEY: Safe for client-side, protected by RLS policies
|
||||
# - SERVICE_ROLE_KEY: ⚠️ SERVER ONLY - bypasses ALL RLS policies!
|
||||
# - Never expose SERVICE_ROLE_KEY to clients
|
||||
#
|
||||
# ==============================================================================
|
||||
|
||||
# ==============================================================================
|
||||
# OPENPANEL ANALYTICS PLUGIN
|
||||
# ==============================================================================
|
||||
#
|
||||
# OpenPanel provides 73 product analytics tools:
|
||||
# - Core (25 tools): Events, Export, System
|
||||
# - Analytics (24 tools): Reports, Funnels, Profiles
|
||||
# - Management (24 tools): Projects, Dashboards, Clients
|
||||
#
|
||||
# Format: OPENPANEL_{SITE_ID}_{CONFIG_KEY}=value
|
||||
# Required keys: URL, CLIENT_ID, CLIENT_SECRET
|
||||
# Recommended keys: PROJECT_ID (required for Export/Read APIs)
|
||||
# Optional keys: ALIAS, ORGANIZATION_ID, SESSION_COOKIE
|
||||
#
|
||||
# Note: CLIENT_ID/CLIENT_SECRET are used for Track API authentication.
|
||||
# PROJECT_ID is the OpenPanel project ID for reading analytics data.
|
||||
# ORGANIZATION_ID is for multi-tenant setups (optional).
|
||||
# SESSION_COOKIE is for tRPC API access (analytics queries).
|
||||
# You can find PROJECT_ID in OpenPanel Dashboard → Project Settings.
|
||||
#
|
||||
# IMPORTANT: Self-hosted OpenPanel tRPC API requires session cookie authentication.
|
||||
# To get a session cookie:
|
||||
# 1. Login to your OpenPanel dashboard
|
||||
# 2. Open browser DevTools → Console
|
||||
# 3. Run: document.cookie (copy the "session" value)
|
||||
# 4. Add to SESSION_COOKIE below
|
||||
#
|
||||
# Without SESSION_COOKIE, analytics queries may fail with 401 Unauthorized.
|
||||
# Track API (event tracking) works with CLIENT_ID/CLIENT_SECRET.
|
||||
|
||||
# Site 1 Configuration
|
||||
OPENPANEL_SITE1_URL=https://analytics.example.com
|
||||
OPENPANEL_SITE1_CLIENT_ID=your_client_id_here
|
||||
OPENPANEL_SITE1_CLIENT_SECRET=your_client_secret_here
|
||||
OPENPANEL_SITE1_PROJECT_ID=your_project_id_here
|
||||
OPENPANEL_SITE1_ORGANIZATION_ID=your_org_id_here
|
||||
OPENPANEL_SITE1_SESSION_COOKIE=your_session_cookie_here
|
||||
OPENPANEL_SITE1_ALIAS=myanalytics
|
||||
|
||||
# Site 2 Configuration
|
||||
#OPENPANEL_SITE2_URL=https://analytics-staging.example.com
|
||||
#OPENPANEL_SITE2_CLIENT_ID=your_client_id_here
|
||||
#OPENPANEL_SITE2_CLIENT_SECRET=your_client_secret_here
|
||||
#OPENPANEL_SITE2_PROJECT_ID=your_project_id_here
|
||||
#OPENPANEL_SITE2_ALIAS=staging-analytics
|
||||
|
||||
# Add more sites as needed (openpanel_site3, openpanel_site4, etc.)
|
||||
|
||||
# ==============================================================================
|
||||
# APPWRITE PLUGIN
|
||||
# ==============================================================================
|
||||
#
|
||||
# Appwrite Self-Hosted provides 100 backend management tools:
|
||||
# - Databases (18 tools): databases, collections, attributes, indexes
|
||||
# - Documents (12 tools): CRUD, bulk ops, queries, full-text search
|
||||
# - Users (12 tools): user management, sessions, labels, status
|
||||
# - Teams (10 tools): teams, memberships, roles
|
||||
# - Storage (14 tools): buckets, files, image transformation
|
||||
# - Functions (14 tools): functions, deployments, executions
|
||||
# - Messaging (12 tools): topics, subscribers, email/SMS/push
|
||||
# - System (8 tools): health checks, avatars
|
||||
#
|
||||
# ⚠️ NOTE: This is for SELF-HOSTED Appwrite on Coolify!
|
||||
#
|
||||
# Format: APPWRITE_{SITE_ID}_{CONFIG_KEY}=value
|
||||
# Required keys: URL, PROJECT_ID, API_KEY
|
||||
# Optional keys: ALIAS
|
||||
#
|
||||
|
||||
# Site 1 Configuration (Self-Hosted Instance)
|
||||
APPWRITE_SITE1_URL=https://appwrite.example.com/v1
|
||||
APPWRITE_SITE1_PROJECT_ID=your_project_id_here
|
||||
APPWRITE_SITE1_API_KEY=your_api_key_here
|
||||
APPWRITE_SITE1_ALIAS=myappwrite
|
||||
|
||||
# Site 2 Configuration (Another Self-Hosted Instance)
|
||||
#APPWRITE_SITE2_URL=https://appwrite-staging.example.com/v1
|
||||
#APPWRITE_SITE2_PROJECT_ID=your_project_id_here
|
||||
#APPWRITE_SITE2_API_KEY=your_api_key_here
|
||||
#APPWRITE_SITE2_ALIAS=staging
|
||||
|
||||
# Add more instances as needed (appwrite_site3, appwrite_site4, etc.)
|
||||
|
||||
# ==============================================================================
|
||||
# APPWRITE SELF-HOSTED CREDENTIALS
|
||||
# ==============================================================================
|
||||
#
|
||||
# Finding your credentials in Coolify:
|
||||
#
|
||||
# 1. URL:
|
||||
# - Coolify Dashboard → Project → Appwrite → Domain
|
||||
# - Add /v1 to the URL: https://appwrite.yourdomain.com/v1
|
||||
#
|
||||
# 2. PROJECT_ID:
|
||||
# - Appwrite Console → Your Project → Settings → Project ID
|
||||
# - Or create a new project and copy its ID
|
||||
#
|
||||
# 3. API_KEY:
|
||||
# - Appwrite Console → Your Project → Settings → API Keys
|
||||
# - Create a new API Key with required scopes:
|
||||
# * databases.read, databases.write
|
||||
# * documents.read, documents.write
|
||||
# * users.read, users.write
|
||||
# * teams.read, teams.write
|
||||
# * files.read, files.write
|
||||
# * buckets.read, buckets.write
|
||||
# * functions.read, functions.write
|
||||
# * execution.read, execution.write
|
||||
# * messaging.read, messaging.write
|
||||
# * health.read
|
||||
#
|
||||
# Security Notes:
|
||||
# - API Keys have project-level access (no cross-project access)
|
||||
# - Set appropriate scopes for least-privilege access
|
||||
# - Rotate keys regularly using MCP tools
|
||||
#
|
||||
# ==============================================================================
|
||||
|
||||
# ==============================================================================
|
||||
# DIRECTUS CMS PLUGIN
|
||||
# ==============================================================================
|
||||
#
|
||||
# Directus Self-Hosted provides 100 headless CMS tools:
|
||||
# - Items (12 tools): CRUD, bulk ops, search, aggregation, import/export
|
||||
# - Collections (14 tools): collections, fields, relations management
|
||||
# - Files (12 tools): files, folders, import from URL
|
||||
# - Users (10 tools): user management, current user, invite
|
||||
# - Access (12 tools): roles, permissions, policies
|
||||
# - Automation (12 tools): flows, operations, webhooks
|
||||
# - Content (10 tools): revisions, versions, comments
|
||||
# - Dashboards (8 tools): dashboards, panels
|
||||
# - System (10 tools): settings, server info, schema, activity
|
||||
#
|
||||
# ⚠️ NOTE: This is for SELF-HOSTED Directus on Coolify!
|
||||
#
|
||||
# Format: DIRECTUS_{SITE_ID}_{CONFIG_KEY}=value
|
||||
# Required keys: URL, TOKEN
|
||||
# Optional keys: ALIAS
|
||||
#
|
||||
|
||||
# Site 1 Configuration (Self-Hosted Instance)
|
||||
DIRECTUS_SITE1_URL=https://directus.example.com
|
||||
DIRECTUS_SITE1_TOKEN=your_static_admin_token_here
|
||||
DIRECTUS_SITE1_ALIAS=mycms
|
||||
|
||||
# Site 2 Configuration (Another Self-Hosted Instance)
|
||||
#DIRECTUS_SITE2_URL=https://directus-staging.example.com
|
||||
#DIRECTUS_SITE2_TOKEN=your_static_admin_token_here
|
||||
#DIRECTUS_SITE2_ALIAS=staging
|
||||
|
||||
# Add more instances as needed (directus_site3, directus_site4, etc.)
|
||||
|
||||
# ==============================================================================
|
||||
# DIRECTUS SELF-HOSTED CREDENTIALS
|
||||
# ==============================================================================
|
||||
#
|
||||
# Finding/Creating your credentials:
|
||||
#
|
||||
# 1. URL:
|
||||
# - Coolify Dashboard → Project → Directus → Domain
|
||||
# - Usually: https://directus.yourdomain.com
|
||||
#
|
||||
# 2. Static Admin Token:
|
||||
# - Option A: Environment Variable
|
||||
# Set ADMIN_TOKEN in Directus environment (Coolify env vars)
|
||||
# This creates a static token for the admin user
|
||||
#
|
||||
# - Option B: Database Token
|
||||
# Connect to Directus database and create a token:
|
||||
# INSERT INTO directus_users (token, ...) VALUES ('your-token', ...);
|
||||
#
|
||||
# - Option C: Generate via Admin UI (temporary)
|
||||
# Directus Admin → Settings → Data Model → Users
|
||||
# Create/edit user → Token field
|
||||
#
|
||||
# Authentication Methods (in order of preference):
|
||||
# - Static Token: Best for server-to-server (MCP)
|
||||
# - Temporary Token: Login endpoint → expires
|
||||
# - Session Cookie: Browser only
|
||||
#
|
||||
# Directus REST API Endpoints:
|
||||
# - /items/{collection} → Collection data
|
||||
# - /collections → Schema management
|
||||
# - /fields → Field management
|
||||
# - /relations → Relationship management
|
||||
# - /files → Asset management
|
||||
# - /folders → Folder management
|
||||
# - /users → User management
|
||||
# - /roles → Role management
|
||||
# - /permissions → Permission rules
|
||||
# - /policies → Access policies
|
||||
# - /flows → Automation flows
|
||||
# - /operations → Flow operations
|
||||
# - /webhooks → Webhook triggers
|
||||
# - /activity → Activity log
|
||||
# - /revisions → Content history
|
||||
# - /versions → Content versions
|
||||
# - /comments → Item comments
|
||||
# - /dashboards → Insight dashboards
|
||||
# - /panels → Dashboard panels
|
||||
# - /settings → System settings
|
||||
# - /server → Server info
|
||||
# - /schema → Schema snapshot
|
||||
#
|
||||
# Security Notes:
|
||||
# - Static tokens never expire (use for automation)
|
||||
# - Tokens inherit user's role permissions
|
||||
# - Admin tokens have full access to all collections
|
||||
# - Create limited-scope users for restricted access
|
||||
#
|
||||
# ==============================================================================
|
||||
|
||||
# ==============================================================================
|
||||
# FUTURE PLUGINS
|
||||
# ==============================================================================
|
||||
|
||||
# Reserved for future plugins...
|
||||
|
||||
# ==============================================================================
|
||||
# WORDPRESS APP PASSWORD GENERATION
|
||||
# ==============================================================================
|
||||
#
|
||||
# To generate an Application Password for WordPress:
|
||||
#
|
||||
# 1. Log in to WordPress admin panel
|
||||
# 2. Go to: Users → Profile → Application Passwords
|
||||
# 3. Enter a name (e.g., "MCP Server")
|
||||
# 4. Click "Add New Application Password"
|
||||
# 5. Copy the generated password (it will only be shown once!)
|
||||
# 6. Use it as WORDPRESS_SITEX_APP_PASSWORD value
|
||||
#
|
||||
# Note: Application Passwords require WordPress 5.6+ with SSL/HTTPS
|
||||
#
|
||||
# ==============================================================================
|
||||
|
||||
# ==============================================================================
|
||||
# DOCKER-SPECIFIC CONFIGURATION
|
||||
# ==============================================================================
|
||||
|
||||
# If running in Docker container and need WP-CLI access:
|
||||
#
|
||||
# 1. Mount Docker socket in docker-compose.yaml:
|
||||
# volumes:
|
||||
# - /var/run/docker.sock:/var/run/docker.sock:ro
|
||||
#
|
||||
# 2. Add docker group to container:
|
||||
# group_add:
|
||||
# - "999" # Docker group ID (check with: getent group docker)
|
||||
#
|
||||
# 3. Set container name for each WordPress site:
|
||||
# WORDPRESS_SITE1_CONTAINER=actual_container_name
|
||||
#
|
||||
# Find container name: docker ps | grep wordpress
|
||||
#
|
||||
# ==============================================================================
|
||||
|
||||
# ==============================================================================
|
||||
# COOLIFY DEPLOYMENT
|
||||
# ==============================================================================
|
||||
#
|
||||
# When deploying to Coolify:
|
||||
#
|
||||
# 1. Repository: https://github.com/your-org/mcphub
|
||||
# 2. Build Pack: Docker Compose
|
||||
# 3. Port: 8000 (internal only, Coolify handles external routing)
|
||||
# 4. Health Check: GET /health (should return 200)
|
||||
# 5. Environment Variables: Add all variables from this file
|
||||
#
|
||||
# For detailed deployment guide, see: DEPLOYMENT_GUIDE.md
|
||||
#
|
||||
# ==============================================================================
|
||||
|
||||
# ==============================================================================
|
||||
# SECURITY BEST PRACTICES
|
||||
# ==============================================================================
|
||||
#
|
||||
# 1. Use strong, unique passwords (32+ characters)
|
||||
# 2. Rotate API keys regularly (use manage_api_keys_rotate tool)
|
||||
# 3. Use per-project API keys instead of master key when possible
|
||||
# 4. Enable HTTPS for all WordPress sites (required for App Passwords)
|
||||
# 5. Keep audit logs for compliance and security analysis
|
||||
# 6. Monitor rate limit statistics for unusual activity
|
||||
# 7. Review and revoke unused API keys periodically
|
||||
#
|
||||
# ==============================================================================
|
||||
|
||||
# ==============================================================================
|
||||
# API KEYS MANAGEMENT
|
||||
# ==============================================================================
|
||||
#
|
||||
# The MCP server supports two types of API keys:
|
||||
#
|
||||
# 1. MASTER_API_KEY (above):
|
||||
# - Full access to all projects and operations (scope: "read write admin")
|
||||
# - Used for server administration
|
||||
# - Should be kept highly secure
|
||||
#
|
||||
# 2. Per-Project API Keys (managed via MCP tools):
|
||||
# - Scoped access: single or multiple scopes
|
||||
# * "read" - Read-only access
|
||||
# * "write" - Read + Write access
|
||||
# * "admin" - Full access including dangerous operations
|
||||
# * "read write" - Read + Write (no admin)
|
||||
# * "read write admin" - All permissions (equivalent to master key for that project)
|
||||
# - Can be limited to specific projects or "*" for all projects
|
||||
# - Can have expiration dates
|
||||
# - Tracked usage and audit trail
|
||||
#
|
||||
# Create per-project keys using:
|
||||
# # Single scope
|
||||
# manage_api_keys_create(project_id="wordpress_site1", scope="read")
|
||||
#
|
||||
# # Multiple scopes (space-separated)
|
||||
# manage_api_keys_create(project_id="wordpress_site1", scope="read write")
|
||||
#
|
||||
# # All scopes for OAuth integration (ChatGPT, etc.)
|
||||
# manage_api_keys_create(project_id="wordpress_site1", scope="read write admin")
|
||||
#
|
||||
# # All scopes for all projects (like master key but tracked)
|
||||
# manage_api_keys_create(project_id="*", scope="read write admin")
|
||||
#
|
||||
# List keys:
|
||||
# manage_api_keys_list()
|
||||
#
|
||||
# Revoke keys:
|
||||
# manage_api_keys_revoke(key_id="key_xxx")
|
||||
#
|
||||
# Rotate keys:
|
||||
# manage_api_keys_rotate(project_id="wordpress_site1")
|
||||
#
|
||||
# 📝 NOTE: For OAuth integration (ChatGPT, etc.), create API Keys with all required scopes.
|
||||
# Example: If ChatGPT requests "read write admin", your API Key must have
|
||||
# "read write admin" to avoid "Not all requested permissions were granted" error.
|
||||
#
|
||||
# ==============================================================================
|
||||
94
.github/ISSUE_TEMPLATE/bug_report.yml
vendored
Normal file
94
.github/ISSUE_TEMPLATE/bug_report.yml
vendored
Normal file
@@ -0,0 +1,94 @@
|
||||
name: Bug Report
|
||||
description: Report a bug or unexpected behavior
|
||||
labels: ["bug"]
|
||||
body:
|
||||
- type: markdown
|
||||
attributes:
|
||||
value: |
|
||||
Thanks for reporting a bug! Please fill out the details below.
|
||||
|
||||
- type: textarea
|
||||
id: description
|
||||
attributes:
|
||||
label: Description
|
||||
description: What happened? What did you expect to happen?
|
||||
placeholder: A clear description of the bug...
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: steps
|
||||
attributes:
|
||||
label: Steps to Reproduce
|
||||
description: How can we reproduce this issue?
|
||||
placeholder: |
|
||||
1. Configure site with ...
|
||||
2. Run tool ...
|
||||
3. See error ...
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: dropdown
|
||||
id: plugin
|
||||
attributes:
|
||||
label: Plugin Type
|
||||
description: Which plugin is affected?
|
||||
options:
|
||||
- WordPress
|
||||
- WooCommerce
|
||||
- WordPress Advanced
|
||||
- Gitea
|
||||
- n8n
|
||||
- Supabase
|
||||
- OpenPanel
|
||||
- Appwrite
|
||||
- Directus
|
||||
- System / Core
|
||||
- Dashboard
|
||||
- Other
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: dropdown
|
||||
id: transport
|
||||
attributes:
|
||||
label: Transport
|
||||
description: How are you running the server?
|
||||
options:
|
||||
- SSE (HTTP)
|
||||
- Streamable HTTP
|
||||
- stdio
|
||||
- Docker
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: input
|
||||
id: mcp-client
|
||||
attributes:
|
||||
label: MCP Client
|
||||
description: Which AI client are you using?
|
||||
placeholder: "e.g., Claude Desktop, Cursor, VS Code Copilot, ChatGPT"
|
||||
|
||||
- type: input
|
||||
id: version
|
||||
attributes:
|
||||
label: Version
|
||||
description: MCP Hub version or commit hash
|
||||
placeholder: "e.g., 3.0.0 or abc1234"
|
||||
|
||||
- type: textarea
|
||||
id: logs
|
||||
attributes:
|
||||
label: Relevant Logs
|
||||
description: Paste any relevant error messages or logs
|
||||
render: shell
|
||||
|
||||
- type: textarea
|
||||
id: environment
|
||||
attributes:
|
||||
label: Environment
|
||||
description: OS, Python version, Docker version if applicable
|
||||
placeholder: |
|
||||
- OS: Ubuntu 22.04
|
||||
- Python: 3.12.1
|
||||
- Docker: 24.0.7
|
||||
55
.github/ISSUE_TEMPLATE/feature_request.yml
vendored
Normal file
55
.github/ISSUE_TEMPLATE/feature_request.yml
vendored
Normal file
@@ -0,0 +1,55 @@
|
||||
name: Feature Request
|
||||
description: Suggest a new feature or improvement
|
||||
labels: ["enhancement"]
|
||||
body:
|
||||
- type: markdown
|
||||
attributes:
|
||||
value: |
|
||||
Thanks for your feature suggestion! Please describe what you'd like to see.
|
||||
|
||||
- type: textarea
|
||||
id: problem
|
||||
attributes:
|
||||
label: Problem or Use Case
|
||||
description: What problem does this solve? What use case does it enable?
|
||||
placeholder: "I'm trying to ... but currently ..."
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: solution
|
||||
attributes:
|
||||
label: Proposed Solution
|
||||
description: How would you like this to work?
|
||||
placeholder: "It would be great if ..."
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: dropdown
|
||||
id: area
|
||||
attributes:
|
||||
label: Area
|
||||
description: Which part of the project does this affect?
|
||||
options:
|
||||
- New Plugin
|
||||
- Existing Plugin Enhancement
|
||||
- Dashboard / UI
|
||||
- Authentication / Security
|
||||
- Performance
|
||||
- Documentation
|
||||
- Developer Experience
|
||||
- Other
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: alternatives
|
||||
attributes:
|
||||
label: Alternatives Considered
|
||||
description: Have you considered any workarounds or alternatives?
|
||||
|
||||
- type: textarea
|
||||
id: context
|
||||
attributes:
|
||||
label: Additional Context
|
||||
description: Any other context, screenshots, or examples?
|
||||
82
.github/workflows/ci.yml
vendored
Normal file
82
.github/workflows/ci.yml
vendored
Normal file
@@ -0,0 +1,82 @@
|
||||
name: CI
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
pull_request:
|
||||
branches: [main]
|
||||
|
||||
jobs:
|
||||
test:
|
||||
name: Test (Python ${{ matrix.python-version }})
|
||||
runs-on: ubuntu-latest
|
||||
strategy:
|
||||
matrix:
|
||||
python-version: ["3.11", "3.12"]
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Set up Python ${{ matrix.python-version }}
|
||||
uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: ${{ matrix.python-version }}
|
||||
|
||||
- name: Cache pip packages
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: ~/.cache/pip
|
||||
key: ${{ runner.os }}-pip-${{ matrix.python-version }}-${{ hashFiles('pyproject.toml') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-pip-${{ matrix.python-version }}-
|
||||
|
||||
- name: Install dependencies
|
||||
run: pip install -e ".[dev]"
|
||||
|
||||
- name: Run tests
|
||||
run: pytest tests/ -v --tb=short
|
||||
|
||||
- name: Run tests with coverage
|
||||
if: matrix.python-version == '3.12'
|
||||
run: pytest tests/ --cov=core --cov=plugins --cov-report=xml --cov-report=term-missing -q
|
||||
|
||||
- name: Upload coverage
|
||||
if: matrix.python-version == '3.12'
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: coverage-report
|
||||
path: coverage.xml
|
||||
|
||||
lint:
|
||||
name: Lint & Format
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: "3.12"
|
||||
|
||||
- name: Install dependencies
|
||||
run: pip install -e ".[dev]"
|
||||
|
||||
- name: Check formatting (Black)
|
||||
run: black --check .
|
||||
|
||||
- name: Lint (Ruff)
|
||||
run: ruff check .
|
||||
|
||||
docker:
|
||||
name: Docker Build
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Build Docker image
|
||||
run: docker build -t mcphub:test .
|
||||
|
||||
- name: Verify image was built
|
||||
run: docker image inspect mcphub:test > /dev/null
|
||||
104
.github/workflows/release.yml
vendored
Normal file
104
.github/workflows/release.yml
vendored
Normal file
@@ -0,0 +1,104 @@
|
||||
name: Release
|
||||
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- "v*"
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
jobs:
|
||||
test:
|
||||
name: Test before release
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: "3.12"
|
||||
|
||||
- name: Install dependencies
|
||||
run: pip install -e ".[dev]"
|
||||
|
||||
- name: Run tests
|
||||
run: pytest tests/ -v --tb=short
|
||||
|
||||
- name: Check formatting
|
||||
run: black --check .
|
||||
|
||||
- name: Lint
|
||||
run: ruff check .
|
||||
|
||||
pypi:
|
||||
name: Publish to PyPI
|
||||
needs: test
|
||||
runs-on: ubuntu-latest
|
||||
environment: release
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: "3.12"
|
||||
|
||||
- name: Install build tools
|
||||
run: pip install build twine
|
||||
|
||||
- name: Build package
|
||||
run: python -m build
|
||||
|
||||
- name: Publish to PyPI
|
||||
env:
|
||||
TWINE_USERNAME: __token__
|
||||
TWINE_PASSWORD: ${{ secrets.PYPI_API_TOKEN }}
|
||||
run: twine upload dist/*
|
||||
|
||||
docker:
|
||||
name: Publish to Docker Hub
|
||||
needs: test
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Extract version from tag
|
||||
id: version
|
||||
run: echo "VERSION=${GITHUB_REF#refs/tags/v}" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
- name: Login to Docker Hub
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
|
||||
- name: Build and push
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
context: .
|
||||
push: true
|
||||
tags: |
|
||||
mcphub/mcphub:latest
|
||||
mcphub/mcphub:${{ steps.version.outputs.VERSION }}
|
||||
platforms: linux/amd64,linux/arm64
|
||||
|
||||
github-release:
|
||||
name: Create GitHub Release
|
||||
needs: [pypi, docker]
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Create Release
|
||||
uses: softprops/action-gh-release@v2
|
||||
with:
|
||||
generate_release_notes: true
|
||||
161
.gitignore
vendored
Normal file
161
.gitignore
vendored
Normal file
@@ -0,0 +1,161 @@
|
||||
# ====================================
|
||||
# Python
|
||||
# ====================================
|
||||
__pycache__/
|
||||
*.py[cod]
|
||||
*$py.class
|
||||
*.so
|
||||
.Python
|
||||
|
||||
# Distribution / packaging
|
||||
dist/
|
||||
build/
|
||||
*.egg-info/
|
||||
*.egg
|
||||
MANIFEST
|
||||
|
||||
# Virtual environments
|
||||
venv/
|
||||
env/
|
||||
ENV/
|
||||
.venv/
|
||||
|
||||
# ====================================
|
||||
# Testing
|
||||
# ====================================
|
||||
.pytest_cache/
|
||||
.coverage
|
||||
.coverage.*
|
||||
htmlcov/
|
||||
.tox/
|
||||
.nox/
|
||||
coverage.xml
|
||||
*.cover
|
||||
.hypothesis/
|
||||
|
||||
# ====================================
|
||||
# Type checking and linting
|
||||
# ====================================
|
||||
.mypy_cache/
|
||||
.dmypy.json
|
||||
dmypy.json
|
||||
.ruff_cache/
|
||||
.pytype/
|
||||
|
||||
# ====================================
|
||||
# IDE and Editors
|
||||
# ====================================
|
||||
.vscode/
|
||||
.idea/
|
||||
*.swp
|
||||
*.swo
|
||||
*~
|
||||
.project
|
||||
.pydevproject
|
||||
.settings/
|
||||
|
||||
# Claude Code local settings
|
||||
.claude/
|
||||
|
||||
# ====================================
|
||||
# Logs and Runtime
|
||||
# ====================================
|
||||
*.log
|
||||
server_output*.log
|
||||
*.pid
|
||||
|
||||
# Keep logs directory structure but ignore contents
|
||||
logs/*
|
||||
!logs/.gitkeep
|
||||
|
||||
# ====================================
|
||||
# OS Files
|
||||
# ====================================
|
||||
.DS_Store
|
||||
.DS_Store?
|
||||
._*
|
||||
.Spotlight-V100
|
||||
.Trashes
|
||||
ehthumbs.db
|
||||
Thumbs.db
|
||||
Desktop.ini
|
||||
|
||||
# ====================================
|
||||
# Environment and Configuration
|
||||
# ====================================
|
||||
.env
|
||||
.env.*
|
||||
!.env.example
|
||||
.kilocode
|
||||
.mcp.json
|
||||
|
||||
# ====================================
|
||||
# Docker
|
||||
# ====================================
|
||||
docker-compose.override.yml
|
||||
*.pid
|
||||
|
||||
# ====================================
|
||||
# Backup files
|
||||
# ====================================
|
||||
*.backup
|
||||
*.bak
|
||||
*.old
|
||||
*.orig
|
||||
*~
|
||||
|
||||
# ====================================
|
||||
# MCP Test files
|
||||
# ====================================
|
||||
MCP_TEST_REPORT.md
|
||||
mcp_test_*.txt
|
||||
test_mcp_connection.py
|
||||
mcp_test_results.json
|
||||
|
||||
# ====================================
|
||||
# Documentation builds
|
||||
# ====================================
|
||||
docs/_build/
|
||||
site/
|
||||
|
||||
# ====================================
|
||||
# Temporary files
|
||||
# ====================================
|
||||
*.tmp
|
||||
*.temp
|
||||
.cache/
|
||||
|
||||
# ====================================
|
||||
# Security and sensitive data
|
||||
# ====================================
|
||||
secrets/
|
||||
data/
|
||||
*.pem
|
||||
*.key
|
||||
*.crt
|
||||
*.cert
|
||||
*.p12
|
||||
*.pfx
|
||||
|
||||
# ====================================
|
||||
# Database files
|
||||
# ====================================
|
||||
*.sqlite
|
||||
*.sqlite3
|
||||
*.db
|
||||
|
||||
# ====================================
|
||||
# Node.js (if added in future)
|
||||
# ====================================
|
||||
node_modules/
|
||||
|
||||
# ====================================
|
||||
# Temporary directories
|
||||
# ====================================
|
||||
temp/
|
||||
pytest-cache-files-*/
|
||||
|
||||
# ====================================
|
||||
# Project specific
|
||||
# ====================================
|
||||
# Add project-specific ignores here
|
||||
102
.pre-commit-config.yaml
Normal file
102
.pre-commit-config.yaml
Normal file
@@ -0,0 +1,102 @@
|
||||
# Pre-commit hooks for MCP Hub
|
||||
# Install: pip install pre-commit
|
||||
# Setup: pre-commit install
|
||||
# Run manually: pre-commit run --all-files
|
||||
|
||||
repos:
|
||||
# Black - Python code formatter
|
||||
- repo: https://github.com/psf/black
|
||||
rev: 24.1.1
|
||||
hooks:
|
||||
- id: black
|
||||
language_version: python3.11
|
||||
args: ['--line-length=100']
|
||||
|
||||
# Ruff - Fast Python linter
|
||||
- repo: https://github.com/astral-sh/ruff-pre-commit
|
||||
rev: v0.1.14
|
||||
hooks:
|
||||
- id: ruff
|
||||
args: ['--fix', '--exit-non-zero-on-fix']
|
||||
|
||||
# isort - Import sorting
|
||||
- repo: https://github.com/PyCQA/isort
|
||||
rev: 5.13.2
|
||||
hooks:
|
||||
- id: isort
|
||||
args: ['--profile', 'black', '--line-length', '100']
|
||||
|
||||
# pyupgrade - Upgrade Python syntax
|
||||
- repo: https://github.com/asottile/pyupgrade
|
||||
rev: v3.15.0
|
||||
hooks:
|
||||
- id: pyupgrade
|
||||
args: ['--py311-plus']
|
||||
|
||||
# General file checks
|
||||
- repo: https://github.com/pre-commit/pre-commit-hooks
|
||||
rev: v4.5.0
|
||||
hooks:
|
||||
# Check file formatting
|
||||
- id: trailing-whitespace
|
||||
args: ['--markdown-linebreak-ext=md']
|
||||
- id: end-of-file-fixer
|
||||
- id: check-yaml
|
||||
args: ['--safe']
|
||||
- id: check-json
|
||||
- id: check-toml
|
||||
- id: check-added-large-files
|
||||
args: ['--maxkb=1000']
|
||||
|
||||
# Check for common issues
|
||||
- id: check-merge-conflict
|
||||
- id: check-case-conflict
|
||||
- id: mixed-line-ending
|
||||
args: ['--fix=lf']
|
||||
|
||||
# Python-specific checks
|
||||
- id: check-ast
|
||||
- id: check-builtin-literals
|
||||
- id: check-docstring-first
|
||||
- id: debug-statements
|
||||
- id: name-tests-test
|
||||
args: ['--pytest-test-first']
|
||||
|
||||
# Security checks
|
||||
- repo: https://github.com/PyCQA/bandit
|
||||
rev: 1.7.6
|
||||
hooks:
|
||||
- id: bandit
|
||||
args: ['-c', 'pyproject.toml', '--skip', 'B101,B601']
|
||||
additional_dependencies: ['bandit[toml]']
|
||||
|
||||
# Markdown linting
|
||||
- repo: https://github.com/igorshubovych/markdownlint-cli
|
||||
rev: v0.38.0
|
||||
hooks:
|
||||
- id: markdownlint
|
||||
args: ['--fix']
|
||||
|
||||
# Shellcheck for bash scripts
|
||||
- repo: https://github.com/shellcheck-py/shellcheck-py
|
||||
rev: v0.9.0.6
|
||||
hooks:
|
||||
- id: shellcheck
|
||||
files: \.(sh|bash)$
|
||||
|
||||
# Configuration for specific hooks
|
||||
exclude: |
|
||||
(?x)^(
|
||||
\.git/|
|
||||
\.venv/|
|
||||
venv/|
|
||||
__pycache__/|
|
||||
\.pytest_cache/|
|
||||
\.mypy_cache/|
|
||||
\.ruff_cache/|
|
||||
logs/|
|
||||
htmlcov/|
|
||||
dist/|
|
||||
build/|
|
||||
.*\.egg-info/
|
||||
)
|
||||
608
CHANGELOG.md
Normal file
608
CHANGELOG.md
Normal file
@@ -0,0 +1,608 @@
|
||||
# 📝 Changelog
|
||||
|
||||
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).
|
||||
|
||||
---
|
||||
|
||||
## [2.9.0] - 2026-02-14
|
||||
|
||||
### Project Revival - Dependency Updates & Documentation Sync
|
||||
|
||||
After 2-month hiatus (Dec 2025 - Feb 2026), updated all dependencies and verified compatibility.
|
||||
|
||||
#### Updated
|
||||
- FastMCP: 2.12.4 → 2.14.5 (zero breaking changes for our codebase)
|
||||
- MCP Protocol: 1.16.0 → 1.26.0
|
||||
- cryptography: 46.0.2 → 46.0.5 (security patches)
|
||||
- starlette: 0.48.0 → 0.52.1
|
||||
- authlib: 1.6.5 → 1.6.7
|
||||
- pydantic: 2.12.0 → 2.12.5
|
||||
- PyJWT: installed (was missing from environment)
|
||||
|
||||
#### Fixed
|
||||
- OAuth token timestamps: replaced `datetime.utcnow()` with `datetime.now(timezone.utc)` and `time.time()` for correct UTC timestamps in non-UTC timezones
|
||||
- OAuth schemas: `is_expired()` now handles both naive and timezone-aware datetimes
|
||||
- OAuth token reuse detection: `get_refresh_token()` now supports `include_revoked` parameter for proper reuse detection
|
||||
- OAuth audit logging: fixed `AuditLogger.log_event()` call to use existing `log_system_event()` method
|
||||
- All 54 tests now pass (previously 5 were failing)
|
||||
|
||||
#### Verified
|
||||
- All 587 tools generate correctly
|
||||
- Middleware API stable (Middleware, MiddlewareContext, get_http_headers)
|
||||
- 30+ custom routes operational (dashboard + OAuth)
|
||||
- Multi-endpoint architecture functional
|
||||
|
||||
#### Documentation
|
||||
- Aligned version to 2.9.0 across all files (pyproject.toml was 1.3.0)
|
||||
- Synchronized pyproject.toml dependencies with requirements.txt
|
||||
- Updated ROADMAP.md and MASTER_CONTEXT.md dates
|
||||
|
||||
---
|
||||
|
||||
## [1.3.0] - 2025-11-21
|
||||
|
||||
### 🎉 Phase E: Custom OAuth Authorization Page with Multi-language Support
|
||||
|
||||
**Major Feature**: Beautiful web-based OAuth authorization with English & Farsi support!
|
||||
|
||||
#### ✨ Added
|
||||
|
||||
**Web-Based Authorization UI:**
|
||||
- Beautiful HTML templates with Tailwind CSS and dark mode support
|
||||
- Responsive design (mobile-friendly)
|
||||
- User-friendly API Key input form with validation
|
||||
- Smooth animations and transitions
|
||||
|
||||
**Multi-language Support (i18n):**
|
||||
- Complete English (EN) & Persian/Farsi (FA) translations
|
||||
- Automatic language detection from Accept-Language header
|
||||
- RTL (Right-to-Left) layout support for Farsi
|
||||
- 30+ translation keys covering all UI elements
|
||||
|
||||
**Security Enhancements:**
|
||||
- CSRF Protection (`core/oauth/csrf.py`):
|
||||
- Cryptographically secure token generation (64 chars)
|
||||
- 10-minute token lifetime with automatic cleanup
|
||||
- One-time use tokens (consumed after validation)
|
||||
- API Key validation at authorization time
|
||||
- Permission inheritance from API Key to OAuth token
|
||||
- Rate limiting infrastructure ready
|
||||
|
||||
**Files Added:**
|
||||
- `core/i18n.py` - Internationalization utilities (200+ lines)
|
||||
- `core/oauth/csrf.py` - CSRF token manager (150+ lines)
|
||||
- `templates/base.html` - Base template with Tailwind CSS
|
||||
- `templates/oauth/authorize.html` - Authorization form (170 lines)
|
||||
- `templates/oauth/error.html` - Error page (90 lines)
|
||||
|
||||
**Commits**: c851c78, 7ec3b9d, b9b7dda, c60dd43
|
||||
|
||||
---
|
||||
|
||||
## [1.2.0] - 2025-11-18
|
||||
|
||||
### 🎉 Phase D: WordPress Advanced Plugin Split
|
||||
|
||||
**Plugin Separation**: Advanced WordPress features moved to dedicated plugin!
|
||||
|
||||
#### Changed
|
||||
|
||||
**Plugin Structure:**
|
||||
- Split WordPress plugin into two modules:
|
||||
- `plugins/wordpress/` - Core features (92 tools)
|
||||
- `plugins/wordpress_advanced/` - Advanced features (22 tools)
|
||||
|
||||
**WordPress Advanced Plugin** (`plugins/wordpress_advanced/`):
|
||||
- Database Operations (7 tools): export, import, size, tables, search, query, repair
|
||||
- Bulk Operations (8 tools): parallel batch processing with semaphore control
|
||||
- System Operations (7 tools): system info, cron, cache, error logs
|
||||
|
||||
**Benefits:**
|
||||
- Better tool visibility (basic users see only 92 tools)
|
||||
- Improved security (sensitive features in separate plugin)
|
||||
- Granular access control (separate API keys per plugin)
|
||||
- Reduced complexity for regular users
|
||||
|
||||
**Documentation:**
|
||||
- Complete README.md in `plugins/wordpress_advanced/`
|
||||
- Environment configuration examples
|
||||
- Conditional initialization guide
|
||||
|
||||
**Tool Count**: Remains 114 total (92 core + 22 advanced split)
|
||||
|
||||
**Commits**: 2df7f31, fc97c85, 475dd73
|
||||
|
||||
---
|
||||
|
||||
## [1.1.0] - 2025-11-19
|
||||
|
||||
### 🎉 Phase C: Gitea Plugin Implementation
|
||||
|
||||
**New Plugin**: Complete Gitea repository management with 55 tools!
|
||||
|
||||
#### ✨ Added
|
||||
|
||||
**Gitea Plugin** (`plugins/gitea/`):
|
||||
|
||||
**Features:**
|
||||
- Repository Management (15 tools): CRUD, branches, tags, files
|
||||
- Issue Tracking (12 tools): issues, labels, milestones, comments
|
||||
- Pull Requests (15 tools): PRs, reviews, merges, comments
|
||||
- User & Organization (8 tools): users, orgs, teams, search
|
||||
- Webhooks (5 tools): webhook management
|
||||
|
||||
**Architecture (Option B Clean Architecture):**
|
||||
- Pydantic Schemas (6 files, ~1,100 lines):
|
||||
- `common.py` - Site, Pagination, User models
|
||||
- `repository.py` - Repository, Branch, Tag, File models
|
||||
- `issue.py` - Issue, Label, Milestone, Comment models
|
||||
- `pull_request.py` - PR, Review, Commit, File models
|
||||
- `user.py` - User, Organization, Team models
|
||||
- `webhook.py` - Webhook configuration models
|
||||
- Gitea API Client (`client.py` - 406 lines):
|
||||
- OAuth 2.1 integration
|
||||
- Full REST API methods
|
||||
- Error handling and logging
|
||||
- Handler Modules (5 files, ~2,900 lines):
|
||||
- `repositories.py`, `issues.py`, `pull_requests.py`
|
||||
- `users.py`, `webhooks.py`
|
||||
- Main Plugin (`plugin.py` - 177 lines):
|
||||
- Dynamic method delegation with `__getattr__`
|
||||
- `get_tool_specifications()` static method
|
||||
- Health check integration
|
||||
|
||||
**Bug Fixes:**
|
||||
- Plugin registration integration (a30b444)
|
||||
- Tool registration enabled (61b8280)
|
||||
- Dynamic method delegation fix (bb251c0)
|
||||
- Pydantic V2 compatibility (8bc4eb6)
|
||||
- Schema validation - JSON strings (dbd3234)
|
||||
|
||||
**Documentation:**
|
||||
- Complete usage guide (`docs/GITEA_GUIDE.md` - 724 lines)
|
||||
- Environment configuration in `.env.example`
|
||||
|
||||
**Tool Count**: 136 → 191 tools (+55 Gitea tools)
|
||||
|
||||
**Commits**: a30b444, 61b8280, bb251c0, 8bc4eb6, dbd3234
|
||||
|
||||
---
|
||||
|
||||
## [1.0.0] - 2025-11-18
|
||||
|
||||
### 🎉 Phase B: OAuth 2.1 Infrastructure
|
||||
|
||||
**Major Feature**: Production-ready OAuth 2.1 server with RFC compliance!
|
||||
|
||||
#### ✨ Added
|
||||
|
||||
**OAuth 2.1 Server** (`core/oauth/server.py` - ~450 lines):
|
||||
- RFC 8414: Authorization Server Metadata
|
||||
- RFC 7591: Dynamic Client Registration (Protected)
|
||||
- RFC 7636: PKCE (S256 mandatory)
|
||||
- RFC 8705: Protected Resource Metadata
|
||||
|
||||
**Core Components:**
|
||||
- `core/oauth/schemas.py` - Pydantic models (OAuthClient, tokens)
|
||||
- `core/oauth/pkce.py` - PKCE implementation (S256)
|
||||
- `core/oauth/storage.py` - JSON-based token storage with auto-cleanup
|
||||
- `core/oauth/client_registry.py` - OAuth client management
|
||||
- `core/oauth/token_manager.py` - JWT generation/validation
|
||||
|
||||
**OAuth Endpoints (4 endpoints):**
|
||||
- `GET /.well-known/oauth-authorization-server` (discovery)
|
||||
- `GET /.well-known/oauth-protected-resource` (RFC 8705)
|
||||
- `POST /oauth/register` (protected - Master API Key required)
|
||||
- `GET /oauth/authorize` - Authorization endpoint (API Key required)
|
||||
- `POST /oauth/token` - Token exchange (all grant types)
|
||||
|
||||
**Security Features:**
|
||||
- Protected client registration (Master API Key required)
|
||||
- API Key authorization mode (OAUTH_AUTH_MODE=required)
|
||||
- Refresh token rotation (prevents reuse)
|
||||
- Authorization code single-use (prevents replay)
|
||||
- PKCE mandatory (prevents CSRF)
|
||||
- API Key permission inheritance
|
||||
|
||||
**OAuth Tools (3 new tools):**
|
||||
- `oauth_register_client` - Register new OAuth clients
|
||||
- `oauth_list_clients` - List all registered clients
|
||||
- `oauth_revoke_client` - Revoke client access
|
||||
|
||||
**ChatGPT Integration:**
|
||||
- OAuth (manual) mode support
|
||||
- Admin registers client with Master API Key
|
||||
- Users authorize with their own API Keys
|
||||
- Enhanced security - no open registration
|
||||
|
||||
**Documentation:**
|
||||
- Complete OAuth guide (`docs/OAUTH_GUIDE.md` - ~650 lines)
|
||||
- Environment configuration examples
|
||||
- Security model documentation
|
||||
|
||||
**Tool Count**: 133 → 136 tools (+3 OAuth tools)
|
||||
|
||||
**Commits**: Multiple commits (2025-11-18, updated 2025-11-19)
|
||||
|
||||
---
|
||||
|
||||
## [Unreleased] - 2025-11-11
|
||||
|
||||
### 🔄 Architecture Refactoring (Option A)
|
||||
|
||||
**BREAKING CHANGE**: Removed per-site tools to prevent tool explosion.
|
||||
|
||||
#### Changed
|
||||
- **Architecture**: Migrated from Hybrid to Unified-Only architecture
|
||||
- **Tool Count**: Reduced from 390 to ~105 tools (constant regardless of site count)
|
||||
- **Scalability**: Now supports unlimited sites without tool explosion
|
||||
- 1 site: 105 tools (was 200)
|
||||
- 10 sites: 105 tools (was 1055)
|
||||
- 100 sites: 105 tools (was 9600+)
|
||||
|
||||
#### Technical Details
|
||||
- Per-site tools (e.g., `wordpress_site1_get_post`) are no longer registered
|
||||
- Only unified tools (e.g., `wordpress_get_post(site="site1")`) are exposed
|
||||
- Plugin infrastructure remains intact for internal use
|
||||
- Site aliases continue to work seamlessly
|
||||
|
||||
#### Migration
|
||||
- **Non-breaking for new users**: Use unified tools from the start
|
||||
- **For existing integrations**: Update tool calls to use `site` parameter
|
||||
- Before: `wordpress_site1_get_post(post_id=123)`
|
||||
- After: `wordpress_get_post(site="site1", post_id=123)`
|
||||
|
||||
**Files Modified**:
|
||||
- `server.py`: Removed per-site tool registration
|
||||
- `MASTER_CONTEXT.md`: Updated architecture documentation
|
||||
- `README.md`: Updated tool count and features
|
||||
|
||||
**Next Steps**: Option B (complete architectural cleanup) planned for separate branch.
|
||||
|
||||
---
|
||||
|
||||
## [1.0.0] - 2025-11-11 (Planned)
|
||||
|
||||
### 🎉 Initial Public Release
|
||||
|
||||
This will be the first stable release of MCP Hub, featuring comprehensive WordPress and WooCommerce management through MCP protocol.
|
||||
|
||||
### ✨ Added
|
||||
|
||||
#### Core Features
|
||||
- **~105 MCP Tools** with unified architecture (constant tool count!)
|
||||
- **Unified Architecture**: Context-based tools for efficient scaling
|
||||
- **Site Registry**: Friendly aliases for projects (e.g., "plantup" → site2)
|
||||
- **Multi-language Support**: Full bilingual documentation (English/Persian)
|
||||
|
||||
#### WordPress Management (Phase 1-3)
|
||||
- **Posts Management**: Create, read, update, delete posts
|
||||
- **Pages Management**: Full CRUD operations for pages
|
||||
- **Media Library**: Upload, manage, and delete media files
|
||||
- **Comments**: Moderate and manage comments
|
||||
- **Categories & Tags**: Taxonomy management
|
||||
- **Users**: User information and authentication
|
||||
- **Plugins & Themes**: List and inspect installed plugins/themes
|
||||
- **Site Settings**: Read WordPress configuration
|
||||
- **Site Health**: Check WordPress accessibility
|
||||
|
||||
**Commits**:
|
||||
- `9881838`: feat(wordpress): implement core WordPress tools (Phase 1)
|
||||
- `ad26cdc`: feat(wordpress): add media, comments, and taxonomy tools (Phase 2)
|
||||
- `e7ac72d`: feat(wordpress): complete users, plugins, themes, and settings (Phase 3)
|
||||
|
||||
#### WooCommerce Integration (Phase 4-5)
|
||||
- **Products**: Full product lifecycle management
|
||||
- **Product Variations**: Variable product support
|
||||
- **Product Categories & Tags**: Product taxonomy management
|
||||
- **Product Attributes**: Global attributes for variations
|
||||
- **Coupons**: Discount code management
|
||||
- **Orders**: Order processing and status updates
|
||||
- **Customers**: Customer data management
|
||||
- **Reports**: Sales, top sellers, and customer analytics
|
||||
|
||||
**Commits**:
|
||||
- `cda10fb`: feat(woocommerce): implement products, categories, and coupons (Phase 4)
|
||||
- `0ffe5ad`: feat(woocommerce): add orders, customers, and reports (Phase 5)
|
||||
|
||||
#### WP-CLI & SEO Tools (Phase 6)
|
||||
- **Cache Management**: Flush object cache, check cache type
|
||||
- **Transients**: List and delete transients
|
||||
- **Database**: Health checks, optimization, export
|
||||
- **Plugin Management**: List, verify checksums, update plugins
|
||||
- **Theme Management**: List, verify, update themes
|
||||
- **Core Management**: Verify and update WordPress core
|
||||
- **Search & Replace**: Dry-run database migrations
|
||||
- **SEO Metadata**: Rank Math & Yoast SEO integration
|
||||
- **Navigation Menus**: Menu and menu item management
|
||||
- **Custom Post Types**: List and manage custom post types
|
||||
- **Custom Taxonomies**: Taxonomy term management
|
||||
|
||||
**Commits**:
|
||||
- `e5aaa97`: feat(wp-cli): implement WP-CLI integration (Phase 6)
|
||||
- `e5aaa97`: feat(seo): add SEO metadata management tools (Phase 6)
|
||||
|
||||
#### Hybrid Architecture (Phase 7.0)
|
||||
- **Site Registry System**: Central site configuration management
|
||||
- **Unified Tool Generator**: Context-based tools with site parameter
|
||||
- **Per-Site Tools**: Backward-compatible legacy tools
|
||||
- **Tool Aliases**: Support for friendly project names
|
||||
- **Plugin Architecture**: Extensible plugin system
|
||||
|
||||
**Commit**: `b638d4a`: feat(architecture): implement hybrid dual-tool architecture (Phase 7.0)
|
||||
|
||||
#### Security & Monitoring (Phase 7.1-7.3)
|
||||
- **Audit Logging** (Phase 7.1):
|
||||
- GDPR-compliant structured logging
|
||||
- Sensitive data filtering (passwords, API keys)
|
||||
- JSON format for easy parsing
|
||||
- Automatic log rotation
|
||||
|
||||
- **Health Monitoring** (Phase 7.2):
|
||||
- Real-time system metrics
|
||||
- Response time tracking
|
||||
- Error rate monitoring
|
||||
- Historical metrics (1-24 hours)
|
||||
- Alert thresholds
|
||||
- Export functionality
|
||||
|
||||
- **Rate Limiting** (Phase 7.3):
|
||||
- Token bucket algorithm
|
||||
- 60 req/min, 1000 req/hour, 10000 req/day
|
||||
- Per-client tracking
|
||||
- Automatic throttling
|
||||
- Statistics and monitoring
|
||||
|
||||
**Commits**:
|
||||
- `003ee0f`: feat(audit): implement GDPR-compliant audit logging (Phase 7.1)
|
||||
- `796c8e0`: feat(health): implement enhanced health monitoring (Phase 7.2)
|
||||
- `9297f20`: feat(rate-limiting): implement rate limiting & throttling (Phase 7.3)
|
||||
|
||||
#### Documentation
|
||||
- **README.md**: Bilingual project introduction
|
||||
- **CONTRIBUTING.md**: Simplified contribution guide
|
||||
- **SECURITY.md**: Security policy with OWASP compliance
|
||||
- **MASTER_CONTEXT.md**: Comprehensive project reference
|
||||
- **Development Documentation**: Architecture and deployment guides
|
||||
|
||||
### 🔒 Security
|
||||
- API key authentication for WordPress and WooCommerce
|
||||
- Password filtering in logs
|
||||
- Input validation and schema checking
|
||||
- Docker security hardening
|
||||
- OWASP Top 10 2025 compliance
|
||||
- Regular dependency updates
|
||||
|
||||
### 🏗️ Infrastructure
|
||||
- **Docker Support**: Production-ready Docker Compose setup
|
||||
- **Coolify Integration**: One-click deployment support
|
||||
- **Environment Variables**: Secure configuration management
|
||||
- **Health Checks**: Automated container health monitoring
|
||||
- **Log Management**: Structured logging with rotation
|
||||
|
||||
### 🧪 Testing
|
||||
- **40 Tests**: Comprehensive test suite
|
||||
- **75%+ Coverage**: Unit, integration, and E2E tests
|
||||
- **pytest Framework**: Modern Python testing
|
||||
- **Mock Support**: Isolated testing with pytest-mock
|
||||
- **Async Testing**: Full async/await test support
|
||||
|
||||
### 📊 Statistics
|
||||
- **390 Total Tools**: 285 per-site + 95 unified + 10 system
|
||||
- **3 WordPress Sites**: Configured and tested
|
||||
- **8 Development Phases**: From inception to production
|
||||
- **19 Commits**: Clean development history
|
||||
|
||||
### 🔧 Technical Details
|
||||
- **Python 3.11+**: Modern async/await support
|
||||
- **FastMCP Framework**: MCP protocol implementation
|
||||
- **httpx**: Async HTTP client
|
||||
- **Docker Compose**: Container orchestration
|
||||
- **pytest**: Testing framework
|
||||
|
||||
---
|
||||
|
||||
## Development History
|
||||
|
||||
### Phase 7.3 - Rate Limiting & Throttling
|
||||
**Date**: 2025-11-11
|
||||
**Status**: ✅ Complete
|
||||
|
||||
Added rate limiting system with token bucket algorithm to prevent API abuse.
|
||||
|
||||
**Changes**:
|
||||
- Implemented `RateLimiter` class with token bucket algorithm
|
||||
- Added per-client request tracking
|
||||
- Configurable limits (per-minute, per-hour, per-day)
|
||||
- Rate limit statistics endpoint
|
||||
- Rate limit reset functionality
|
||||
- Integration with all tool handlers
|
||||
- Comprehensive testing
|
||||
|
||||
**Tools Added**: 2 (get_rate_limit_stats, reset_rate_limit)
|
||||
|
||||
### Phase 7.2 - Enhanced Health Monitoring
|
||||
**Date**: 2025-11-10
|
||||
**Status**: ✅ Complete
|
||||
|
||||
Enhanced health monitoring system with detailed metrics and historical tracking.
|
||||
|
||||
**Changes**:
|
||||
- Response time tracking per project
|
||||
- Error rate calculation
|
||||
- Historical metrics storage (up to 24 hours)
|
||||
- Alert threshold system
|
||||
- Health metrics export
|
||||
- System-wide statistics
|
||||
- Project-specific health endpoints
|
||||
|
||||
**Tools Added**: 5 (check_all_projects_health, get_project_health, get_system_metrics, get_system_uptime, get_project_metrics, export_health_metrics)
|
||||
|
||||
### Phase 7.1 - Audit Logging
|
||||
**Date**: 2025-11-09
|
||||
**Status**: ✅ Complete
|
||||
|
||||
Implemented GDPR-compliant audit logging system with sensitive data filtering.
|
||||
|
||||
**Changes**:
|
||||
- Structured JSON logging
|
||||
- GDPR compliance (PII filtering)
|
||||
- Password and API key filtering
|
||||
- Log rotation configuration
|
||||
- User action tracking
|
||||
- Security event logging
|
||||
- Timezone-aware timestamps
|
||||
|
||||
### Phase 7.0 - Hybrid Architecture
|
||||
**Date**: 2025-11-08
|
||||
**Status**: ✅ Complete
|
||||
|
||||
Major architectural refactor introducing dual tool system.
|
||||
|
||||
**Changes**:
|
||||
- Site Registry system
|
||||
- Unified tool generator
|
||||
- Plugin architecture
|
||||
- Per-site tool preservation (backward compatibility)
|
||||
- Site alias support
|
||||
- Dynamic tool registration
|
||||
|
||||
**Tools Added**: 95 unified tools (matching per-site tools)
|
||||
|
||||
### Phase 6 - WP-CLI & Advanced Features
|
||||
**Date**: 2025-11-05
|
||||
**Status**: ✅ Complete
|
||||
|
||||
Added WP-CLI integration and advanced WordPress features.
|
||||
|
||||
**Changes**:
|
||||
- Cache management (flush, type)
|
||||
- Transient management
|
||||
- Database operations (check, optimize, export)
|
||||
- Plugin checksums verification
|
||||
- Core file verification
|
||||
- Search & replace (dry-run only)
|
||||
- Plugin/theme/core updates (with dry-run mode)
|
||||
- SEO metadata management (Rank Math & Yoast)
|
||||
- Navigation menu management
|
||||
- Custom post types support
|
||||
- Custom taxonomies support
|
||||
|
||||
**Tools Added per Site**: 19
|
||||
|
||||
### Phase 5 - WooCommerce Orders & Customers
|
||||
**Date**: 2025-11-03
|
||||
**Status**: ✅ Complete
|
||||
|
||||
Extended WooCommerce support with order and customer management.
|
||||
|
||||
**Changes**:
|
||||
- Order listing with filters
|
||||
- Order details retrieval
|
||||
- Order status updates
|
||||
- Order creation
|
||||
- Order deletion
|
||||
- Customer listing
|
||||
- Customer details
|
||||
- Customer creation/updates
|
||||
|
||||
**Tools Added per Site**: 8
|
||||
|
||||
### Phase 4 - WooCommerce Products & Reports
|
||||
**Date**: 2025-11-02
|
||||
**Status**: ✅ Complete
|
||||
|
||||
Added comprehensive WooCommerce product management and reporting.
|
||||
|
||||
**Changes**:
|
||||
- Product CRUD operations
|
||||
- Product categories and tags
|
||||
- Product attributes
|
||||
- Product variations
|
||||
- Coupon management
|
||||
- Sales reports
|
||||
- Top sellers analytics
|
||||
- Customer statistics
|
||||
|
||||
**Tools Added per Site**: 20
|
||||
|
||||
### Phase 3 - WordPress Extended Features
|
||||
**Date**: 2025-11-01
|
||||
**Status**: ✅ Complete
|
||||
|
||||
Completed core WordPress functionality.
|
||||
|
||||
**Changes**:
|
||||
- User management
|
||||
- Plugin listing
|
||||
- Theme management
|
||||
- Site settings access
|
||||
- Site health checks
|
||||
|
||||
**Tools Added per Site**: 5
|
||||
|
||||
### Phase 2 - WordPress Media & Taxonomy
|
||||
**Date**: 2025-10-31
|
||||
**Status**: ✅ Complete
|
||||
|
||||
Extended WordPress tools with media and taxonomy support.
|
||||
|
||||
**Changes**:
|
||||
- Media library management
|
||||
- Media upload from URL
|
||||
- Media metadata updates
|
||||
- Comment moderation
|
||||
- Category management
|
||||
- Tag management
|
||||
|
||||
**Tools Added per Site**: 6
|
||||
|
||||
### Phase 1 - Core WordPress Tools
|
||||
**Date**: 2025-10-30
|
||||
**Status**: ✅ Complete
|
||||
|
||||
Initial implementation of WordPress management tools.
|
||||
|
||||
**Changes**:
|
||||
- Post management (CRUD)
|
||||
- Page management (CRUD)
|
||||
- Basic MCP server setup
|
||||
- Environment configuration
|
||||
- Docker Compose setup
|
||||
|
||||
**Tools Added per Site**: 6
|
||||
|
||||
---
|
||||
|
||||
---
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
### 🔮 Planned for Phase 2
|
||||
|
||||
#### Gitea Integration (Priority)
|
||||
- Repository management
|
||||
- Issue tracking
|
||||
- Pull request operations
|
||||
- Webhook management
|
||||
- User and team management
|
||||
|
||||
#### Supabase Integration
|
||||
- Database operations
|
||||
- Authentication management
|
||||
- Storage management
|
||||
- Real-time subscriptions
|
||||
- Edge functions
|
||||
|
||||
#### Migration Tools
|
||||
- WordPress to Supabase migration
|
||||
- Data export/import utilities
|
||||
- Schema generation
|
||||
|
||||
---
|
||||
|
||||
[1.0.0]: https://github.com/mcphub/mcphub/releases/tag/v1.0.0
|
||||
[Unreleased]: https://github.com/mcphub/mcphub/compare/v1.0.0...HEAD
|
||||
194
CLAUDE.md
Normal file
194
CLAUDE.md
Normal file
@@ -0,0 +1,194 @@
|
||||
# CLAUDE.md
|
||||
|
||||
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
|
||||
|
||||
## Project Overview
|
||||
|
||||
**MCP Hub** — a Python MCP (Model Context Protocol) server that manages multiple self-hosted projects through a unified plugin architecture. Supports 9 plugin types (WordPress, WooCommerce, WordPress Advanced, Gitea, n8n, Supabase, OpenPanel, Appwrite, Directus) with ~587 tools total. The tool count stays constant regardless of how many sites are configured.
|
||||
|
||||
## Quick Setup
|
||||
|
||||
```bash
|
||||
cp env.example .env # Copy and fill in credentials
|
||||
pip install -e ".[dev]" # Install with dev deps
|
||||
python server.py # Run (stdio) or:
|
||||
python server.py --transport sse --port 8000 # Run (HTTP)
|
||||
```
|
||||
|
||||
## Build & Development Commands
|
||||
|
||||
```bash
|
||||
# Install with dev dependencies
|
||||
pip install -e ".[dev]"
|
||||
|
||||
# Run server (stdio transport for Claude Desktop)
|
||||
python server.py
|
||||
|
||||
# Run server (SSE/HTTP transport for testing)
|
||||
python server.py --transport sse --port 8000
|
||||
|
||||
# Run all tests
|
||||
pytest
|
||||
|
||||
# Run single test file
|
||||
pytest tests/test_api_keys.py
|
||||
|
||||
# Run by marker (unit, integration, security, slow)
|
||||
pytest -m unit
|
||||
pytest -m "not slow"
|
||||
|
||||
# Tests with coverage
|
||||
pytest --cov --cov-report=html
|
||||
|
||||
# Format code
|
||||
black .
|
||||
|
||||
# Lint
|
||||
ruff check .
|
||||
ruff check --fix .
|
||||
|
||||
# Type check
|
||||
mypy .
|
||||
|
||||
# Docker build and run
|
||||
docker build -t mcphub .
|
||||
docker-compose up -d
|
||||
```
|
||||
|
||||
## Code Quality Configuration
|
||||
|
||||
All configured in `pyproject.toml`:
|
||||
- **Black**: line-length=100, target py311
|
||||
- **Ruff**: strict rules (E, W, F, I, N, D, UP, B, C4, SIM, TCH, PTH), Google-style docstrings
|
||||
- **mypy**: Python 3.11, strict equality, check_untyped_defs=true
|
||||
- **pytest**: asyncio_mode="auto", testpaths=["tests"], markers: slow, integration, unit, security
|
||||
|
||||
## Architecture
|
||||
|
||||
### Root Directory Overview
|
||||
|
||||
```
|
||||
├── 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)
|
||||
├── templates/ # Jinja2 templates (dashboard + OAuth)
|
||||
├── tests/ # Organized test suite
|
||||
├── scripts/ # Setup & deployment scripts
|
||||
├── wordpress-plugin/ # Companion WP plugins (PHP)
|
||||
├── docs/ # Extensive documentation
|
||||
├── pyproject.toml # All tool configs (black, ruff, mypy, pytest)
|
||||
├── docker-compose.yaml # Docker composition
|
||||
└── env.example # Environment variable template
|
||||
```
|
||||
|
||||
### Three-Layer Clean Architecture ("Option B")
|
||||
|
||||
```
|
||||
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 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
|
||||
|
||||
```
|
||||
/mcp → Admin (all tools, Master API Key required)
|
||||
/system/mcp → System tools only
|
||||
/{plugin_type}/mcp → Plugin-specific tools (wordpress, gitea, n8n, etc.)
|
||||
/project/{alias_or_id}/mcp → Per-project endpoint (auto-injects site parameter)
|
||||
```
|
||||
|
||||
Implemented in `core/endpoints/` — EndpointConfig, MCPEndpointFactory, EndpointRegistry.
|
||||
|
||||
### Plugin System
|
||||
|
||||
All plugins extend `BasePlugin` (in `plugins/base.py`). Registration happens in `plugins/__init__.py` via `PluginRegistry`.
|
||||
|
||||
Each plugin follows this structure:
|
||||
```
|
||||
plugins/{name}/
|
||||
├── plugin.py # Main class extending BasePlugin
|
||||
├── client.py # REST API client for the service
|
||||
├── handlers/ # Feature-specific handlers (posts.py, orders.py, etc.)
|
||||
└── schemas/ # Pydantic models for validation
|
||||
```
|
||||
|
||||
**Registered plugins**: wordpress, woocommerce, wordpress_advanced, gitea, n8n, supabase, openpanel, appwrite, directus
|
||||
|
||||
### 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
|
||||
|
||||
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
|
||||
|
||||
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.
|
||||
|
||||
### Key Core Modules
|
||||
|
||||
| Module | Purpose |
|
||||
|--------|---------|
|
||||
| `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/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 |
|
||||
| `core/rate_limiter.py` | Token bucket rate limiting (60/min, 1000/hr, 10k/day) |
|
||||
| `core/audit_log.py` | GDPR-compliant JSON audit logging |
|
||||
| `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) |
|
||||
|
||||
### 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.
|
||||
|
||||
### 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.
|
||||
|
||||
## Commit Style
|
||||
|
||||
```
|
||||
<type>(<scope>): <description>
|
||||
```
|
||||
Types: `feat`, `fix`, `docs`, `style`, `refactor`, `test`, `chore`
|
||||
|
||||
## 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
|
||||
- `env.example` has "FUTURE" labels for Supabase/Gitea but both are fully implemented
|
||||
- Dashboard templates live in `templates/` (not inside `core/dashboard/`)
|
||||
- `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)
|
||||
|
||||
## Deployment Notes
|
||||
|
||||
- **Coolify**: Docker Compose build pack, port 8000, health check `GET /health`
|
||||
- Must listen on `0.0.0.0` (not localhost)
|
||||
- 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`
|
||||
136
CONTRIBUTING.md
Normal file
136
CONTRIBUTING.md
Normal file
@@ -0,0 +1,136 @@
|
||||
# Contributing to MCP Hub
|
||||
|
||||
---
|
||||
|
||||
Thank you for your interest in contributing to MCP Hub!
|
||||
|
||||
### Development Setup
|
||||
|
||||
**Prerequisites**: Python 3.11+, Docker (optional), Git
|
||||
|
||||
```bash
|
||||
git clone https://github.com/mcphub/mcphub.git
|
||||
cd mcphub
|
||||
cp env.example .env
|
||||
pip install -e ".[dev]"
|
||||
pytest # Verify setup
|
||||
```
|
||||
|
||||
### Running the Server
|
||||
|
||||
```bash
|
||||
python server.py # stdio (Claude Desktop)
|
||||
python server.py --transport sse --port 8000 # HTTP (testing)
|
||||
```
|
||||
|
||||
### Code Style
|
||||
|
||||
```bash
|
||||
black . # Format
|
||||
ruff check . # Lint
|
||||
ruff check --fix . # Auto-fix lint issues
|
||||
```
|
||||
|
||||
- **Line length**: 100 characters
|
||||
- **Target**: Python 3.11
|
||||
- **Docstrings**: Google style
|
||||
|
||||
### Testing
|
||||
|
||||
```bash
|
||||
pytest # All tests
|
||||
pytest -v # Verbose
|
||||
pytest tests/test_wordpress_plugin.py # Single file
|
||||
pytest --cov=core --cov=plugins --cov-report=html # Coverage
|
||||
pytest -m "not slow" # Skip slow
|
||||
```
|
||||
|
||||
All contributions must include tests. Target: 70%+ coverage on core modules.
|
||||
|
||||
### Commit Messages
|
||||
|
||||
```
|
||||
<type>(<scope>): <description>
|
||||
```
|
||||
|
||||
Types: `feat`, `fix`, `docs`, `style`, `refactor`, `test`, `chore`
|
||||
|
||||
Examples:
|
||||
- `feat(wordpress): add bulk post update tool`
|
||||
- `fix(oauth): handle expired refresh tokens`
|
||||
- `test(dashboard): add session management tests`
|
||||
|
||||
### Pull Request Process
|
||||
|
||||
1. Fork the repository
|
||||
2. Create a feature branch (`feat/description`, `fix/description`)
|
||||
3. Make changes, add tests
|
||||
4. Verify: `pytest && black --check . && ruff check .`
|
||||
5. Submit PR with clear description
|
||||
6. CI must pass (tests, lint, Docker build)
|
||||
|
||||
### Priority Contribution Areas
|
||||
|
||||
- **Test coverage**: Expand tests for plugins and dashboard routes
|
||||
- **New plugins**: See plugin development guide below
|
||||
- **Client setup guides**: Claude Desktop, Cursor, VS Code, ChatGPT
|
||||
- **Workflow templates**: Pre-built AI workflow examples
|
||||
- **Translations**: Dashboard i18n (currently EN/FA)
|
||||
|
||||
### Adding a New Plugin
|
||||
|
||||
Create `plugins/yourplugin/` with:
|
||||
|
||||
```python
|
||||
# plugins/yourplugin/plugin.py
|
||||
from plugins.base import BasePlugin
|
||||
|
||||
class YourPlugin(BasePlugin):
|
||||
@staticmethod
|
||||
def get_plugin_name() -> str:
|
||||
return "yourplugin"
|
||||
|
||||
@staticmethod
|
||||
def get_required_config_keys() -> list[str]:
|
||||
return ["url", "api_key"]
|
||||
|
||||
@staticmethod
|
||||
def get_tool_specifications() -> list[dict]:
|
||||
return [
|
||||
{
|
||||
"name": "list_items",
|
||||
"method_name": "list_items",
|
||||
"description": "List items from YourPlatform",
|
||||
"schema": {"type": "object", "properties": {
|
||||
"limit": {"type": "integer", "default": 10}
|
||||
}},
|
||||
"scope": "read",
|
||||
}
|
||||
]
|
||||
|
||||
async def list_items(self, **kwargs):
|
||||
return await self.client.get("items", params=kwargs)
|
||||
```
|
||||
|
||||
Then register in `plugins/__init__.py` and add tests.
|
||||
|
||||
### Project Structure
|
||||
|
||||
```
|
||||
core/ # Core system (auth, site manager, tool registry, dashboard)
|
||||
plugins/ # Plugin system (9 plugins, each with handlers + schemas)
|
||||
templates/ # Jinja2 templates (dashboard + OAuth)
|
||||
tests/ # Test suite (289 tests)
|
||||
scripts/ # Setup & deployment scripts
|
||||
docs/ # Documentation
|
||||
```
|
||||
|
||||
See [CLAUDE.md](CLAUDE.md) for detailed architecture docs.
|
||||
|
||||
---
|
||||
|
||||
---
|
||||
|
||||
## License
|
||||
|
||||
By contributing, you agree that your contributions will be licensed under the [MIT License](LICENSE).
|
||||
72
Dockerfile
Normal file
72
Dockerfile
Normal file
@@ -0,0 +1,72 @@
|
||||
# ===================================
|
||||
# Coolify Projects MCP Server - Dockerfile
|
||||
# ===================================
|
||||
# Multi-stage build for optimized image size
|
||||
# Production-ready with security best practices
|
||||
# ===================================
|
||||
|
||||
# Stage 1: Build stage
|
||||
FROM python:3.12-alpine AS builder
|
||||
|
||||
# Install build dependencies
|
||||
RUN apk add --no-cache \
|
||||
gcc \
|
||||
musl-dev \
|
||||
libffi-dev \
|
||||
openssl-dev
|
||||
|
||||
# Create build directory
|
||||
WORKDIR /build
|
||||
|
||||
# Copy requirements and install Python dependencies
|
||||
COPY requirements.txt .
|
||||
RUN pip install --no-cache-dir --user -r requirements.txt
|
||||
|
||||
# Stage 2: Production stage
|
||||
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
|
||||
|
||||
# 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"]
|
||||
21
LICENSE
Normal file
21
LICENSE
Normal file
@@ -0,0 +1,21 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2025-2026 MCP Hub Contributors
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
293
README.md
Normal file
293
README.md
Normal file
@@ -0,0 +1,293 @@
|
||||
# MCP Hub
|
||||
|
||||
<div align="center">
|
||||
|
||||
**The AI-native management hub for WordPress, WooCommerce, and self-hosted services.**
|
||||
|
||||
Connect your sites, stores, repos, and databases — manage them all through Claude, ChatGPT, Cursor, or any MCP client.
|
||||
|
||||
[](LICENSE)
|
||||
[](https://www.python.org/)
|
||||
[]()
|
||||
[]()
|
||||
|
||||
</div>
|
||||
|
||||
---
|
||||
|
||||
## Why MCP Hub?
|
||||
|
||||
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 and only 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:
|
||||
|
||||
> *"Update the SEO meta description for all WooCommerce products that don't have one"*
|
||||
>
|
||||
> *"Create a new blog post about our Black Friday sale and schedule it for next Monday"*
|
||||
>
|
||||
> *"Check the health of all 12 WordPress sites and report any with slow response times"*
|
||||
|
||||
### What Makes MCP Hub Different
|
||||
|
||||
| Feature | ManageWP | MainWP | AI Content Plugins | **MCP Hub** |
|
||||
|---------|----------|--------|---------------------|-------------|
|
||||
| Multi-site management | Yes | Yes | No | **Yes** |
|
||||
| AI agent integration | No | No | No | **Native (MCP)** |
|
||||
| Full WordPress API | Dashboard | Dashboard | Content only | **65 tools** |
|
||||
| WooCommerce management | No | Limited | No | **28 tools** |
|
||||
| Git/CI management | No | No | No | **56 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** |
|
||||
| Price | $0.70-8/site/mo | $29-79/yr | $19-79/mo | **Free** |
|
||||
|
||||
---
|
||||
|
||||
## 587 Tools Across 9 Plugins
|
||||
|
||||
| Plugin | Tools | What You Can Do |
|
||||
|--------|-------|-----------------|
|
||||
| **WordPress** | 65 | 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** | 17 | Health monitoring, API keys, project discovery |
|
||||
| **Total** | **587** | Constant count — scales to unlimited sites |
|
||||
|
||||
---
|
||||
|
||||
## Quick Start
|
||||
|
||||
### Option 1: Docker (Recommended)
|
||||
|
||||
```bash
|
||||
git clone https://github.com/mcphub/mcphub.git
|
||||
cd mcphub
|
||||
cp env.example .env
|
||||
# Edit .env with your site credentials
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
### Option 2: Python
|
||||
|
||||
```bash
|
||||
git clone https://github.com/mcphub/mcphub.git
|
||||
cd mcphub
|
||||
pip install -e .
|
||||
cp env.example .env
|
||||
# Edit .env with your site credentials
|
||||
python server.py --transport sse --port 8000
|
||||
```
|
||||
|
||||
### Configure Your Sites
|
||||
|
||||
Add site credentials to `.env`:
|
||||
|
||||
```bash
|
||||
# Master API Key (required)
|
||||
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
|
||||
```
|
||||
|
||||
### Connect Your AI Client
|
||||
|
||||
<details>
|
||||
<summary><b>Claude Desktop</b></summary>
|
||||
|
||||
Add to `claude_desktop_config.json`:
|
||||
|
||||
```json
|
||||
{
|
||||
"mcpServers": {
|
||||
"mcphub": {
|
||||
"url": "https://your-server:8000/mcp",
|
||||
"headers": {
|
||||
"Authorization": "Bearer YOUR_MASTER_API_KEY"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><b>Claude Code</b></summary>
|
||||
|
||||
Add to `.mcp.json` in your project:
|
||||
|
||||
```json
|
||||
{
|
||||
"mcpServers": {
|
||||
"mcphub": {
|
||||
"type": "sse",
|
||||
"url": "https://your-server:8000/mcp",
|
||||
"headers": {
|
||||
"Authorization": "Bearer YOUR_MASTER_API_KEY"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><b>Cursor</b></summary>
|
||||
|
||||
Go to **Settings > MCP Servers > Add Server**:
|
||||
|
||||
- **Name**: MCP Hub
|
||||
- **URL**: `https://your-server:8000/mcp`
|
||||
- **Headers**: `Authorization: Bearer YOUR_MASTER_API_KEY`
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><b>VS Code + Copilot</b></summary>
|
||||
|
||||
Add to `.vscode/mcp.json`:
|
||||
|
||||
```json
|
||||
{
|
||||
"servers": {
|
||||
"mcphub": {
|
||||
"type": "sse",
|
||||
"url": "https://your-server:8000/mcp",
|
||||
"headers": {
|
||||
"Authorization": "Bearer YOUR_MASTER_API_KEY"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><b>ChatGPT (Remote MCP)</b></summary>
|
||||
|
||||
MCP Hub supports **Open Dynamic Client Registration** (RFC 7591). ChatGPT can auto-register as an OAuth client:
|
||||
|
||||
1. Deploy MCP Hub with `OAUTH_BASE_URL` set
|
||||
2. In ChatGPT, add MCP server: `https://your-server:8000/mcp`
|
||||
3. ChatGPT auto-discovers OAuth metadata and registers
|
||||
|
||||
</details>
|
||||
|
||||
---
|
||||
|
||||
## Architecture
|
||||
|
||||
```
|
||||
/mcp → Admin endpoint (all 587 tools)
|
||||
/system/mcp → System tools only (17 tools)
|
||||
/wordpress/mcp → WordPress tools (65 tools)
|
||||
/woocommerce/mcp → WooCommerce tools (28 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)
|
||||
/project/{alias}/mcp → Per-project endpoint (auto-injects site)
|
||||
```
|
||||
|
||||
**Multi-endpoint architecture**: Give each team member or AI agent access to only the tools they need.
|
||||
|
||||
### Security
|
||||
|
||||
- **OAuth 2.1 + PKCE** (RFC 8414, 7591, 7636) with auto-registration for Claude/ChatGPT
|
||||
- **Per-project API keys** with scoped permissions (read/write/admin)
|
||||
- **Rate limiting**: 60/min, 1,000/hr, 10,000/day per client
|
||||
- **GDPR-compliant audit logging** with automatic sensitive data filtering
|
||||
- **Web dashboard** with real-time health monitoring (8 pages, EN/FA i18n)
|
||||
|
||||
---
|
||||
|
||||
## Documentation
|
||||
|
||||
| Guide | Description |
|
||||
|-------|-------------|
|
||||
| [Getting Started](docs/getting-started.md) | Full setup walkthrough |
|
||||
| [Architecture](docs/ARCHITECTURE.md) | System design and module reference |
|
||||
| [API Keys Guide](docs/API_KEYS_GUIDE.md) | Per-project API key management |
|
||||
| [OAuth Guide](docs/OAUTH_GUIDE.md) | OAuth 2.1 setup for Claude/ChatGPT |
|
||||
| [Gitea Guide](docs/GITEA_GUIDE.md) | Gitea plugin configuration |
|
||||
| [Deployment Guide](DEPLOYMENT_GUIDE.md) | Docker and Coolify deployment |
|
||||
| [Troubleshooting](docs/troubleshooting.md) | Common issues and solutions |
|
||||
| [Plugin Development](docs/PLUGIN_DEVELOPMENT.md) | Build your own plugin |
|
||||
|
||||
---
|
||||
|
||||
## Development
|
||||
|
||||
```bash
|
||||
# Install with dev dependencies
|
||||
pip install -e ".[dev]"
|
||||
|
||||
# Run tests (164 tests)
|
||||
pytest
|
||||
|
||||
# Format and lint
|
||||
black . && ruff check --fix .
|
||||
|
||||
# Run server locally
|
||||
python server.py --transport sse --port 8000
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Support This Project
|
||||
|
||||
MCP Hub is free and open-source. Development is funded by community donations.
|
||||
|
||||
**Donate via crypto** (NOWPayments): Global, no geographic restrictions.
|
||||
|
||||
| Goal | Monthly | Enables |
|
||||
|------|---------|---------|
|
||||
| Infrastructure | $50/mo | Demo hosting, CI/CD, domain |
|
||||
| Part-time maintenance | $500/mo | Updates, security patches, issue triage |
|
||||
| Active development | $2,000/mo | New plugins, features, community support |
|
||||
|
||||
---
|
||||
|
||||
## Contributing
|
||||
|
||||
We welcome contributions! See [CONTRIBUTING.md](CONTRIBUTING.md) for guidelines.
|
||||
|
||||
**Priority areas:**
|
||||
- New plugin development
|
||||
- Client setup guides
|
||||
- Workflow templates and examples
|
||||
- Test coverage expansion
|
||||
- Translations (i18n)
|
||||
|
||||
---
|
||||
|
||||
## License
|
||||
|
||||
MIT License. See [LICENSE](LICENSE).
|
||||
|
||||
---
|
||||
143
SECURITY.md
Normal file
143
SECURITY.md
Normal file
@@ -0,0 +1,143 @@
|
||||
# Security Policy
|
||||
|
||||
---
|
||||
|
||||
### Supported Versions
|
||||
|
||||
| Version | Supported | Status |
|
||||
|---------|-----------|--------|
|
||||
| 3.0.x | Yes | Active (Current) |
|
||||
| < 3.0 | No | EOL |
|
||||
|
||||
We recommend always using the latest stable version for the best security posture.
|
||||
|
||||
---
|
||||
|
||||
### Reporting Vulnerabilities
|
||||
|
||||
If you discover a security vulnerability, please report it responsibly.
|
||||
|
||||
**DO NOT** open a public issue for security vulnerabilities.
|
||||
|
||||
#### Reporting Process
|
||||
|
||||
1. **Email**: security@mcphub.dev (or hello@mcphub.dev)
|
||||
2. **Subject**: `[SECURITY] Brief description`
|
||||
3. **Include**:
|
||||
- Detailed description of the vulnerability
|
||||
- Steps to reproduce
|
||||
- Potential impact assessment
|
||||
- Suggested fix (if any)
|
||||
|
||||
#### Response Timeline
|
||||
|
||||
| Severity | Initial Response | Fix Target |
|
||||
|----------|-----------------|------------|
|
||||
| Critical | 24 hours | 7 days |
|
||||
| High | 48 hours | 30 days |
|
||||
| Medium | 1 week | 90 days |
|
||||
| Low | 2 weeks | Next release |
|
||||
|
||||
#### Recognition
|
||||
|
||||
Security researchers who responsibly disclose vulnerabilities will be credited in release notes (if desired).
|
||||
|
||||
---
|
||||
|
||||
### Security Architecture
|
||||
|
||||
#### Authentication Layers
|
||||
|
||||
| Layer | Method | Scope |
|
||||
|-------|--------|-------|
|
||||
| Master API Key | Env-based shared secret | Full admin access |
|
||||
| Per-Project API Keys | Scoped keys (read/write/admin) | Project-level access |
|
||||
| OAuth 2.1 + PKCE | RFC 8414, 7591, 7636 compliant | Client app access |
|
||||
| Dashboard Sessions | JWT-based sessions | Web UI access |
|
||||
|
||||
#### OAuth 2.1 Implementation
|
||||
|
||||
- **PKCE mandatory** (S256 only)
|
||||
- **Refresh token rotation** (one-time use)
|
||||
- **Authorization codes** are single-use
|
||||
- **Open Dynamic Client Registration** (DCR) for Claude/ChatGPT auto-registration
|
||||
- **Protected client registration** requires Master API Key
|
||||
|
||||
#### Rate Limiting
|
||||
|
||||
- 60 requests/minute per client
|
||||
- 1,000 requests/hour per client
|
||||
- 10,000 requests/day per client
|
||||
- Token bucket algorithm with automatic throttling
|
||||
|
||||
#### Audit Logging
|
||||
|
||||
- GDPR-compliant structured JSON logging
|
||||
- Sensitive data filtering (passwords, API keys masked)
|
||||
- Automatic log rotation (10MB, 5 backups)
|
||||
- Timezone-aware UTC timestamps
|
||||
|
||||
---
|
||||
|
||||
### Known Security Considerations
|
||||
|
||||
The following items are documented and tracked for improvement:
|
||||
|
||||
| Item | Risk | Mitigation | Planned Fix |
|
||||
|------|------|------------|-------------|
|
||||
| `exec()` in tool generation | Medium | Only executes internally generated code | Replace with closures |
|
||||
| `create_subprocess_shell` in WP-CLI | Medium | Only runs pre-validated Docker commands | Migrate to `create_subprocess_exec` |
|
||||
| SHA-256 for API key hashing | Low | Keys are high-entropy random strings | Migrate to bcrypt/argon2 |
|
||||
|
||||
---
|
||||
|
||||
### Security Best Practices for Deployment
|
||||
|
||||
#### Environment Variables
|
||||
|
||||
- Use `.env` file (never commit to git)
|
||||
- Set a strong `MASTER_API_KEY` (32+ characters)
|
||||
- Set `OAUTH_JWT_SECRET_KEY` explicitly (do not rely on auto-generation)
|
||||
- Set `DASHBOARD_SESSION_SECRET` explicitly
|
||||
- Rotate API keys regularly
|
||||
|
||||
#### Network Security
|
||||
|
||||
- Deploy behind a reverse proxy with TLS/HTTPS
|
||||
- Restrict access to the management port (8000)
|
||||
- Use firewall rules to limit access
|
||||
- Consider VPN for remote access
|
||||
|
||||
#### Docker Security
|
||||
|
||||
- Containers run as non-root user
|
||||
- Use read-only volume mounts where possible
|
||||
- Keep base images updated
|
||||
- Docker socket mount (`/var/run/docker.sock`) is needed for WP-CLI only — remove if not used
|
||||
|
||||
#### Monitoring
|
||||
|
||||
- Review `logs/audit.log` regularly
|
||||
- Monitor health endpoint (`GET /health`)
|
||||
- Set up alerts for error rate spikes (>10% threshold)
|
||||
|
||||
---
|
||||
|
||||
### Security Checklist
|
||||
|
||||
Before deploying to production:
|
||||
|
||||
- [ ] Strong `MASTER_API_KEY` configured (32+ characters)
|
||||
- [ ] `OAUTH_JWT_SECRET_KEY` set explicitly
|
||||
- [ ] `DASHBOARD_SESSION_SECRET` set explicitly
|
||||
- [ ] `.env` file excluded from version control
|
||||
- [ ] HTTPS enabled for all WordPress/WooCommerce sites
|
||||
- [ ] Application Passwords are strong (16+ characters)
|
||||
- [ ] WooCommerce API permissions are minimal (read-only where possible)
|
||||
- [ ] Rate limiting is active
|
||||
- [ ] Audit logging is enabled
|
||||
- [ ] Health monitoring is running
|
||||
- [ ] Docker containers run as non-root
|
||||
- [ ] All dependencies are up-to-date
|
||||
|
||||
---
|
||||
103
core/__init__.py
Normal file
103
core/__init__.py
Normal file
@@ -0,0 +1,103 @@
|
||||
"""
|
||||
Core modules for MCP server
|
||||
|
||||
Architecture:
|
||||
- Multi-Endpoint: Separate MCP endpoints for different plugin types
|
||||
- Tool Registry: Central tool management
|
||||
- Site Manager: Multi-site configuration
|
||||
- Middleware: Authentication, rate limiting, audit logging
|
||||
"""
|
||||
|
||||
# Authentication and API Keys
|
||||
from core.api_keys import APIKeyManager, get_api_key_manager
|
||||
|
||||
# Logging and Audit
|
||||
from core.audit_log import AuditLogger, EventType, LogLevel, get_audit_logger
|
||||
from core.auth import AuthManager, get_auth_manager
|
||||
|
||||
# Context Management
|
||||
from core.context import clear_api_key_context, get_api_key_context, set_api_key_context
|
||||
|
||||
# Multi-Endpoint Architecture (Phase X)
|
||||
from core.endpoints import (
|
||||
EndpointConfig,
|
||||
EndpointRegistry,
|
||||
EndpointType,
|
||||
MCPEndpointFactory,
|
||||
)
|
||||
|
||||
# Health Monitoring
|
||||
from core.health import (
|
||||
AlertThreshold,
|
||||
HealthMetric,
|
||||
HealthMonitor,
|
||||
ProjectHealthStatus,
|
||||
SystemMetrics,
|
||||
get_health_monitor,
|
||||
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
|
||||
"AuthManager",
|
||||
"get_auth_manager",
|
||||
"APIKeyManager",
|
||||
"get_api_key_manager",
|
||||
# Project/Site Management
|
||||
"ProjectManager",
|
||||
"get_project_manager",
|
||||
"SiteManager",
|
||||
"SiteConfig",
|
||||
"get_site_manager",
|
||||
# Legacy (deprecated)
|
||||
"SiteRegistry",
|
||||
"SiteInfo",
|
||||
"get_site_registry",
|
||||
"UnifiedToolGenerator",
|
||||
# Tool Management
|
||||
"ToolRegistry",
|
||||
"ToolDefinition",
|
||||
"get_tool_registry",
|
||||
"ToolGenerator",
|
||||
# Multi-Endpoint Architecture
|
||||
"EndpointConfig",
|
||||
"EndpointType",
|
||||
"MCPEndpointFactory",
|
||||
"EndpointRegistry",
|
||||
# Logging
|
||||
"AuditLogger",
|
||||
"get_audit_logger",
|
||||
"LogLevel",
|
||||
"EventType",
|
||||
# Context
|
||||
"set_api_key_context",
|
||||
"get_api_key_context",
|
||||
"clear_api_key_context",
|
||||
# Health
|
||||
"HealthMonitor",
|
||||
"HealthMetric",
|
||||
"SystemMetrics",
|
||||
"ProjectHealthStatus",
|
||||
"AlertThreshold",
|
||||
"get_health_monitor",
|
||||
"initialize_health_monitor",
|
||||
# Rate Limiting
|
||||
"RateLimiter",
|
||||
"get_rate_limiter",
|
||||
"RateLimitConfig",
|
||||
]
|
||||
499
core/api_keys.py
Normal file
499
core/api_keys.py
Normal file
@@ -0,0 +1,499 @@
|
||||
"""
|
||||
API Key Management System
|
||||
|
||||
Comprehensive per-project API key management with scopes, expiration,
|
||||
and audit trail.
|
||||
"""
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import secrets
|
||||
from dataclasses import asdict, dataclass
|
||||
from datetime import datetime, timedelta
|
||||
from pathlib import Path
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Valid scope values
|
||||
VALID_SCOPES = ["read", "write", "admin"]
|
||||
|
||||
# Scope can be single ("read") or multiple space-separated ("read write admin")
|
||||
Scope = str
|
||||
|
||||
def validate_scope(scope: str) -> bool:
|
||||
"""
|
||||
Validate that scope contains only valid scope values.
|
||||
|
||||
Args:
|
||||
scope: Single scope ("read") or space-separated scopes ("read write admin")
|
||||
|
||||
Returns:
|
||||
True if all scopes are valid, False otherwise
|
||||
"""
|
||||
if not scope:
|
||||
return False
|
||||
|
||||
scope_list = scope.split()
|
||||
return all(s in VALID_SCOPES for s in scope_list)
|
||||
|
||||
def normalize_scope(scope: str) -> str:
|
||||
"""
|
||||
Normalize scope string by removing duplicates and sorting.
|
||||
|
||||
Args:
|
||||
scope: Single scope or space-separated scopes
|
||||
|
||||
Returns:
|
||||
Normalized scope string (e.g., "admin read write" -> "read write admin")
|
||||
"""
|
||||
scope_list = scope.split()
|
||||
# Remove duplicates, sort by priority (read < write < admin)
|
||||
unique_scopes = []
|
||||
for s in ["read", "write", "admin"]:
|
||||
if s in scope_list:
|
||||
unique_scopes.append(s)
|
||||
return " ".join(unique_scopes)
|
||||
|
||||
@dataclass
|
||||
class APIKey:
|
||||
"""
|
||||
Represents an API key with metadata.
|
||||
|
||||
Fields:
|
||||
key_id: Unique identifier for the key
|
||||
key_hash: SHA256 hash of the actual key (for storage)
|
||||
project_id: Project this key belongs to ("*" for all projects)
|
||||
scope: Access scope - single ("read") or multiple space-separated ("read write admin")
|
||||
created_at: ISO timestamp when created
|
||||
expires_at: Optional ISO timestamp when expires
|
||||
last_used_at: Optional ISO timestamp of last use
|
||||
usage_count: Number of times used
|
||||
description: Optional description
|
||||
revoked: Whether the key has been revoked
|
||||
"""
|
||||
|
||||
key_id: str
|
||||
key_hash: str
|
||||
project_id: str
|
||||
scope: Scope
|
||||
created_at: str
|
||||
expires_at: str | None = None
|
||||
last_used_at: str | None = None
|
||||
usage_count: int = 0
|
||||
description: str | None = None
|
||||
revoked: bool = False
|
||||
|
||||
def to_dict(self) -> dict:
|
||||
"""Convert to dictionary for JSON serialization."""
|
||||
return asdict(self)
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, data: dict) -> "APIKey":
|
||||
"""Create from dictionary."""
|
||||
return cls(**data)
|
||||
|
||||
def is_expired(self) -> bool:
|
||||
"""Check if key has expired."""
|
||||
if not self.expires_at:
|
||||
return False
|
||||
expires = datetime.fromisoformat(self.expires_at)
|
||||
return datetime.now() > expires
|
||||
|
||||
def is_valid(self) -> bool:
|
||||
"""Check if key is valid (not revoked, not expired)."""
|
||||
return not self.revoked and not self.is_expired()
|
||||
|
||||
class APIKeyManager:
|
||||
"""
|
||||
Manages per-project API keys with persistence.
|
||||
|
||||
Features:
|
||||
- Persistent JSON storage
|
||||
- Key creation with scopes
|
||||
- Key validation with project and scope checking
|
||||
- Key rotation and revocation
|
||||
- Usage tracking
|
||||
- Expiration support
|
||||
"""
|
||||
|
||||
def __init__(self, storage_path: str = "data/api_keys.json"):
|
||||
"""
|
||||
Initialize API Key Manager.
|
||||
|
||||
Args:
|
||||
storage_path: Path to JSON file for key storage
|
||||
"""
|
||||
self.storage_path = Path(storage_path)
|
||||
self.keys: dict[str, APIKey] = {}
|
||||
|
||||
# Ensure storage directory exists (with graceful fallback)
|
||||
try:
|
||||
self.storage_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
except PermissionError:
|
||||
# Fallback to /tmp if we can't create in data/
|
||||
logger.warning(
|
||||
f"Cannot create directory {self.storage_path.parent}, " f"falling back to /tmp"
|
||||
)
|
||||
self.storage_path = Path("/tmp/api_keys.json")
|
||||
self.storage_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# Load existing keys
|
||||
self._load_keys()
|
||||
|
||||
logger.info(
|
||||
f"API Key Manager initialized with {len(self.keys)} keys "
|
||||
f"(storage: {self.storage_path})"
|
||||
)
|
||||
|
||||
def _load_keys(self) -> None:
|
||||
"""Load keys from storage file."""
|
||||
if not self.storage_path.exists():
|
||||
logger.info("No existing keys file found, starting fresh")
|
||||
return
|
||||
|
||||
try:
|
||||
with open(self.storage_path) as f:
|
||||
data = json.load(f)
|
||||
self.keys = {
|
||||
key_id: APIKey.from_dict(key_data) for key_id, key_data in data.items()
|
||||
}
|
||||
logger.info(f"Loaded {len(self.keys)} keys from storage")
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to load keys: {e}")
|
||||
self.keys = {}
|
||||
|
||||
def _save_keys(self) -> None:
|
||||
"""Save keys to storage file."""
|
||||
try:
|
||||
data = {key_id: key.to_dict() for key_id, key in self.keys.items()}
|
||||
with open(self.storage_path, "w") as f:
|
||||
json.dump(data, f, indent=2)
|
||||
logger.debug(f"Saved {len(self.keys)} keys to storage")
|
||||
except Exception as e:
|
||||
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()
|
||||
|
||||
def create_key(
|
||||
self,
|
||||
project_id: str,
|
||||
scope: Scope = "read",
|
||||
expires_in_days: int | None = None,
|
||||
description: str | None = None,
|
||||
) -> dict[str, str]:
|
||||
"""
|
||||
Create a new API key.
|
||||
|
||||
Args:
|
||||
project_id: Project ID ("*" for all projects)
|
||||
scope: Access scope - single ("read") or multiple space-separated ("read write admin")
|
||||
expires_in_days: Optional expiration in days
|
||||
description: Optional description
|
||||
|
||||
Returns:
|
||||
dict: {"key": actual_key, "key_id": key_id, "project_id": project_id, "scope": scope}
|
||||
|
||||
Raises:
|
||||
ValueError: If scope contains invalid values
|
||||
"""
|
||||
# Validate and normalize scope
|
||||
if not validate_scope(scope):
|
||||
raise ValueError(
|
||||
f"Invalid scope: {scope}. Must contain only: {', '.join(VALID_SCOPES)}"
|
||||
)
|
||||
normalized_scope = normalize_scope(scope)
|
||||
|
||||
# Generate secure random key
|
||||
api_key = f"cmp_{secrets.token_urlsafe(32)}"
|
||||
key_id = f"key_{secrets.token_urlsafe(16)}"
|
||||
key_hash = self._hash_key(api_key)
|
||||
|
||||
# Calculate expiration
|
||||
expires_at = None
|
||||
if expires_in_days:
|
||||
expires = datetime.now() + timedelta(days=expires_in_days)
|
||||
expires_at = expires.isoformat()
|
||||
|
||||
# Create key object
|
||||
key = APIKey(
|
||||
key_id=key_id,
|
||||
key_hash=key_hash,
|
||||
project_id=project_id,
|
||||
scope=normalized_scope,
|
||||
created_at=datetime.now().isoformat(),
|
||||
expires_at=expires_at,
|
||||
description=description,
|
||||
)
|
||||
|
||||
# Store and save
|
||||
self.keys[key_id] = key
|
||||
self._save_keys()
|
||||
|
||||
logger.info(
|
||||
f"Created API key {key_id} for project {project_id} " f"with scope '{normalized_scope}'"
|
||||
)
|
||||
|
||||
return {
|
||||
"key": api_key,
|
||||
"key_id": key_id,
|
||||
"scope": normalized_scope,
|
||||
"project_id": project_id,
|
||||
"expires_at": expires_at,
|
||||
}
|
||||
|
||||
def validate_key(
|
||||
self,
|
||||
api_key: str,
|
||||
project_id: str,
|
||||
required_scope: Scope = "read",
|
||||
skip_project_check: bool = False,
|
||||
) -> str | None:
|
||||
"""
|
||||
Validate API key for project and scope.
|
||||
|
||||
Args:
|
||||
api_key: The API key to validate
|
||||
project_id: Project to check access for
|
||||
required_scope: Minimum required scope
|
||||
skip_project_check: Skip project-level validation (for unified tools)
|
||||
|
||||
Returns:
|
||||
Optional[str]: key_id if valid, None otherwise
|
||||
"""
|
||||
key_hash = self._hash_key(api_key)
|
||||
|
||||
# Find key by hash
|
||||
for key_id, key in self.keys.items():
|
||||
if key.key_hash != key_hash:
|
||||
continue
|
||||
|
||||
# Check if valid (not revoked, not expired)
|
||||
if not key.is_valid():
|
||||
logger.warning(
|
||||
f"Key {key_id} is invalid "
|
||||
f"(revoked={key.revoked}, expired={key.is_expired()})"
|
||||
)
|
||||
return None
|
||||
|
||||
# Check project access (unless skipped for unified tools)
|
||||
if not skip_project_check:
|
||||
if key.project_id != "*" and key.project_id != project_id:
|
||||
logger.warning(f"Key {key_id} does not have access to project {project_id}")
|
||||
return None
|
||||
|
||||
# Check scope: key must have required_scope or higher
|
||||
# Scope hierarchy: admin > write > read
|
||||
scope_hierarchy = {"read": 0, "write": 1, "admin": 2}
|
||||
key_scopes = key.scope.split()
|
||||
|
||||
# Check if required_scope is directly present
|
||||
if required_scope in key_scopes:
|
||||
# Update usage tracking
|
||||
key.last_used_at = datetime.now().isoformat()
|
||||
key.usage_count += 1
|
||||
self._save_keys()
|
||||
|
||||
logger.debug(f"Key {key_id} validated successfully (scope: {key.scope})")
|
||||
return key_id
|
||||
|
||||
# Check if key has higher scope (e.g., admin covers write and read)
|
||||
key_level = max(scope_hierarchy.get(s, 0) for s in key_scopes)
|
||||
required_level = scope_hierarchy.get(required_scope, 0)
|
||||
|
||||
if key_level >= required_level:
|
||||
# Update usage tracking
|
||||
key.last_used_at = datetime.now().isoformat()
|
||||
key.usage_count += 1
|
||||
self._save_keys()
|
||||
|
||||
logger.debug(f"Key {key_id} validated successfully (scope: {key.scope})")
|
||||
return key_id
|
||||
|
||||
logger.warning(
|
||||
f"Key {key_id} has insufficient scope "
|
||||
f"({key.scope} does not include {required_scope})"
|
||||
)
|
||||
return None
|
||||
|
||||
logger.warning("No matching API key found")
|
||||
return None
|
||||
|
||||
def get_key_by_token(self, api_key: str) -> APIKey | None:
|
||||
"""
|
||||
Get API key object by token (without project validation).
|
||||
|
||||
This method looks up an API key by its raw token value and returns
|
||||
the APIKey object if found. Unlike validate_key(), it does not
|
||||
validate against a specific project or scope.
|
||||
|
||||
Args:
|
||||
api_key: The raw API key token (e.g., "cmp_xxx...")
|
||||
|
||||
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:
|
||||
logger.debug(f"Found API key {key_id} by token")
|
||||
return key
|
||||
|
||||
logger.debug("No API key found for provided token")
|
||||
return None
|
||||
|
||||
def revoke_key(self, key_id: str) -> bool:
|
||||
"""
|
||||
Revoke an API key.
|
||||
|
||||
Args:
|
||||
key_id: Key ID to revoke
|
||||
|
||||
Returns:
|
||||
bool: True if revoked successfully
|
||||
"""
|
||||
if key_id not in self.keys:
|
||||
logger.warning(f"Key {key_id} not found")
|
||||
return False
|
||||
|
||||
self.keys[key_id].revoked = True
|
||||
self._save_keys()
|
||||
|
||||
logger.info(f"Revoked API key {key_id}")
|
||||
return True
|
||||
|
||||
def delete_key(self, key_id: str) -> bool:
|
||||
"""
|
||||
Permanently delete an API key.
|
||||
|
||||
Args:
|
||||
key_id: Key ID to delete
|
||||
|
||||
Returns:
|
||||
bool: True if deleted successfully
|
||||
"""
|
||||
if key_id not in self.keys:
|
||||
logger.warning(f"Key {key_id} not found")
|
||||
return False
|
||||
|
||||
del self.keys[key_id]
|
||||
self._save_keys()
|
||||
|
||||
logger.info(f"Deleted API key {key_id}")
|
||||
return True
|
||||
|
||||
def list_keys(self, project_id: str | None = None, include_revoked: bool = False) -> list[dict]:
|
||||
"""
|
||||
List API keys.
|
||||
|
||||
Args:
|
||||
project_id: Optional filter by project
|
||||
include_revoked: Include revoked keys
|
||||
|
||||
Returns:
|
||||
List of key information (without actual keys)
|
||||
"""
|
||||
keys = []
|
||||
|
||||
for key_id, key in self.keys.items():
|
||||
# Filter by project
|
||||
if project_id and key.project_id != project_id and key.project_id != "*":
|
||||
continue
|
||||
|
||||
# Filter revoked
|
||||
if not include_revoked and key.revoked:
|
||||
continue
|
||||
|
||||
keys.append(
|
||||
{
|
||||
"key_id": key_id,
|
||||
"project_id": key.project_id,
|
||||
"scope": key.scope,
|
||||
"created_at": key.created_at,
|
||||
"expires_at": key.expires_at,
|
||||
"last_used_at": key.last_used_at,
|
||||
"usage_count": key.usage_count,
|
||||
"description": key.description,
|
||||
"revoked": key.revoked,
|
||||
"expired": key.is_expired(),
|
||||
"valid": key.is_valid(),
|
||||
}
|
||||
)
|
||||
|
||||
return keys
|
||||
|
||||
def rotate_keys(self, project_id: str) -> list[dict[str, str]]:
|
||||
"""
|
||||
Rotate all keys for a project.
|
||||
|
||||
Creates new keys with same scopes and revokes old ones.
|
||||
|
||||
Args:
|
||||
project_id: Project to rotate keys for
|
||||
|
||||
Returns:
|
||||
List of new key information
|
||||
"""
|
||||
old_keys = [
|
||||
key for key in self.keys.values() if key.project_id == project_id and key.is_valid()
|
||||
]
|
||||
|
||||
new_keys = []
|
||||
|
||||
for old_key in old_keys:
|
||||
# Create new key with same scope
|
||||
new_key_data = self.create_key(
|
||||
project_id=project_id,
|
||||
scope=old_key.scope,
|
||||
description=f"Rotated from {old_key.key_id}",
|
||||
)
|
||||
new_keys.append(new_key_data)
|
||||
|
||||
# Revoke old key
|
||||
self.revoke_key(old_key.key_id)
|
||||
|
||||
logger.info(f"Rotated {len(new_keys)} keys for project {project_id}")
|
||||
return new_keys
|
||||
|
||||
def get_key_info(self, key_id: str) -> dict | None:
|
||||
"""
|
||||
Get information about a specific key.
|
||||
|
||||
Args:
|
||||
key_id: Key ID
|
||||
|
||||
Returns:
|
||||
Key information or None
|
||||
"""
|
||||
if key_id not in self.keys:
|
||||
return None
|
||||
|
||||
key = self.keys[key_id]
|
||||
return {
|
||||
"key_id": key_id,
|
||||
"project_id": key.project_id,
|
||||
"scope": key.scope,
|
||||
"created_at": key.created_at,
|
||||
"expires_at": key.expires_at,
|
||||
"last_used_at": key.last_used_at,
|
||||
"usage_count": key.usage_count,
|
||||
"description": key.description,
|
||||
"revoked": key.revoked,
|
||||
"expired": key.is_expired(),
|
||||
"valid": key.is_valid(),
|
||||
}
|
||||
|
||||
# Global instance
|
||||
_api_key_manager: APIKeyManager | None = None
|
||||
|
||||
def get_api_key_manager() -> APIKeyManager:
|
||||
"""Get the global API Key Manager instance."""
|
||||
global _api_key_manager
|
||||
if _api_key_manager is None:
|
||||
storage_path = os.getenv("API_KEYS_STORAGE", "data/api_keys.json")
|
||||
_api_key_manager = APIKeyManager(storage_path)
|
||||
return _api_key_manager
|
||||
565
core/audit_log.py
Normal file
565
core/audit_log.py
Normal file
@@ -0,0 +1,565 @@
|
||||
"""
|
||||
Audit Logging System
|
||||
|
||||
Comprehensive audit logging for all MCP operations.
|
||||
Tracks tool calls, authentication attempts, and system events.
|
||||
|
||||
Features:
|
||||
- Structured JSON logging
|
||||
- Log rotation support
|
||||
- Query and filter capabilities
|
||||
- Export to JSON/CSV
|
||||
- GDPR-compliant (no sensitive data in logs)
|
||||
"""
|
||||
|
||||
import json
|
||||
import logging
|
||||
from datetime import UTC, datetime
|
||||
from enum import Enum
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
class LogLevel(Enum):
|
||||
"""Log severity levels."""
|
||||
|
||||
INFO = "INFO"
|
||||
WARNING = "WARNING"
|
||||
ERROR = "ERROR"
|
||||
CRITICAL = "CRITICAL"
|
||||
|
||||
class EventType(Enum):
|
||||
"""Types of events to log."""
|
||||
|
||||
TOOL_CALL = "tool_call"
|
||||
AUTHENTICATION = "authentication"
|
||||
HEALTH_CHECK = "health_check"
|
||||
ERROR = "error"
|
||||
SYSTEM = "system"
|
||||
|
||||
class AuditLogger:
|
||||
"""
|
||||
Audit logging system for MCP operations.
|
||||
|
||||
Logs all important events to a structured JSON log file with
|
||||
rotation support and query capabilities.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
log_dir: str = "logs",
|
||||
log_file: str = "audit.log",
|
||||
max_file_size_mb: int = 10,
|
||||
backup_count: int = 5,
|
||||
):
|
||||
"""
|
||||
Initialize audit logger.
|
||||
|
||||
Args:
|
||||
log_dir: Directory for log files
|
||||
log_file: Log file name
|
||||
max_file_size_mb: Max size before rotation (MB)
|
||||
backup_count: Number of backup files to keep
|
||||
"""
|
||||
# Setup Python logger for internal logging
|
||||
self.logger = logging.getLogger("AuditLogger")
|
||||
|
||||
# Try to create log directory, fallback to /tmp if permission denied
|
||||
self.log_dir = Path(log_dir)
|
||||
|
||||
try:
|
||||
self.log_dir.mkdir(parents=True, exist_ok=True)
|
||||
except PermissionError:
|
||||
# Fallback to /tmp/logs for Docker containers
|
||||
self.logger.warning(f"Permission denied for {log_dir}, using /tmp/logs instead")
|
||||
self.log_dir = Path("/tmp/logs")
|
||||
self.log_dir.mkdir(parents=True, exist_ok=True)
|
||||
except Exception as e:
|
||||
self.logger.error(f"Failed to create log directory: {e}, using /tmp/logs")
|
||||
self.log_dir = Path("/tmp/logs")
|
||||
try:
|
||||
self.log_dir.mkdir(parents=True, exist_ok=True)
|
||||
except Exception as e2:
|
||||
self.logger.critical(f"Cannot create any log directory: {e2}, logging disabled")
|
||||
# Set to None to disable file logging
|
||||
self.log_dir = None
|
||||
|
||||
if self.log_dir:
|
||||
self.log_file = self.log_dir / log_file
|
||||
self.max_file_size = max_file_size_mb * 1024 * 1024 # Convert to bytes
|
||||
self.backup_count = backup_count
|
||||
self.logger.info(f"Audit logger initialized: {self.log_file}")
|
||||
else:
|
||||
self.log_file = None
|
||||
self.max_file_size = 0
|
||||
self.backup_count = 0
|
||||
self.logger.warning("Audit logging to file is disabled due to permission errors")
|
||||
|
||||
def _rotate_logs_if_needed(self) -> None:
|
||||
"""Rotate log files if size exceeds limit."""
|
||||
if not self.log_file or not self.log_file.exists():
|
||||
return
|
||||
|
||||
if self.log_file.stat().st_size >= self.max_file_size:
|
||||
# Rotate existing backup files
|
||||
for i in range(self.backup_count - 1, 0, -1):
|
||||
old_backup = self.log_dir / f"{self.log_file.name}.{i}"
|
||||
new_backup = self.log_dir / f"{self.log_file.name}.{i + 1}"
|
||||
|
||||
if old_backup.exists():
|
||||
if new_backup.exists():
|
||||
new_backup.unlink() # Delete oldest if at limit
|
||||
old_backup.rename(new_backup)
|
||||
|
||||
# Move current log to .1
|
||||
backup = self.log_dir / f"{self.log_file.name}.1"
|
||||
if backup.exists():
|
||||
backup.unlink()
|
||||
self.log_file.rename(backup)
|
||||
|
||||
self.logger.info(f"Log rotated: {self.log_file}")
|
||||
|
||||
def _write_log_entry(self, entry: dict[str, Any]) -> None:
|
||||
"""
|
||||
Write a log entry to file.
|
||||
|
||||
Args:
|
||||
entry: Log entry dictionary
|
||||
"""
|
||||
# Skip if logging is disabled
|
||||
if not self.log_file:
|
||||
return
|
||||
|
||||
self._rotate_logs_if_needed()
|
||||
|
||||
try:
|
||||
with open(self.log_file, "a", encoding="utf-8") as f:
|
||||
# Write as JSON line
|
||||
json.dump(entry, f, ensure_ascii=False)
|
||||
f.write("\n")
|
||||
except Exception as e:
|
||||
self.logger.error(f"Failed to write audit log: {e}", exc_info=True)
|
||||
|
||||
def log_tool_call(
|
||||
self,
|
||||
tool_name: str,
|
||||
site: str | None = None,
|
||||
project_id: str | None = None,
|
||||
params: dict[str, Any] | None = None,
|
||||
result_summary: str | None = None,
|
||||
error: str | None = None,
|
||||
duration_ms: int | None = None,
|
||||
user_id: str | None = None,
|
||||
) -> None:
|
||||
"""
|
||||
Log a tool call.
|
||||
|
||||
Args:
|
||||
tool_name: Name of the tool called
|
||||
site: Site ID or alias (for unified tools)
|
||||
project_id: Full project ID (for per-site tools)
|
||||
params: Tool parameters (sensitive data should be filtered)
|
||||
result_summary: Brief summary of result (not full response)
|
||||
error: Error message if failed
|
||||
duration_ms: Execution duration in milliseconds
|
||||
user_id: User identifier (if available)
|
||||
"""
|
||||
# Filter sensitive data from params
|
||||
safe_params = self._filter_sensitive_data(params) if params else None
|
||||
|
||||
entry = {
|
||||
"timestamp": datetime.now(UTC).isoformat(),
|
||||
"event_type": EventType.TOOL_CALL.value,
|
||||
"level": LogLevel.ERROR.value if error else LogLevel.INFO.value,
|
||||
"tool_name": tool_name,
|
||||
"site": site,
|
||||
"project_id": project_id,
|
||||
"params": safe_params,
|
||||
"result_summary": result_summary,
|
||||
"error": error,
|
||||
"duration_ms": duration_ms,
|
||||
"user_id": user_id,
|
||||
"success": error is None,
|
||||
}
|
||||
|
||||
self._write_log_entry(entry)
|
||||
|
||||
def log_authentication(
|
||||
self,
|
||||
success: bool,
|
||||
project_id: str | None = None,
|
||||
reason: str | None = None,
|
||||
ip_address: str | None = None,
|
||||
) -> None:
|
||||
"""
|
||||
Log an authentication attempt.
|
||||
|
||||
Args:
|
||||
success: Whether authentication succeeded
|
||||
project_id: Project being accessed (if known)
|
||||
reason: Failure reason if unsuccessful
|
||||
ip_address: Client IP address
|
||||
"""
|
||||
entry = {
|
||||
"timestamp": datetime.now(UTC).isoformat(),
|
||||
"event_type": EventType.AUTHENTICATION.value,
|
||||
"level": LogLevel.WARNING.value if not success else LogLevel.INFO.value,
|
||||
"success": success,
|
||||
"project_id": project_id,
|
||||
"reason": reason,
|
||||
"ip_address": ip_address,
|
||||
}
|
||||
|
||||
self._write_log_entry(entry)
|
||||
|
||||
def log_error(
|
||||
self,
|
||||
error_type: str,
|
||||
error_message: str,
|
||||
context: dict[str, Any] | None = None,
|
||||
stack_trace: str | None = None,
|
||||
) -> None:
|
||||
"""
|
||||
Log an error event.
|
||||
|
||||
Args:
|
||||
error_type: Type of error (e.g., 'ValidationError', 'APIError')
|
||||
error_message: Error message
|
||||
context: Additional context
|
||||
stack_trace: Stack trace if available
|
||||
"""
|
||||
entry = {
|
||||
"timestamp": datetime.now(UTC).isoformat(),
|
||||
"event_type": EventType.ERROR.value,
|
||||
"level": LogLevel.ERROR.value,
|
||||
"error_type": error_type,
|
||||
"error_message": error_message,
|
||||
"context": context,
|
||||
"stack_trace": stack_trace,
|
||||
}
|
||||
|
||||
self._write_log_entry(entry)
|
||||
|
||||
def log_system_event(
|
||||
self, event: str, details: dict[str, Any] | None = None, level: LogLevel = LogLevel.INFO
|
||||
) -> None:
|
||||
"""
|
||||
Log a system event.
|
||||
|
||||
Args:
|
||||
event: Event description
|
||||
details: Event details
|
||||
level: Log level
|
||||
"""
|
||||
entry = {
|
||||
"timestamp": datetime.now(UTC).isoformat(),
|
||||
"event_type": EventType.SYSTEM.value,
|
||||
"level": level.value,
|
||||
"event": event,
|
||||
"details": details,
|
||||
}
|
||||
|
||||
self._write_log_entry(entry)
|
||||
|
||||
def _filter_sensitive_data(self, data: dict[str, Any]) -> dict[str, Any]:
|
||||
"""
|
||||
Filter sensitive data from logs (GDPR compliance).
|
||||
|
||||
Args:
|
||||
data: Data to filter
|
||||
|
||||
Returns:
|
||||
Filtered data with sensitive fields masked
|
||||
"""
|
||||
if not data:
|
||||
return {}
|
||||
|
||||
sensitive_keys = {
|
||||
"password",
|
||||
"app_password",
|
||||
"token",
|
||||
"api_key",
|
||||
"secret",
|
||||
"credential",
|
||||
"auth",
|
||||
"private_key",
|
||||
"access_token",
|
||||
"refresh_token",
|
||||
}
|
||||
|
||||
filtered = {}
|
||||
for key, value in data.items():
|
||||
# Check if key contains sensitive words
|
||||
if any(sensitive in key.lower() for sensitive in sensitive_keys):
|
||||
filtered[key] = "[REDACTED]"
|
||||
elif isinstance(value, dict):
|
||||
filtered[key] = self._filter_sensitive_data(value)
|
||||
else:
|
||||
filtered[key] = value
|
||||
|
||||
return filtered
|
||||
|
||||
def get_logs(
|
||||
self,
|
||||
event_type: EventType | None = None,
|
||||
start_time: datetime | None = None,
|
||||
end_time: datetime | None = None,
|
||||
level: LogLevel | None = None,
|
||||
project_id: str | None = None,
|
||||
tool_name: str | None = None,
|
||||
success_only: bool | None = None,
|
||||
limit: int = 100,
|
||||
) -> list[dict[str, Any]]:
|
||||
"""
|
||||
Query audit logs with filters.
|
||||
|
||||
Args:
|
||||
event_type: Filter by event type
|
||||
start_time: Start of time range
|
||||
end_time: End of time range
|
||||
level: Filter by log level
|
||||
project_id: Filter by project
|
||||
tool_name: Filter by tool name
|
||||
success_only: Only successful operations
|
||||
limit: Maximum number of entries to return
|
||||
|
||||
Returns:
|
||||
List of log entries matching filters
|
||||
"""
|
||||
if not self.log_file or not self.log_file.exists():
|
||||
return []
|
||||
|
||||
results = []
|
||||
|
||||
try:
|
||||
with open(self.log_file, encoding="utf-8") as f:
|
||||
for line in f:
|
||||
if not line.strip():
|
||||
continue
|
||||
|
||||
try:
|
||||
entry = json.loads(line)
|
||||
|
||||
# Apply filters
|
||||
if event_type and entry.get("event_type") != event_type.value:
|
||||
continue
|
||||
|
||||
if level and entry.get("level") != level.value:
|
||||
continue
|
||||
|
||||
if project_id and entry.get("project_id") != project_id:
|
||||
continue
|
||||
|
||||
if tool_name and entry.get("tool_name") != tool_name:
|
||||
continue
|
||||
|
||||
if success_only is not None:
|
||||
if entry.get("success") != success_only:
|
||||
continue
|
||||
|
||||
# Time range filter
|
||||
if start_time or end_time:
|
||||
entry_time = datetime.fromisoformat(entry.get("timestamp", ""))
|
||||
|
||||
if start_time and entry_time < start_time:
|
||||
continue
|
||||
|
||||
if end_time and entry_time > end_time:
|
||||
continue
|
||||
|
||||
results.append(entry)
|
||||
|
||||
if len(results) >= limit:
|
||||
break
|
||||
|
||||
except json.JSONDecodeError:
|
||||
self.logger.warning(f"Invalid JSON in log: {line[:50]}...")
|
||||
continue
|
||||
|
||||
except Exception as e:
|
||||
self.logger.error(f"Error reading logs: {e}", exc_info=True)
|
||||
|
||||
return results
|
||||
|
||||
def export_logs(self, output_path: str, format: str = "json", **filter_kwargs) -> bool:
|
||||
"""
|
||||
Export logs to a file.
|
||||
|
||||
Args:
|
||||
output_path: Output file path
|
||||
format: Export format ('json' or 'csv')
|
||||
**filter_kwargs: Filters to apply (same as get_logs)
|
||||
|
||||
Returns:
|
||||
True if successful
|
||||
"""
|
||||
logs = self.get_logs(**filter_kwargs)
|
||||
|
||||
try:
|
||||
if format == "json":
|
||||
with open(output_path, "w", encoding="utf-8") as f:
|
||||
json.dump(logs, f, indent=2, ensure_ascii=False)
|
||||
|
||||
elif format == "csv":
|
||||
import csv
|
||||
|
||||
if not logs:
|
||||
return False
|
||||
|
||||
# Get all unique keys from logs
|
||||
keys = set()
|
||||
for log in logs:
|
||||
keys.update(log.keys())
|
||||
|
||||
with open(output_path, "w", newline="", encoding="utf-8") as f:
|
||||
writer = csv.DictWriter(f, fieldnames=sorted(keys))
|
||||
writer.writeheader()
|
||||
writer.writerows(logs)
|
||||
|
||||
else:
|
||||
raise ValueError(f"Unsupported format: {format}")
|
||||
|
||||
self.logger.info(f"Exported {len(logs)} logs to {output_path}")
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
self.logger.error(f"Error exporting logs: {e}", exc_info=True)
|
||||
return False
|
||||
|
||||
def get_recent_entries(self, limit: int = 10) -> list[dict[str, Any]]:
|
||||
"""
|
||||
Get the most recent log entries.
|
||||
|
||||
Args:
|
||||
limit: Maximum number of entries to return
|
||||
|
||||
Returns:
|
||||
List of recent log entries (newest first)
|
||||
"""
|
||||
if not self.log_file or not self.log_file.exists():
|
||||
return []
|
||||
|
||||
entries = []
|
||||
|
||||
try:
|
||||
with open(self.log_file, encoding="utf-8") as f:
|
||||
# Read all lines and get the last N
|
||||
lines = f.readlines()
|
||||
|
||||
# Process lines in reverse order
|
||||
for line in reversed(lines):
|
||||
if not line.strip():
|
||||
continue
|
||||
|
||||
try:
|
||||
entry = json.loads(line)
|
||||
|
||||
# Format the entry for display
|
||||
formatted_entry = {
|
||||
"timestamp": entry.get("timestamp", ""),
|
||||
"event_type": entry.get("event_type", "unknown"),
|
||||
"level": entry.get("level", "INFO"),
|
||||
"message": self._format_log_message(entry),
|
||||
"metadata": {
|
||||
"project_id": entry.get("project_id"),
|
||||
"tool_name": entry.get("tool_name"),
|
||||
"site": entry.get("site"),
|
||||
"duration_ms": entry.get("duration_ms"),
|
||||
"success": entry.get("success"),
|
||||
},
|
||||
}
|
||||
|
||||
entries.append(formatted_entry)
|
||||
|
||||
if len(entries) >= limit:
|
||||
break
|
||||
|
||||
except json.JSONDecodeError:
|
||||
continue
|
||||
|
||||
except Exception as e:
|
||||
self.logger.error(f"Error reading recent logs: {e}", exc_info=True)
|
||||
|
||||
return entries
|
||||
|
||||
def _format_log_message(self, entry: dict[str, Any]) -> str:
|
||||
"""Format a log entry into a human-readable message."""
|
||||
event_type = entry.get("event_type", "")
|
||||
|
||||
if event_type == EventType.TOOL_CALL.value:
|
||||
tool_name = entry.get("tool_name", "unknown")
|
||||
if entry.get("error"):
|
||||
return f"{tool_name} failed: {entry.get('error', '')[:50]}"
|
||||
return f"{tool_name}"
|
||||
|
||||
elif event_type == EventType.AUTHENTICATION.value:
|
||||
if entry.get("success"):
|
||||
return "Authentication successful"
|
||||
return f"Authentication failed: {entry.get('reason', 'unknown')}"
|
||||
|
||||
elif event_type == EventType.ERROR.value:
|
||||
return f"{entry.get('error_type', 'Error')}: {entry.get('error_message', '')[:50]}"
|
||||
|
||||
elif event_type == EventType.SYSTEM.value:
|
||||
return entry.get("event", "System event")
|
||||
|
||||
return entry.get("event", entry.get("message", "Unknown event"))
|
||||
|
||||
def get_statistics(self) -> dict[str, Any]:
|
||||
"""
|
||||
Get statistics about audit logs.
|
||||
|
||||
Returns:
|
||||
Dictionary with statistics
|
||||
"""
|
||||
all_logs = self.get_logs(limit=10000) # Get recent logs
|
||||
|
||||
if not all_logs:
|
||||
return {"total_entries": 0, "by_type": {}, "by_level": {}, "success_rate": 0.0}
|
||||
|
||||
# Count by type
|
||||
by_type = {}
|
||||
by_level = {}
|
||||
successful = 0
|
||||
total_with_success_field = 0
|
||||
|
||||
for entry in all_logs:
|
||||
# Count by type
|
||||
event_type = entry.get("event_type", "unknown")
|
||||
by_type[event_type] = by_type.get(event_type, 0) + 1
|
||||
|
||||
# Count by level
|
||||
level = entry.get("level", "unknown")
|
||||
by_level[level] = by_level.get(level, 0) + 1
|
||||
|
||||
# Success rate
|
||||
if "success" in entry:
|
||||
total_with_success_field += 1
|
||||
if entry["success"]:
|
||||
successful += 1
|
||||
|
||||
success_rate = (
|
||||
(successful / total_with_success_field * 100) if total_with_success_field > 0 else 0.0
|
||||
)
|
||||
|
||||
# Calculate log file size
|
||||
log_file_size_mb = 0
|
||||
if self.log_file and self.log_file.exists():
|
||||
log_file_size_mb = round(self.log_file.stat().st_size / (1024 * 1024), 2)
|
||||
|
||||
return {
|
||||
"total_entries": len(all_logs),
|
||||
"by_type": by_type,
|
||||
"by_level": by_level,
|
||||
"success_rate": round(success_rate, 2),
|
||||
"log_file_size_mb": log_file_size_mb,
|
||||
}
|
||||
|
||||
# Global audit logger instance
|
||||
_audit_logger: AuditLogger | None = None
|
||||
|
||||
def get_audit_logger() -> AuditLogger:
|
||||
"""Get the global audit logger instance."""
|
||||
global _audit_logger
|
||||
if _audit_logger is None:
|
||||
_audit_logger = AuditLogger()
|
||||
return _audit_logger
|
||||
126
core/auth.py
Normal file
126
core/auth.py
Normal file
@@ -0,0 +1,126 @@
|
||||
"""
|
||||
Authentication system
|
||||
|
||||
Simple API key based authentication for MCP server.
|
||||
"""
|
||||
|
||||
import logging
|
||||
import os
|
||||
import secrets
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
class AuthManager:
|
||||
"""
|
||||
Manage authentication for MCP server.
|
||||
|
||||
Currently supports simple API key authentication.
|
||||
Future: Can extend to support per-project keys, JWT, etc.
|
||||
"""
|
||||
|
||||
def __init__(self):
|
||||
"""Initialize authentication manager."""
|
||||
# Load master API key from environment
|
||||
self.master_api_key = os.getenv("MASTER_API_KEY")
|
||||
|
||||
if not self.master_api_key:
|
||||
# Generate a random key if not provided (dev mode)
|
||||
self.master_api_key = secrets.token_urlsafe(32)
|
||||
logger.warning(
|
||||
"No MASTER_API_KEY environment variable found. "
|
||||
f"Generated temporary key: {self.master_api_key[:8]}***{self.master_api_key[-4:]} "
|
||||
"(set MASTER_API_KEY in .env for production use)"
|
||||
)
|
||||
|
||||
# Project-specific keys (future feature)
|
||||
self.project_keys = {}
|
||||
|
||||
logger.info("Authentication manager initialized")
|
||||
|
||||
def validate_master_key(self, api_key: str) -> bool:
|
||||
"""
|
||||
Validate master API key.
|
||||
|
||||
Args:
|
||||
api_key: API key to validate
|
||||
|
||||
Returns:
|
||||
bool: True if valid
|
||||
"""
|
||||
is_valid = secrets.compare_digest(api_key, self.master_api_key)
|
||||
|
||||
if not is_valid:
|
||||
logger.warning("Invalid API key attempt")
|
||||
|
||||
return is_valid
|
||||
|
||||
def validate_project_key(self, project_id: str, api_key: str) -> bool:
|
||||
"""
|
||||
Validate project-specific API key.
|
||||
|
||||
Args:
|
||||
project_id: Project identifier
|
||||
api_key: API key to validate
|
||||
|
||||
Returns:
|
||||
bool: True if valid
|
||||
"""
|
||||
if project_id not in self.project_keys:
|
||||
# No project-specific key, fall back to master key
|
||||
return self.validate_master_key(api_key)
|
||||
|
||||
project_key = self.project_keys[project_id]
|
||||
is_valid = secrets.compare_digest(api_key, project_key)
|
||||
|
||||
if not is_valid:
|
||||
logger.warning(f"Invalid project key for {project_id}")
|
||||
|
||||
return is_valid
|
||||
|
||||
def add_project_key(self, project_id: str, api_key: str | None = None) -> str:
|
||||
"""
|
||||
Add or generate a project-specific API key.
|
||||
|
||||
Args:
|
||||
project_id: Project identifier
|
||||
api_key: Optional pre-defined key, will generate if None
|
||||
|
||||
Returns:
|
||||
str: The API key (useful if generated)
|
||||
"""
|
||||
if api_key is None:
|
||||
api_key = secrets.token_urlsafe(32)
|
||||
|
||||
self.project_keys[project_id] = api_key
|
||||
logger.info(f"Added API key for project: {project_id}")
|
||||
|
||||
return api_key
|
||||
|
||||
def remove_project_key(self, project_id: str) -> None:
|
||||
"""
|
||||
Remove project-specific API key.
|
||||
|
||||
Args:
|
||||
project_id: Project identifier
|
||||
"""
|
||||
if project_id in self.project_keys:
|
||||
del self.project_keys[project_id]
|
||||
logger.info(f"Removed API key for project: {project_id}")
|
||||
|
||||
def get_master_key(self) -> str:
|
||||
"""Get the master API key (for display/setup purposes)."""
|
||||
return self.master_api_key
|
||||
|
||||
def has_project_key(self, project_id: str) -> bool:
|
||||
"""Check if a project has its own API key."""
|
||||
return project_id in self.project_keys
|
||||
|
||||
# Global authentication manager instance
|
||||
_auth_manager: AuthManager | None = None
|
||||
|
||||
def get_auth_manager() -> AuthManager:
|
||||
"""Get the global authentication manager instance."""
|
||||
global _auth_manager
|
||||
if _auth_manager is None:
|
||||
_auth_manager = AuthManager()
|
||||
return _auth_manager
|
||||
40
core/context.py
Normal file
40
core/context.py
Normal file
@@ -0,0 +1,40 @@
|
||||
"""
|
||||
Request Context Storage
|
||||
|
||||
Stores request-level information using contextvars for thread-safe access
|
||||
across async operations.
|
||||
"""
|
||||
|
||||
from contextvars import ContextVar
|
||||
from typing import Any
|
||||
|
||||
# Context variable for storing API key info during request processing
|
||||
# This allows unified handlers to check project access permissions
|
||||
_api_key_context: ContextVar[dict[str, Any] | None] = ContextVar("api_key_context", default=None)
|
||||
|
||||
def set_api_key_context(key_id: str, project_id: str, scope: str, is_global: bool) -> None:
|
||||
"""
|
||||
Store API key information in request context.
|
||||
|
||||
Args:
|
||||
key_id: API key identifier
|
||||
project_id: Project the key belongs to ('*' for global)
|
||||
scope: Access scope (read/write/admin)
|
||||
is_global: Whether this is a global key
|
||||
"""
|
||||
_api_key_context.set(
|
||||
{"key_id": key_id, "project_id": project_id, "scope": scope, "is_global": is_global}
|
||||
)
|
||||
|
||||
def get_api_key_context() -> dict[str, Any] | None:
|
||||
"""
|
||||
Retrieve API key information from request context.
|
||||
|
||||
Returns:
|
||||
Dict with key_id, project_id, scope, is_global or None
|
||||
"""
|
||||
return _api_key_context.get()
|
||||
|
||||
def clear_api_key_context() -> None:
|
||||
"""Clear API key context (for cleanup)."""
|
||||
_api_key_context.set(None)
|
||||
75
core/dashboard/__init__.py
Normal file
75
core/dashboard/__init__.py
Normal file
@@ -0,0 +1,75 @@
|
||||
"""
|
||||
Dashboard module for MCP Hub Web UI.
|
||||
|
||||
Phase K: Web UI Dashboard
|
||||
"""
|
||||
|
||||
from .auth import DashboardAuth, get_dashboard_auth
|
||||
from .routes import (
|
||||
dashboard_api_audit_logs,
|
||||
dashboard_api_health,
|
||||
dashboard_api_keys_create,
|
||||
dashboard_api_keys_delete,
|
||||
# K.3: API Keys routes
|
||||
dashboard_api_keys_list,
|
||||
dashboard_api_keys_revoke,
|
||||
dashboard_api_project_detail,
|
||||
dashboard_api_projects,
|
||||
dashboard_api_stats,
|
||||
# K.4: Audit Logs routes
|
||||
dashboard_audit_logs_list,
|
||||
# K.5: Health Monitoring routes
|
||||
dashboard_health_page,
|
||||
dashboard_health_projects_partial,
|
||||
dashboard_home,
|
||||
# K.1: Core routes
|
||||
dashboard_login_page,
|
||||
dashboard_login_submit,
|
||||
dashboard_logout,
|
||||
dashboard_oauth_clients_create,
|
||||
dashboard_oauth_clients_delete,
|
||||
# K.4: OAuth Clients routes
|
||||
dashboard_oauth_clients_list,
|
||||
dashboard_project_detail,
|
||||
dashboard_project_health_check,
|
||||
# K.2: Projects routes
|
||||
dashboard_projects_list,
|
||||
# K.5: Settings routes
|
||||
dashboard_settings_page,
|
||||
register_dashboard_routes,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
"DashboardAuth",
|
||||
"get_dashboard_auth",
|
||||
"register_dashboard_routes",
|
||||
# K.1
|
||||
"dashboard_login_page",
|
||||
"dashboard_login_submit",
|
||||
"dashboard_logout",
|
||||
"dashboard_home",
|
||||
"dashboard_api_stats",
|
||||
# K.2
|
||||
"dashboard_projects_list",
|
||||
"dashboard_project_detail",
|
||||
"dashboard_api_projects",
|
||||
"dashboard_api_project_detail",
|
||||
"dashboard_project_health_check",
|
||||
# K.3
|
||||
"dashboard_api_keys_list",
|
||||
"dashboard_api_keys_create",
|
||||
"dashboard_api_keys_revoke",
|
||||
"dashboard_api_keys_delete",
|
||||
# K.4 OAuth
|
||||
"dashboard_oauth_clients_list",
|
||||
"dashboard_oauth_clients_create",
|
||||
"dashboard_oauth_clients_delete",
|
||||
# K.4 Audit
|
||||
"dashboard_audit_logs_list",
|
||||
"dashboard_api_audit_logs",
|
||||
# K.5
|
||||
"dashboard_health_page",
|
||||
"dashboard_api_health",
|
||||
"dashboard_health_projects_partial",
|
||||
"dashboard_settings_page",
|
||||
]
|
||||
278
core/dashboard/auth.py
Normal file
278
core/dashboard/auth.py
Normal file
@@ -0,0 +1,278 @@
|
||||
"""
|
||||
Dashboard Authentication - Session-based authentication for Web UI.
|
||||
|
||||
Phase K.1: Core Infrastructure
|
||||
"""
|
||||
|
||||
import logging
|
||||
import os
|
||||
import secrets
|
||||
from dataclasses import dataclass
|
||||
from datetime import UTC, datetime, timedelta
|
||||
from typing import Optional
|
||||
|
||||
import jwt
|
||||
from starlette.requests import Request
|
||||
from starlette.responses import RedirectResponse, Response
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Singleton instance
|
||||
_dashboard_auth: Optional["DashboardAuth"] = None
|
||||
|
||||
@dataclass
|
||||
class DashboardSession:
|
||||
"""Dashboard session information."""
|
||||
|
||||
session_id: str
|
||||
created_at: datetime
|
||||
expires_at: datetime
|
||||
user_type: str # "master" or "api_key"
|
||||
key_id: str | None = None # For API key sessions
|
||||
|
||||
class DashboardAuth:
|
||||
"""
|
||||
Dashboard authentication manager.
|
||||
|
||||
Handles session-based authentication using JWT tokens stored in httpOnly cookies.
|
||||
"""
|
||||
|
||||
COOKIE_NAME = "mcp_dashboard_session"
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
secret_key: str | None = None,
|
||||
session_expiry_hours: int = 24,
|
||||
master_api_key: str | None = None,
|
||||
):
|
||||
"""
|
||||
Initialize dashboard authentication.
|
||||
|
||||
Args:
|
||||
secret_key: Secret for JWT signing. Generated if not provided.
|
||||
session_expiry_hours: Session expiration in hours.
|
||||
master_api_key: Master API key for validation.
|
||||
"""
|
||||
self.secret_key = secret_key or os.environ.get(
|
||||
"DASHBOARD_SESSION_SECRET", os.environ.get("OAUTH_JWT_SECRET_KEY")
|
||||
)
|
||||
if not self.secret_key:
|
||||
self.secret_key = secrets.token_hex(32)
|
||||
logger.warning(
|
||||
"DASHBOARD_SESSION_SECRET not set. Generated random session secret. "
|
||||
"All dashboard sessions will be invalidated on restart. "
|
||||
"Set DASHBOARD_SESSION_SECRET in your .env for persistent sessions."
|
||||
)
|
||||
self.session_expiry_hours = int(
|
||||
os.environ.get("DASHBOARD_SESSION_EXPIRY_HOURS", session_expiry_hours)
|
||||
)
|
||||
self.master_api_key = master_api_key or os.environ.get("MASTER_API_KEY")
|
||||
|
||||
# Rate limiting for login attempts
|
||||
self._login_attempts: dict[str, list] = {} # IP -> list of timestamps
|
||||
self.max_login_attempts = int(os.environ.get("DASHBOARD_LOGIN_RATE_LIMIT", 5))
|
||||
|
||||
logger.info(f"DashboardAuth initialized with {self.session_expiry_hours}h session expiry")
|
||||
|
||||
def validate_api_key(self, api_key: str) -> tuple[bool, str, str | None]:
|
||||
"""
|
||||
Validate an API key for dashboard login.
|
||||
|
||||
Args:
|
||||
api_key: The API key to validate.
|
||||
|
||||
Returns:
|
||||
Tuple of (is_valid, user_type, key_id)
|
||||
- user_type: "master" or "api_key"
|
||||
- key_id: Key ID for API keys, None for master
|
||||
"""
|
||||
if not api_key:
|
||||
return False, "", None
|
||||
|
||||
# Check master API key
|
||||
if self.master_api_key and secrets.compare_digest(api_key, self.master_api_key):
|
||||
return True, "master", None
|
||||
|
||||
# Check project API keys with admin scope
|
||||
try:
|
||||
from core.api_keys import get_api_key_manager
|
||||
|
||||
api_key_manager = get_api_key_manager()
|
||||
|
||||
key_info = api_key_manager.validate_key(api_key)
|
||||
if key_info and "admin" in key_info.get("scope", "").split():
|
||||
return True, "api_key", key_info.get("key_id")
|
||||
except Exception as e:
|
||||
logger.warning(f"Error checking API key: {e}")
|
||||
|
||||
return False, "", None
|
||||
|
||||
def check_rate_limit(self, client_ip: str) -> bool:
|
||||
"""
|
||||
Check if login attempts are within rate limit.
|
||||
|
||||
Args:
|
||||
client_ip: Client IP address.
|
||||
|
||||
Returns:
|
||||
True if within limit, False if exceeded.
|
||||
"""
|
||||
now = datetime.now(UTC)
|
||||
window = timedelta(minutes=1)
|
||||
|
||||
# Clean old attempts
|
||||
if client_ip in self._login_attempts:
|
||||
self._login_attempts[client_ip] = [
|
||||
ts for ts in self._login_attempts[client_ip] if now - ts < window
|
||||
]
|
||||
else:
|
||||
self._login_attempts[client_ip] = []
|
||||
|
||||
return len(self._login_attempts[client_ip]) < self.max_login_attempts
|
||||
|
||||
def record_login_attempt(self, client_ip: str):
|
||||
"""Record a login attempt for rate limiting."""
|
||||
if client_ip not in self._login_attempts:
|
||||
self._login_attempts[client_ip] = []
|
||||
self._login_attempts[client_ip].append(datetime.now(UTC))
|
||||
|
||||
def create_session(self, user_type: str, key_id: str | None = None) -> str:
|
||||
"""
|
||||
Create a new dashboard session.
|
||||
|
||||
Args:
|
||||
user_type: Type of user ("master" or "api_key").
|
||||
key_id: Key ID for API key sessions.
|
||||
|
||||
Returns:
|
||||
JWT session token.
|
||||
"""
|
||||
now = datetime.now(UTC)
|
||||
expires_at = now + timedelta(hours=self.session_expiry_hours)
|
||||
session_id = secrets.token_hex(16)
|
||||
|
||||
payload = {
|
||||
"sid": session_id,
|
||||
"type": user_type,
|
||||
"iat": now.timestamp(),
|
||||
"exp": expires_at.timestamp(),
|
||||
}
|
||||
if key_id:
|
||||
payload["kid"] = key_id
|
||||
|
||||
token = jwt.encode(payload, self.secret_key, algorithm="HS256")
|
||||
logger.info(f"Dashboard session created: type={user_type}, expires={expires_at}")
|
||||
|
||||
return token
|
||||
|
||||
def validate_session(self, token: str) -> DashboardSession | None:
|
||||
"""
|
||||
Validate a session token.
|
||||
|
||||
Args:
|
||||
token: JWT session token.
|
||||
|
||||
Returns:
|
||||
DashboardSession if valid, None otherwise.
|
||||
"""
|
||||
if not token:
|
||||
return None
|
||||
|
||||
try:
|
||||
payload = jwt.decode(token, self.secret_key, algorithms=["HS256"])
|
||||
|
||||
return DashboardSession(
|
||||
session_id=payload["sid"],
|
||||
created_at=datetime.fromtimestamp(payload["iat"]),
|
||||
expires_at=datetime.fromtimestamp(payload["exp"]),
|
||||
user_type=payload["type"],
|
||||
key_id=payload.get("kid"),
|
||||
)
|
||||
except jwt.ExpiredSignatureError:
|
||||
logger.debug("Dashboard session expired")
|
||||
return None
|
||||
except jwt.InvalidTokenError as e:
|
||||
logger.warning(f"Invalid dashboard session token: {e}")
|
||||
return None
|
||||
|
||||
def get_session_from_request(self, request: Request) -> DashboardSession | None:
|
||||
"""
|
||||
Extract and validate session from request.
|
||||
|
||||
Args:
|
||||
request: Starlette request object.
|
||||
|
||||
Returns:
|
||||
DashboardSession if valid session exists, None otherwise.
|
||||
"""
|
||||
token = request.cookies.get(self.COOKIE_NAME)
|
||||
if not token:
|
||||
return None
|
||||
return self.validate_session(token)
|
||||
|
||||
def set_session_cookie(self, response: Response, token: str) -> Response:
|
||||
"""
|
||||
Set session cookie on response.
|
||||
|
||||
Args:
|
||||
response: Response object to modify.
|
||||
token: Session token to set.
|
||||
|
||||
Returns:
|
||||
Modified response.
|
||||
"""
|
||||
response.set_cookie(
|
||||
key=self.COOKIE_NAME,
|
||||
value=token,
|
||||
max_age=self.session_expiry_hours * 3600,
|
||||
httponly=True,
|
||||
secure=os.environ.get("DASHBOARD_SECURE_COOKIE", "true").lower() == "true",
|
||||
samesite="lax",
|
||||
path="/", # Allow cookie for both /dashboard and /api/dashboard
|
||||
)
|
||||
return response
|
||||
|
||||
def clear_session_cookie(self, response: Response) -> Response:
|
||||
"""
|
||||
Clear session cookie on response.
|
||||
|
||||
Args:
|
||||
response: Response object to modify.
|
||||
|
||||
Returns:
|
||||
Modified response.
|
||||
"""
|
||||
response.delete_cookie(
|
||||
key=self.COOKIE_NAME,
|
||||
path="/", # Match the path used in set_cookie
|
||||
)
|
||||
return response
|
||||
|
||||
def require_auth(self, request: Request) -> RedirectResponse | None:
|
||||
"""
|
||||
Check if request is authenticated, redirect to login if not.
|
||||
|
||||
Args:
|
||||
request: Starlette request object.
|
||||
|
||||
Returns:
|
||||
RedirectResponse to login page if not authenticated, None if OK.
|
||||
"""
|
||||
session = self.get_session_from_request(request)
|
||||
if not 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}",
|
||||
status_code=303,
|
||||
)
|
||||
return None
|
||||
|
||||
def get_dashboard_auth() -> DashboardAuth:
|
||||
"""Get or create the singleton DashboardAuth instance."""
|
||||
global _dashboard_auth
|
||||
if _dashboard_auth is None:
|
||||
_dashboard_auth = DashboardAuth()
|
||||
return _dashboard_auth
|
||||
2075
core/dashboard/routes.py
Normal file
2075
core/dashboard/routes.py
Normal file
File diff suppressed because it is too large
Load Diff
63
core/endpoints/__init__.py
Normal file
63
core/endpoints/__init__.py
Normal file
@@ -0,0 +1,63 @@
|
||||
"""
|
||||
Multi-Endpoint Architecture for MCP Hub
|
||||
|
||||
This module provides a factory pattern for creating scoped MCP endpoints.
|
||||
Each endpoint exposes only the tools relevant to its purpose.
|
||||
|
||||
Endpoints:
|
||||
/mcp - Admin endpoint (all tools, requires Master API Key)
|
||||
/mcp/wordpress - WordPress tools only (92 tools)
|
||||
/mcp/wordpress-advanced - WordPress Advanced tools (22 tools)
|
||||
/mcp/gitea - Gitea tools only (55 tools)
|
||||
/mcp/project/{id} - Project-specific tools
|
||||
|
||||
Benefits:
|
||||
- Better security: Users only see tools they can access
|
||||
- Optimized context: Smaller tool lists for AI assistants
|
||||
- Scalability: Easy to add new plugin endpoints
|
||||
- Clear separation of concerns
|
||||
"""
|
||||
|
||||
from .config import (
|
||||
ENDPOINT_CONFIGS,
|
||||
EndpointConfig,
|
||||
EndpointType,
|
||||
create_project_endpoint_config,
|
||||
get_endpoint_config,
|
||||
)
|
||||
from .factory import MCPEndpointFactory
|
||||
from .middleware import (
|
||||
AuthContext,
|
||||
EndpointAuditMiddleware,
|
||||
EndpointAuthMiddleware,
|
||||
EndpointRateLimitMiddleware,
|
||||
create_endpoint_middleware,
|
||||
)
|
||||
from .registry import (
|
||||
EndpointInfo,
|
||||
EndpointRegistry,
|
||||
get_endpoint_registry,
|
||||
initialize_endpoint_registry,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
# Config
|
||||
"EndpointConfig",
|
||||
"EndpointType",
|
||||
"ENDPOINT_CONFIGS",
|
||||
"get_endpoint_config",
|
||||
"create_project_endpoint_config",
|
||||
# Factory
|
||||
"MCPEndpointFactory",
|
||||
# Registry
|
||||
"EndpointRegistry",
|
||||
"EndpointInfo",
|
||||
"get_endpoint_registry",
|
||||
"initialize_endpoint_registry",
|
||||
# Middleware
|
||||
"EndpointAuthMiddleware",
|
||||
"EndpointRateLimitMiddleware",
|
||||
"EndpointAuditMiddleware",
|
||||
"create_endpoint_middleware",
|
||||
"AuthContext",
|
||||
]
|
||||
350
core/endpoints/config.py
Normal file
350
core/endpoints/config.py
Normal file
@@ -0,0 +1,350 @@
|
||||
"""
|
||||
Endpoint Configuration Module
|
||||
|
||||
Defines configurations for different MCP endpoints.
|
||||
Each endpoint has specific plugin types, scopes, and access requirements.
|
||||
"""
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
from enum import Enum
|
||||
|
||||
class EndpointType(Enum):
|
||||
"""Types of MCP endpoints"""
|
||||
|
||||
ADMIN = "admin"
|
||||
SYSTEM = "system" # Phase X.3 - System tools only
|
||||
WORDPRESS = "wordpress"
|
||||
WOOCOMMERCE = "woocommerce"
|
||||
WORDPRESS_ADVANCED = "wordpress_advanced"
|
||||
GITEA = "gitea"
|
||||
N8N = "n8n"
|
||||
SUPABASE = "supabase" # Phase G
|
||||
OPENPANEL = "openpanel" # Phase H
|
||||
APPWRITE = "appwrite" # Phase I
|
||||
DIRECTUS = "directus" # Phase J
|
||||
PROJECT = "project" # Dynamic per-project endpoint
|
||||
CUSTOM = "custom"
|
||||
|
||||
@dataclass
|
||||
class EndpointConfig:
|
||||
"""
|
||||
Configuration for a single MCP endpoint.
|
||||
|
||||
Attributes:
|
||||
path: URL mount path for this endpoint (e.g., "/wordpress" → /wordpress/mcp)
|
||||
name: Display name for the endpoint
|
||||
description: Human-readable description
|
||||
endpoint_type: Type of endpoint (admin, wordpress, etc.)
|
||||
plugin_types: List of plugin types to include
|
||||
require_master_key: Whether Master API Key is required
|
||||
allowed_scopes: Allowed API key scopes (empty = all)
|
||||
tool_whitelist: Specific tools to include (None = all from plugins)
|
||||
tool_blacklist: Specific tools to exclude
|
||||
site_filter: Filter to specific site (for project endpoints)
|
||||
max_tools: Maximum number of tools (for safety)
|
||||
"""
|
||||
|
||||
path: str
|
||||
name: str
|
||||
description: str
|
||||
endpoint_type: EndpointType
|
||||
plugin_types: list[str] = field(default_factory=list)
|
||||
require_master_key: bool = False
|
||||
allowed_scopes: set[str] = field(default_factory=set)
|
||||
tool_whitelist: set[str] | None = None
|
||||
tool_blacklist: set[str] = field(default_factory=set)
|
||||
site_filter: str | None = None
|
||||
max_tools: int = 200
|
||||
|
||||
def __post_init__(self):
|
||||
"""Validate configuration after initialization"""
|
||||
if not self.path.startswith("/"):
|
||||
raise ValueError(f"Endpoint path must start with '/': {self.path}")
|
||||
|
||||
if self.tool_whitelist and self.tool_blacklist:
|
||||
overlap = self.tool_whitelist & self.tool_blacklist
|
||||
if overlap:
|
||||
raise ValueError(f"Tools cannot be in both whitelist and blacklist: {overlap}")
|
||||
|
||||
def allows_plugin(self, plugin_type: str) -> bool:
|
||||
"""Check if this endpoint allows a specific plugin type"""
|
||||
if not self.plugin_types:
|
||||
return True # Empty list = all plugins
|
||||
return plugin_type in self.plugin_types
|
||||
|
||||
def allows_tool(self, tool_name: str) -> bool:
|
||||
"""Check if this endpoint allows a specific tool"""
|
||||
# Check blacklist first
|
||||
if tool_name in self.tool_blacklist:
|
||||
return False
|
||||
|
||||
# If whitelist exists, tool must be in it
|
||||
if self.tool_whitelist is not None:
|
||||
return tool_name in self.tool_whitelist
|
||||
|
||||
return True
|
||||
|
||||
def allows_scope(self, scope: str) -> bool:
|
||||
"""Check if this endpoint allows a specific API key scope"""
|
||||
if not self.allowed_scopes:
|
||||
return True # Empty set = all scopes
|
||||
return scope in self.allowed_scopes
|
||||
|
||||
# Predefined endpoint configurations
|
||||
ENDPOINT_CONFIGS = {
|
||||
# Admin endpoint - all tools, requires Master API Key
|
||||
# Mounted at "/" → /mcp (FastMCP adds /mcp automatically)
|
||||
EndpointType.ADMIN: EndpointConfig(
|
||||
path="/",
|
||||
name="Coolify Admin",
|
||||
description="Full administrative access to all tools and plugins",
|
||||
endpoint_type=EndpointType.ADMIN,
|
||||
plugin_types=[], # Empty = all plugins
|
||||
require_master_key=True,
|
||||
allowed_scopes={"admin"},
|
||||
max_tools=400,
|
||||
),
|
||||
# System endpoint - system tools only (17 tools) - Phase X.3
|
||||
# For API key management, OAuth, rate limiting without loading all plugins
|
||||
EndpointType.SYSTEM: EndpointConfig(
|
||||
path="/system",
|
||||
name="System Manager",
|
||||
description="System management tools (API keys, OAuth, health, rate limiting)",
|
||||
endpoint_type=EndpointType.SYSTEM,
|
||||
plugin_types=["system"], # Only system tools
|
||||
require_master_key=True,
|
||||
allowed_scopes={"admin"},
|
||||
# Whitelist only system tools
|
||||
tool_whitelist={
|
||||
# API Key Management (6)
|
||||
"manage_api_keys_create",
|
||||
"manage_api_keys_list",
|
||||
"manage_api_keys_get_info",
|
||||
"manage_api_keys_revoke",
|
||||
"manage_api_keys_delete",
|
||||
"manage_api_keys_rotate",
|
||||
# Health & Status (4)
|
||||
"list_projects",
|
||||
"get_endpoints",
|
||||
"get_system_info",
|
||||
"get_audit_log",
|
||||
# OAuth Management (4)
|
||||
"oauth_register_client",
|
||||
"oauth_list_clients",
|
||||
"oauth_revoke_client",
|
||||
"oauth_get_client_info",
|
||||
# Rate Limiting (3)
|
||||
"get_rate_limit_stats",
|
||||
"reset_rate_limit",
|
||||
"set_rate_limit_config",
|
||||
},
|
||||
max_tools=20,
|
||||
),
|
||||
# WordPress endpoint - core WordPress tools only (64 tools)
|
||||
EndpointType.WORDPRESS: EndpointConfig(
|
||||
path="/wordpress",
|
||||
name="WordPress Manager",
|
||||
description="WordPress content management tools (posts, pages, media, SEO, menus)",
|
||||
endpoint_type=EndpointType.WORDPRESS,
|
||||
plugin_types=["wordpress"],
|
||||
require_master_key=False,
|
||||
allowed_scopes={"read", "write", "admin"},
|
||||
# Blacklist system and admin tools
|
||||
tool_blacklist={
|
||||
"manage_api_keys_create",
|
||||
"manage_api_keys_delete",
|
||||
"manage_api_keys_rotate",
|
||||
"oauth_register_client",
|
||||
"oauth_revoke_client",
|
||||
},
|
||||
max_tools=70,
|
||||
),
|
||||
# WooCommerce endpoint - e-commerce tools (28 tools)
|
||||
EndpointType.WOOCOMMERCE: EndpointConfig(
|
||||
path="/woocommerce",
|
||||
name="WooCommerce Manager",
|
||||
description="WooCommerce e-commerce tools (products, orders, customers, coupons, reports)",
|
||||
endpoint_type=EndpointType.WOOCOMMERCE,
|
||||
plugin_types=["woocommerce"],
|
||||
require_master_key=False,
|
||||
allowed_scopes={"read", "write", "admin"},
|
||||
# Blacklist system and admin tools
|
||||
tool_blacklist={
|
||||
"manage_api_keys_create",
|
||||
"manage_api_keys_delete",
|
||||
"manage_api_keys_rotate",
|
||||
"oauth_register_client",
|
||||
"oauth_revoke_client",
|
||||
},
|
||||
max_tools=35,
|
||||
),
|
||||
# WordPress Advanced endpoint - advanced operations
|
||||
EndpointType.WORDPRESS_ADVANCED: EndpointConfig(
|
||||
path="/wordpress-advanced",
|
||||
name="WordPress Advanced",
|
||||
description="WordPress advanced operations (database, bulk, system)",
|
||||
endpoint_type=EndpointType.WORDPRESS_ADVANCED,
|
||||
plugin_types=["wordpress_advanced"],
|
||||
require_master_key=False,
|
||||
allowed_scopes={"admin"}, # Admin scope required
|
||||
max_tools=30,
|
||||
),
|
||||
# Gitea endpoint - Git repository management
|
||||
EndpointType.GITEA: EndpointConfig(
|
||||
path="/gitea",
|
||||
name="Gitea Manager",
|
||||
description="Git repository management tools (repos, issues, PRs)",
|
||||
endpoint_type=EndpointType.GITEA,
|
||||
plugin_types=["gitea"],
|
||||
require_master_key=False,
|
||||
allowed_scopes={"read", "write", "admin"},
|
||||
# Blacklist system and admin tools
|
||||
tool_blacklist={
|
||||
"manage_api_keys_create",
|
||||
"manage_api_keys_delete",
|
||||
"manage_api_keys_rotate",
|
||||
"oauth_register_client",
|
||||
"oauth_revoke_client",
|
||||
},
|
||||
max_tools=60,
|
||||
),
|
||||
# n8n endpoint - Workflow automation management (60 tools)
|
||||
EndpointType.N8N: EndpointConfig(
|
||||
path="/n8n",
|
||||
name="n8n Automation",
|
||||
description="Workflow automation management (workflows, executions, credentials, tags)",
|
||||
endpoint_type=EndpointType.N8N,
|
||||
plugin_types=["n8n"],
|
||||
require_master_key=False,
|
||||
allowed_scopes={"read", "write", "admin"},
|
||||
# Blacklist system and admin tools
|
||||
tool_blacklist={
|
||||
"manage_api_keys_create",
|
||||
"manage_api_keys_delete",
|
||||
"manage_api_keys_rotate",
|
||||
"oauth_register_client",
|
||||
"oauth_revoke_client",
|
||||
},
|
||||
max_tools=70,
|
||||
),
|
||||
# Supabase endpoint - Self-Hosted management (70 tools) - Phase G
|
||||
EndpointType.SUPABASE: EndpointConfig(
|
||||
path="/supabase",
|
||||
name="Supabase Manager",
|
||||
description="Supabase Self-Hosted management (database, auth, storage, functions, admin)",
|
||||
endpoint_type=EndpointType.SUPABASE,
|
||||
plugin_types=["supabase"],
|
||||
require_master_key=False,
|
||||
allowed_scopes={"read", "write", "admin"},
|
||||
# Blacklist system and admin tools
|
||||
tool_blacklist={
|
||||
"manage_api_keys_create",
|
||||
"manage_api_keys_delete",
|
||||
"manage_api_keys_rotate",
|
||||
"oauth_register_client",
|
||||
"oauth_revoke_client",
|
||||
},
|
||||
max_tools=80,
|
||||
),
|
||||
# OpenPanel endpoint - Product Analytics (73 tools) - Phase H
|
||||
EndpointType.OPENPANEL: EndpointConfig(
|
||||
path="/openpanel",
|
||||
name="OpenPanel Analytics",
|
||||
description="OpenPanel product analytics management (events, export, funnels, dashboards)",
|
||||
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",
|
||||
"manage_api_keys_rotate",
|
||||
"oauth_register_client",
|
||||
"oauth_revoke_client",
|
||||
},
|
||||
max_tools=80,
|
||||
),
|
||||
# Appwrite endpoint - Backend-as-a-Service (100 tools) - Phase I
|
||||
EndpointType.APPWRITE: EndpointConfig(
|
||||
path="/appwrite",
|
||||
name="Appwrite Manager",
|
||||
description="Appwrite Self-Hosted management (databases, documents, users, teams, storage, functions, messaging)",
|
||||
endpoint_type=EndpointType.APPWRITE,
|
||||
plugin_types=["appwrite"],
|
||||
require_master_key=False,
|
||||
allowed_scopes={"read", "write", "admin"},
|
||||
# Blacklist system and admin tools
|
||||
tool_blacklist={
|
||||
"manage_api_keys_create",
|
||||
"manage_api_keys_delete",
|
||||
"manage_api_keys_rotate",
|
||||
"oauth_register_client",
|
||||
"oauth_revoke_client",
|
||||
},
|
||||
max_tools=110,
|
||||
),
|
||||
# Directus endpoint - Headless CMS (100 tools) - Phase J
|
||||
EndpointType.DIRECTUS: EndpointConfig(
|
||||
path="/directus",
|
||||
name="Directus CMS",
|
||||
description="Directus Self-Hosted CMS management (items, collections, files, users, roles, flows, dashboards)",
|
||||
endpoint_type=EndpointType.DIRECTUS,
|
||||
plugin_types=["directus"],
|
||||
require_master_key=False,
|
||||
allowed_scopes={"read", "write", "admin"},
|
||||
# Blacklist system and admin tools
|
||||
tool_blacklist={
|
||||
"manage_api_keys_create",
|
||||
"manage_api_keys_delete",
|
||||
"manage_api_keys_rotate",
|
||||
"oauth_register_client",
|
||||
"oauth_revoke_client",
|
||||
},
|
||||
max_tools=110,
|
||||
),
|
||||
}
|
||||
|
||||
def get_endpoint_config(endpoint_type: EndpointType) -> EndpointConfig:
|
||||
"""Get configuration for a specific endpoint type"""
|
||||
if endpoint_type not in ENDPOINT_CONFIGS:
|
||||
raise ValueError(f"Unknown endpoint type: {endpoint_type}")
|
||||
return ENDPOINT_CONFIGS[endpoint_type]
|
||||
|
||||
def create_project_endpoint_config(
|
||||
project_id: str, plugin_type: str, site_alias: str | None = None
|
||||
) -> EndpointConfig:
|
||||
"""
|
||||
Create a dynamic endpoint configuration for a specific project.
|
||||
|
||||
Args:
|
||||
project_id: Full project ID (e.g., "wordpress_site4")
|
||||
plugin_type: Plugin type (e.g., "wordpress")
|
||||
site_alias: Optional site alias for the path
|
||||
|
||||
Returns:
|
||||
EndpointConfig for the project-specific endpoint
|
||||
"""
|
||||
path_suffix = site_alias or project_id
|
||||
|
||||
# FastMCP adds /mcp automatically, so /project/xxx → /project/xxx/mcp
|
||||
return EndpointConfig(
|
||||
path=f"/project/{path_suffix}",
|
||||
name=f"Project: {project_id}",
|
||||
description=f"Tools for project {project_id}",
|
||||
endpoint_type=EndpointType.PROJECT,
|
||||
plugin_types=[plugin_type],
|
||||
require_master_key=False,
|
||||
site_filter=project_id,
|
||||
# Blacklist admin tools for project endpoints
|
||||
tool_blacklist={
|
||||
"manage_api_keys_create",
|
||||
"manage_api_keys_delete",
|
||||
"manage_api_keys_rotate",
|
||||
"oauth_register_client",
|
||||
"oauth_revoke_client",
|
||||
"list_projects", # Only show own project
|
||||
"oauth_list_clients",
|
||||
},
|
||||
max_tools=120,
|
||||
)
|
||||
295
core/endpoints/factory.py
Normal file
295
core/endpoints/factory.py
Normal file
@@ -0,0 +1,295 @@
|
||||
"""
|
||||
MCP Endpoint Factory
|
||||
|
||||
Creates and configures FastMCP instances for different endpoints.
|
||||
Each endpoint has its own set of tools based on configuration.
|
||||
"""
|
||||
|
||||
import logging
|
||||
from collections.abc import Callable
|
||||
from functools import wraps
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
from fastmcp import FastMCP
|
||||
from fastmcp.server.middleware import Middleware
|
||||
|
||||
from .config import EndpointConfig
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from core.site_manager import SiteManager
|
||||
from core.tool_registry import ToolRegistry
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
class MCPEndpointFactory:
|
||||
"""
|
||||
Factory for creating scoped MCP endpoints.
|
||||
|
||||
Each endpoint is a separate FastMCP instance with only
|
||||
the tools relevant to its configuration.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
site_manager: "SiteManager",
|
||||
tool_registry: "ToolRegistry",
|
||||
middleware_classes: list[type] | None = None,
|
||||
):
|
||||
"""
|
||||
Initialize the endpoint factory.
|
||||
|
||||
Args:
|
||||
site_manager: Site manager for accessing site configurations
|
||||
tool_registry: Central tool registry
|
||||
middleware_classes: List of middleware classes to apply to endpoints
|
||||
"""
|
||||
self.site_manager = site_manager
|
||||
self.tool_registry = tool_registry
|
||||
self.middleware_classes = middleware_classes or []
|
||||
self.endpoints: dict[str, FastMCP] = {}
|
||||
self._tool_handlers: dict[str, Callable] = {}
|
||||
|
||||
def register_tool_handler(self, tool_name: str, handler: Callable):
|
||||
"""
|
||||
Register a tool handler function.
|
||||
|
||||
Args:
|
||||
tool_name: Name of the tool
|
||||
handler: Async handler function for the tool
|
||||
"""
|
||||
self._tool_handlers[tool_name] = handler
|
||||
|
||||
def create_endpoint(
|
||||
self, config: EndpointConfig, custom_middleware: list[Middleware] | None = None
|
||||
) -> FastMCP:
|
||||
"""
|
||||
Create a new MCP endpoint with scoped tools.
|
||||
|
||||
Args:
|
||||
config: Endpoint configuration
|
||||
custom_middleware: Additional middleware for this endpoint
|
||||
|
||||
Returns:
|
||||
Configured FastMCP instance
|
||||
"""
|
||||
logger.info(f"Creating endpoint: {config.path} ({config.name})")
|
||||
|
||||
# Create FastMCP instance
|
||||
mcp = FastMCP(config.name)
|
||||
|
||||
# Get tools for this endpoint
|
||||
tools = self._get_tools_for_endpoint(config)
|
||||
|
||||
logger.info(f" - Registering {len(tools)} tools for {config.path}")
|
||||
|
||||
# Register tools
|
||||
for tool_info in tools:
|
||||
self._register_tool(mcp, tool_info, config)
|
||||
|
||||
# Apply middleware
|
||||
if custom_middleware:
|
||||
for middleware in custom_middleware:
|
||||
mcp.add_middleware(middleware)
|
||||
|
||||
# Store endpoint
|
||||
self.endpoints[config.path] = mcp
|
||||
|
||||
logger.info(f" - Endpoint {config.path} created successfully")
|
||||
|
||||
return mcp
|
||||
|
||||
def _get_tools_for_endpoint(self, config: EndpointConfig) -> list[dict[str, Any]]:
|
||||
"""
|
||||
Get list of tools that should be available on this endpoint.
|
||||
|
||||
Args:
|
||||
config: Endpoint configuration
|
||||
|
||||
Returns:
|
||||
List of tool definitions
|
||||
"""
|
||||
tools = []
|
||||
|
||||
# Get all tools from registry
|
||||
all_tools = self.tool_registry.get_all()
|
||||
|
||||
for tool_def in all_tools:
|
||||
tool_name = tool_def.name
|
||||
|
||||
# Check plugin type filter
|
||||
plugin_type = self._extract_plugin_type(tool_name)
|
||||
if plugin_type and not config.allows_plugin(plugin_type):
|
||||
continue
|
||||
|
||||
# Check tool whitelist/blacklist
|
||||
if not config.allows_tool(tool_name):
|
||||
continue
|
||||
|
||||
# For project endpoints, filter by site
|
||||
if config.site_filter and plugin_type:
|
||||
# Tool should work with the specific site
|
||||
pass # Site filtering happens at execution time
|
||||
|
||||
tools.append(
|
||||
{
|
||||
"name": tool_name,
|
||||
"description": tool_def.description,
|
||||
"parameters": tool_def.parameters,
|
||||
"handler": tool_def.handler,
|
||||
"plugin_type": plugin_type,
|
||||
}
|
||||
)
|
||||
|
||||
# Check max tools limit
|
||||
if len(tools) > config.max_tools:
|
||||
logger.warning(
|
||||
f"Endpoint {config.path} has {len(tools)} tools, "
|
||||
f"exceeding max_tools={config.max_tools}"
|
||||
)
|
||||
|
||||
return tools
|
||||
|
||||
def _extract_plugin_type(self, tool_name: str) -> str | None:
|
||||
"""
|
||||
Extract plugin type from tool name.
|
||||
|
||||
Args:
|
||||
tool_name: Name of the tool
|
||||
|
||||
Returns:
|
||||
Plugin type or None for system tools
|
||||
"""
|
||||
# Check for wordpress_advanced first (before wordpress_)
|
||||
# Tools are named: wordpress_advanced_wp_db_*, wordpress_advanced_bulk_*, wordpress_advanced_system_*
|
||||
if tool_name.startswith("wordpress_advanced_"):
|
||||
return "wordpress_advanced"
|
||||
|
||||
if tool_name.startswith("wordpress_"):
|
||||
return "wordpress"
|
||||
|
||||
elif tool_name.startswith("woocommerce_"):
|
||||
return "woocommerce"
|
||||
|
||||
elif tool_name.startswith("gitea_"):
|
||||
return "gitea"
|
||||
|
||||
elif tool_name.startswith("n8n_"):
|
||||
return "n8n"
|
||||
|
||||
elif tool_name.startswith("supabase_"):
|
||||
return "supabase"
|
||||
|
||||
elif tool_name.startswith("openpanel_"):
|
||||
return "openpanel"
|
||||
|
||||
elif tool_name.startswith("appwrite_"):
|
||||
return "appwrite"
|
||||
|
||||
elif tool_name.startswith("directus_"):
|
||||
return "directus"
|
||||
|
||||
# System tools have no plugin type
|
||||
return None
|
||||
|
||||
def _register_tool(self, mcp: FastMCP, tool_info: dict[str, Any], config: EndpointConfig):
|
||||
"""
|
||||
Register a single tool with the FastMCP instance.
|
||||
|
||||
Args:
|
||||
mcp: FastMCP instance
|
||||
tool_info: Tool definition
|
||||
config: Endpoint configuration
|
||||
"""
|
||||
tool_name = tool_info["name"]
|
||||
|
||||
# Get handler
|
||||
handler = tool_info.get("handler") or self._tool_handlers.get(tool_name)
|
||||
|
||||
if not handler:
|
||||
logger.warning(f"No handler found for tool: {tool_name}")
|
||||
return
|
||||
|
||||
# Wrap handler with endpoint context
|
||||
wrapped_handler = self._wrap_handler(handler, tool_name, config)
|
||||
|
||||
# Register with FastMCP
|
||||
# We need to create a function with the correct signature
|
||||
mcp.tool()(wrapped_handler)
|
||||
|
||||
def _wrap_handler(self, handler: Callable, tool_name: str, config: EndpointConfig) -> Callable:
|
||||
"""
|
||||
Wrap a tool handler with endpoint-specific logic.
|
||||
|
||||
Args:
|
||||
handler: Original handler function
|
||||
tool_name: Name of the tool
|
||||
config: Endpoint configuration
|
||||
|
||||
Returns:
|
||||
Wrapped handler function
|
||||
"""
|
||||
|
||||
@wraps(handler)
|
||||
async def wrapped(*args, **kwargs):
|
||||
# For project endpoints, always inject site filter
|
||||
# This locks all tools to the specific project's site
|
||||
if config.site_filter:
|
||||
# Extract site_id from project's site_filter (format: plugin_type_site_id)
|
||||
# The site parameter expects just the site identifier (site_id or alias)
|
||||
if "_" in config.site_filter:
|
||||
# site_filter is full_id like "wordpress_site1"
|
||||
parts = config.site_filter.split("_", 1)
|
||||
if len(parts) == 2:
|
||||
# Use the site_id part (site1)
|
||||
kwargs["site"] = parts[1]
|
||||
else:
|
||||
kwargs["site"] = config.site_filter
|
||||
else:
|
||||
kwargs["site"] = config.site_filter
|
||||
|
||||
# Call original handler
|
||||
return await handler(*args, **kwargs)
|
||||
|
||||
# Preserve function metadata
|
||||
wrapped.__name__ = tool_name
|
||||
wrapped.__doc__ = handler.__doc__
|
||||
|
||||
return wrapped
|
||||
|
||||
def get_endpoint(self, path: str) -> FastMCP | None:
|
||||
"""
|
||||
Get an endpoint by path.
|
||||
|
||||
Args:
|
||||
path: Endpoint path
|
||||
|
||||
Returns:
|
||||
FastMCP instance or None
|
||||
"""
|
||||
return self.endpoints.get(path)
|
||||
|
||||
def get_all_endpoints(self) -> dict[str, FastMCP]:
|
||||
"""Get all registered endpoints"""
|
||||
return self.endpoints.copy()
|
||||
|
||||
def get_endpoint_info(self) -> list[dict[str, Any]]:
|
||||
"""
|
||||
Get information about all endpoints.
|
||||
|
||||
Returns:
|
||||
List of endpoint info dictionaries
|
||||
"""
|
||||
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
|
||||
|
||||
info.append(
|
||||
{
|
||||
"path": path,
|
||||
"name": mcp.name,
|
||||
"tool_count": tool_count,
|
||||
}
|
||||
)
|
||||
return info
|
||||
350
core/endpoints/middleware.py
Normal file
350
core/endpoints/middleware.py
Normal file
@@ -0,0 +1,350 @@
|
||||
"""
|
||||
Middleware for Multi-Endpoint Architecture
|
||||
|
||||
Provides authentication, rate limiting, and audit logging
|
||||
that works with the multi-endpoint architecture.
|
||||
"""
|
||||
|
||||
import logging
|
||||
import time
|
||||
from collections.abc import Callable
|
||||
from dataclasses import dataclass
|
||||
|
||||
from fastmcp.exceptions import ToolError
|
||||
from fastmcp.server.dependencies import get_http_headers
|
||||
from fastmcp.server.middleware import Middleware, MiddlewareContext
|
||||
|
||||
from core.api_keys import get_api_key_manager
|
||||
from core.audit_log import EventType, LogLevel, get_audit_logger
|
||||
from core.auth import get_auth_manager
|
||||
from core.context import clear_api_key_context, set_api_key_context
|
||||
from core.rate_limiter import get_rate_limiter
|
||||
|
||||
from .config import EndpointConfig
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@dataclass
|
||||
class AuthContext:
|
||||
"""Authentication context for a request"""
|
||||
|
||||
key_id: str | None = None
|
||||
project_id: str | None = None
|
||||
scope: str = "read"
|
||||
is_master_key: bool = False
|
||||
is_oauth_token: bool = False
|
||||
client_ip: str | None = None
|
||||
|
||||
class EndpointAuthMiddleware(Middleware):
|
||||
"""
|
||||
Authentication middleware for multi-endpoint architecture.
|
||||
|
||||
Validates API keys/tokens and enforces endpoint-specific access rules.
|
||||
"""
|
||||
|
||||
def __init__(self, endpoint_config: EndpointConfig):
|
||||
"""
|
||||
Initialize middleware with endpoint configuration.
|
||||
|
||||
Args:
|
||||
endpoint_config: Configuration for this endpoint
|
||||
"""
|
||||
self.config = endpoint_config
|
||||
self.auth_manager = get_auth_manager()
|
||||
self.api_key_manager = get_api_key_manager()
|
||||
|
||||
async def on_call_tool(self, context: MiddlewareContext, call_next: Callable):
|
||||
"""
|
||||
Handle tool call with authentication and authorization.
|
||||
|
||||
Args:
|
||||
context: Middleware context
|
||||
call_next: Next middleware in chain
|
||||
"""
|
||||
tool_name = getattr(context.message, "name", "unknown")
|
||||
start_time = time.time()
|
||||
|
||||
try:
|
||||
# Extract and validate authentication
|
||||
auth_context = await self._authenticate(context)
|
||||
|
||||
# Check endpoint access
|
||||
self._check_endpoint_access(auth_context)
|
||||
|
||||
# Check tool access
|
||||
self._check_tool_access(tool_name, auth_context)
|
||||
|
||||
# Set context for downstream handlers
|
||||
if auth_context.key_id:
|
||||
set_api_key_context(
|
||||
key_id=auth_context.key_id,
|
||||
project_id=auth_context.project_id or "*",
|
||||
scope=auth_context.scope,
|
||||
is_global=auth_context.project_id == "*",
|
||||
)
|
||||
|
||||
# Call the actual tool
|
||||
result = await call_next(context)
|
||||
|
||||
# Log success
|
||||
self._log_success(tool_name, auth_context, start_time)
|
||||
|
||||
return result
|
||||
|
||||
except ToolError:
|
||||
raise
|
||||
except Exception as e:
|
||||
self._log_error(tool_name, str(e), start_time)
|
||||
raise ToolError(f"Authentication error: {str(e)}")
|
||||
finally:
|
||||
clear_api_key_context()
|
||||
|
||||
async def _authenticate(self, context: MiddlewareContext) -> AuthContext:
|
||||
"""
|
||||
Extract and validate authentication from request.
|
||||
|
||||
Args:
|
||||
context: Middleware context
|
||||
|
||||
Returns:
|
||||
AuthContext with authentication details
|
||||
"""
|
||||
auth_context = AuthContext()
|
||||
|
||||
# Get headers
|
||||
try:
|
||||
headers = get_http_headers()
|
||||
except Exception:
|
||||
headers = {}
|
||||
|
||||
# Extract client IP
|
||||
auth_context.client_ip = headers.get("x-forwarded-for", "unknown")
|
||||
|
||||
# Get authorization header
|
||||
auth_header = headers.get("authorization", "")
|
||||
|
||||
if not auth_header:
|
||||
# No auth provided
|
||||
if self.config.require_master_key:
|
||||
raise ToolError("Master API key required for this endpoint")
|
||||
return auth_context
|
||||
|
||||
# Parse authorization
|
||||
if auth_header.startswith("Bearer "):
|
||||
token = auth_header[7:]
|
||||
else:
|
||||
token = auth_header
|
||||
|
||||
# Check token type
|
||||
if token.startswith("sk-"):
|
||||
# Master API key
|
||||
if self.auth_manager.validate_master_key(token):
|
||||
auth_context.is_master_key = True
|
||||
auth_context.project_id = "*"
|
||||
auth_context.scope = "admin"
|
||||
auth_context.key_id = "master"
|
||||
return auth_context
|
||||
else:
|
||||
raise ToolError("Invalid master API key")
|
||||
|
||||
elif token.startswith("cmp_"):
|
||||
# Project API key
|
||||
key = self.api_key_manager.get_key_by_token(token)
|
||||
if not key:
|
||||
raise ToolError("Invalid API key")
|
||||
|
||||
if key.revoked:
|
||||
raise ToolError("API key has been revoked")
|
||||
|
||||
if key.is_expired():
|
||||
raise ToolError("API key has expired")
|
||||
|
||||
auth_context.key_id = key.key_id
|
||||
auth_context.project_id = key.project_id
|
||||
auth_context.scope = key.scope
|
||||
return auth_context
|
||||
|
||||
else:
|
||||
# Possibly OAuth token (JWT)
|
||||
try:
|
||||
from core.oauth import get_token_manager
|
||||
|
||||
token_manager = get_token_manager()
|
||||
payload = token_manager.validate_access_token(token)
|
||||
|
||||
if payload:
|
||||
auth_context.is_oauth_token = True
|
||||
auth_context.project_id = payload.get("project_id", "*")
|
||||
auth_context.scope = payload.get("scope", "read")
|
||||
auth_context.key_id = f"oauth_{payload.get('sub', 'unknown')}"
|
||||
return auth_context
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
raise ToolError("Invalid authentication token")
|
||||
|
||||
def _check_endpoint_access(self, auth_context: AuthContext):
|
||||
"""
|
||||
Check if auth context allows access to this endpoint.
|
||||
|
||||
Args:
|
||||
auth_context: Authentication context
|
||||
"""
|
||||
# Master key always has access
|
||||
if auth_context.is_master_key:
|
||||
return
|
||||
|
||||
# Check if endpoint requires master key
|
||||
if self.config.require_master_key:
|
||||
raise ToolError(f"Endpoint {self.config.path} requires master API key")
|
||||
|
||||
# Check scope requirements
|
||||
if self.config.allowed_scopes:
|
||||
# Check if any of the user's scopes are allowed
|
||||
user_scopes = set(auth_context.scope.split())
|
||||
if not user_scopes & self.config.allowed_scopes:
|
||||
raise ToolError(
|
||||
f"Insufficient scope. Required: {self.config.allowed_scopes}, "
|
||||
f"Got: {user_scopes}"
|
||||
)
|
||||
|
||||
# Check plugin type access
|
||||
if auth_context.project_id and auth_context.project_id != "*":
|
||||
# Extract plugin type from project_id (e.g., "wordpress_site4" -> "wordpress")
|
||||
if "_" in auth_context.project_id:
|
||||
key_plugin_type = auth_context.project_id.split("_")[0]
|
||||
|
||||
# Check if endpoint allows this plugin type
|
||||
if self.config.plugin_types and key_plugin_type not in self.config.plugin_types:
|
||||
raise ToolError(
|
||||
f"API key for {key_plugin_type} cannot access "
|
||||
f"{self.config.endpoint_type.value} endpoint"
|
||||
)
|
||||
|
||||
def _check_tool_access(self, tool_name: str, auth_context: AuthContext):
|
||||
"""
|
||||
Check if auth context allows access to specific tool.
|
||||
|
||||
Args:
|
||||
tool_name: Name of the tool
|
||||
auth_context: Authentication context
|
||||
"""
|
||||
# Master key has access to all tools
|
||||
if auth_context.is_master_key:
|
||||
return
|
||||
|
||||
# Check tool blacklist
|
||||
if not self.config.allows_tool(tool_name):
|
||||
raise ToolError(f"Access denied to tool: {tool_name}")
|
||||
|
||||
# Check site filter for project endpoints
|
||||
if self.config.site_filter:
|
||||
# Tool must be for the configured site
|
||||
# This is handled by parameter injection in the wrapper
|
||||
pass
|
||||
|
||||
def _log_success(self, tool_name: str, auth_context: AuthContext, start_time: float):
|
||||
"""Log successful tool execution"""
|
||||
duration_ms = int((time.time() - start_time) * 1000)
|
||||
logger.debug(
|
||||
f"Tool {tool_name} executed successfully "
|
||||
f"(key={auth_context.key_id}, duration={duration_ms}ms)"
|
||||
)
|
||||
|
||||
def _log_error(self, tool_name: str, error: str, start_time: float):
|
||||
"""Log tool execution error"""
|
||||
duration_ms = int((time.time() - start_time) * 1000)
|
||||
logger.warning(f"Tool {tool_name} failed: {error} (duration={duration_ms}ms)")
|
||||
|
||||
class EndpointRateLimitMiddleware(Middleware):
|
||||
"""
|
||||
Rate limiting middleware for multi-endpoint architecture.
|
||||
"""
|
||||
|
||||
def __init__(self, endpoint_config: EndpointConfig):
|
||||
self.config = endpoint_config
|
||||
self.rate_limiter = get_rate_limiter()
|
||||
|
||||
async def on_call_tool(self, context: MiddlewareContext, call_next: Callable):
|
||||
"""Apply rate limiting before tool execution"""
|
||||
# Get client identifier
|
||||
try:
|
||||
headers = get_http_headers()
|
||||
client_id = headers.get("authorization", "anonymous")[:50]
|
||||
except Exception:
|
||||
client_id = "unknown"
|
||||
|
||||
# Check rate limit
|
||||
allowed, info = self.rate_limiter.check_rate_limit(client_id)
|
||||
|
||||
if not allowed:
|
||||
raise ToolError(
|
||||
f"Rate limit exceeded. Retry after {info.get('retry_after', 60)} seconds"
|
||||
)
|
||||
|
||||
# Proceed with request
|
||||
return await call_next(context)
|
||||
|
||||
class EndpointAuditMiddleware(Middleware):
|
||||
"""
|
||||
Audit logging middleware for multi-endpoint architecture.
|
||||
"""
|
||||
|
||||
def __init__(self, endpoint_config: EndpointConfig):
|
||||
self.config = endpoint_config
|
||||
self.audit_logger = get_audit_logger()
|
||||
|
||||
async def on_call_tool(self, context: MiddlewareContext, call_next: Callable):
|
||||
"""Log tool execution to audit log"""
|
||||
tool_name = getattr(context.message, "name", "unknown")
|
||||
start_time = time.time()
|
||||
|
||||
try:
|
||||
result = await call_next(context)
|
||||
|
||||
# Log success
|
||||
self.audit_logger.log(
|
||||
level=LogLevel.INFO,
|
||||
event_type=EventType.TOOL_CALL,
|
||||
message=f"Tool executed: {tool_name}",
|
||||
details={
|
||||
"tool": tool_name,
|
||||
"endpoint": self.config.path,
|
||||
"duration_ms": int((time.time() - start_time) * 1000),
|
||||
"success": True,
|
||||
},
|
||||
)
|
||||
|
||||
return result
|
||||
|
||||
except Exception as e:
|
||||
# Log failure
|
||||
self.audit_logger.log(
|
||||
level=LogLevel.WARNING,
|
||||
event_type=EventType.TOOL_CALL,
|
||||
message=f"Tool failed: {tool_name}",
|
||||
details={
|
||||
"tool": tool_name,
|
||||
"endpoint": self.config.path,
|
||||
"duration_ms": int((time.time() - start_time) * 1000),
|
||||
"success": False,
|
||||
"error": str(e),
|
||||
},
|
||||
)
|
||||
raise
|
||||
|
||||
def create_endpoint_middleware(endpoint_config: EndpointConfig) -> list:
|
||||
"""
|
||||
Create middleware stack for an endpoint.
|
||||
|
||||
Args:
|
||||
endpoint_config: Endpoint configuration
|
||||
|
||||
Returns:
|
||||
List of middleware instances
|
||||
"""
|
||||
return [
|
||||
EndpointAuthMiddleware(endpoint_config),
|
||||
EndpointRateLimitMiddleware(endpoint_config),
|
||||
EndpointAuditMiddleware(endpoint_config),
|
||||
]
|
||||
226
core/endpoints/registry.py
Normal file
226
core/endpoints/registry.py
Normal file
@@ -0,0 +1,226 @@
|
||||
"""
|
||||
Endpoint Registry
|
||||
|
||||
Central registry for managing all MCP endpoints.
|
||||
Handles routing requests to the correct endpoint based on path.
|
||||
"""
|
||||
|
||||
import logging
|
||||
from dataclasses import dataclass
|
||||
|
||||
from fastmcp import FastMCP
|
||||
from starlette.routing import Mount, Route
|
||||
|
||||
from .config import (
|
||||
ENDPOINT_CONFIGS,
|
||||
EndpointConfig,
|
||||
EndpointType,
|
||||
create_project_endpoint_config,
|
||||
)
|
||||
from .factory import MCPEndpointFactory
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@dataclass
|
||||
class EndpointInfo:
|
||||
"""Information about a registered endpoint"""
|
||||
|
||||
path: str
|
||||
name: str
|
||||
description: str
|
||||
endpoint_type: EndpointType
|
||||
tool_count: int
|
||||
plugin_types: list[str]
|
||||
require_master_key: bool
|
||||
|
||||
class EndpointRegistry:
|
||||
"""
|
||||
Central registry for all MCP endpoints.
|
||||
|
||||
Manages endpoint creation, routing, and discovery.
|
||||
"""
|
||||
|
||||
def __init__(self, factory: MCPEndpointFactory):
|
||||
"""
|
||||
Initialize the endpoint registry.
|
||||
|
||||
Args:
|
||||
factory: Endpoint factory for creating MCP instances
|
||||
"""
|
||||
self.factory = factory
|
||||
self._endpoints: dict[str, FastMCP] = {}
|
||||
self._configs: dict[str, EndpointConfig] = {}
|
||||
self._initialized = False
|
||||
|
||||
def initialize_default_endpoints(self):
|
||||
"""
|
||||
Initialize the default set of endpoints.
|
||||
|
||||
Creates admin, wordpress, wordpress-advanced, and gitea endpoints.
|
||||
"""
|
||||
if self._initialized:
|
||||
logger.warning("Endpoints already initialized")
|
||||
return
|
||||
|
||||
logger.info("=" * 60)
|
||||
logger.info("Initializing Multi-Endpoint Architecture")
|
||||
logger.info("=" * 60)
|
||||
|
||||
# Create default endpoints
|
||||
for _endpoint_type, config in ENDPOINT_CONFIGS.items():
|
||||
try:
|
||||
mcp = self.factory.create_endpoint(config)
|
||||
self._endpoints[config.path] = mcp
|
||||
self._configs[config.path] = config
|
||||
logger.info(f" ✓ {config.path}: {config.name}")
|
||||
except Exception as e:
|
||||
logger.error(f" ✗ Failed to create {config.path}: {e}")
|
||||
|
||||
self._initialized = True
|
||||
|
||||
# Log summary
|
||||
self._log_summary()
|
||||
|
||||
def create_project_endpoint(
|
||||
self, project_id: str, plugin_type: str, site_alias: str | None = None
|
||||
) -> FastMCP:
|
||||
"""
|
||||
Create a dynamic endpoint for a specific project.
|
||||
|
||||
Args:
|
||||
project_id: Full project ID
|
||||
plugin_type: Plugin type
|
||||
site_alias: Optional site alias
|
||||
|
||||
Returns:
|
||||
Created FastMCP instance
|
||||
"""
|
||||
config = create_project_endpoint_config(project_id, plugin_type, site_alias)
|
||||
|
||||
# Check if already exists
|
||||
if config.path in self._endpoints:
|
||||
logger.info(f"Endpoint {config.path} already exists")
|
||||
return self._endpoints[config.path]
|
||||
|
||||
mcp = self.factory.create_endpoint(config)
|
||||
self._endpoints[config.path] = mcp
|
||||
self._configs[config.path] = config
|
||||
|
||||
logger.info(f"Created project endpoint: {config.path}")
|
||||
return mcp
|
||||
|
||||
def get_endpoint(self, path: str) -> FastMCP | None:
|
||||
"""
|
||||
Get endpoint by path.
|
||||
|
||||
Args:
|
||||
path: Endpoint path
|
||||
|
||||
Returns:
|
||||
FastMCP instance or None
|
||||
"""
|
||||
# Exact match
|
||||
if path in self._endpoints:
|
||||
return self._endpoints[path]
|
||||
|
||||
# Try with trailing slash
|
||||
if not path.endswith("/"):
|
||||
return self._endpoints.get(path + "/")
|
||||
|
||||
return None
|
||||
|
||||
def get_config(self, path: str) -> EndpointConfig | None:
|
||||
"""Get endpoint configuration by path"""
|
||||
return self._configs.get(path)
|
||||
|
||||
def list_endpoints(self) -> list[EndpointInfo]:
|
||||
"""
|
||||
List all registered endpoints with their info.
|
||||
|
||||
Returns:
|
||||
List of EndpointInfo objects
|
||||
"""
|
||||
endpoints = []
|
||||
|
||||
for path, config in self._configs.items():
|
||||
mcp = self._endpoints.get(path)
|
||||
tool_count = 0
|
||||
|
||||
if mcp:
|
||||
# Try to get tool count from FastMCP
|
||||
try:
|
||||
tool_count = len(mcp._tool_manager._tools)
|
||||
except AttributeError:
|
||||
pass
|
||||
|
||||
endpoints.append(
|
||||
EndpointInfo(
|
||||
path=path,
|
||||
name=config.name,
|
||||
description=config.description,
|
||||
endpoint_type=config.endpoint_type,
|
||||
tool_count=tool_count,
|
||||
plugin_types=config.plugin_types,
|
||||
require_master_key=config.require_master_key,
|
||||
)
|
||||
)
|
||||
|
||||
return endpoints
|
||||
|
||||
def get_routes(self) -> list[Route]:
|
||||
"""
|
||||
Get Starlette routes for all endpoints.
|
||||
|
||||
Returns:
|
||||
List of Route objects for mounting
|
||||
"""
|
||||
routes = []
|
||||
|
||||
for path, mcp in self._endpoints.items():
|
||||
# Each MCP endpoint needs to handle its own routing
|
||||
# FastMCP provides an ASGI app
|
||||
routes.append(Mount(path, app=mcp.sse_app(), name=f"mcp_{path.replace('/', '_')}"))
|
||||
|
||||
return routes
|
||||
|
||||
def _log_summary(self):
|
||||
"""Log a summary of all endpoints"""
|
||||
logger.info("-" * 60)
|
||||
logger.info("Endpoint Summary:")
|
||||
logger.info("-" * 60)
|
||||
|
||||
total_tools = 0
|
||||
for info in self.list_endpoints():
|
||||
total_tools += info.tool_count
|
||||
auth_note = " (Master Key required)" if info.require_master_key else ""
|
||||
logger.info(f" {info.path}: {info.tool_count} tools{auth_note}")
|
||||
|
||||
logger.info("-" * 60)
|
||||
logger.info(f"Total: {len(self._endpoints)} endpoints")
|
||||
logger.info("=" * 60)
|
||||
|
||||
# Singleton instance
|
||||
_registry: EndpointRegistry | None = None
|
||||
|
||||
def get_endpoint_registry() -> EndpointRegistry:
|
||||
"""Get the global endpoint registry instance"""
|
||||
global _registry
|
||||
if _registry is None:
|
||||
raise RuntimeError(
|
||||
"Endpoint registry not initialized. " "Call initialize_endpoint_registry() first."
|
||||
)
|
||||
return _registry
|
||||
|
||||
def initialize_endpoint_registry(factory: MCPEndpointFactory) -> EndpointRegistry:
|
||||
"""
|
||||
Initialize the global endpoint registry.
|
||||
|
||||
Args:
|
||||
factory: Endpoint factory to use
|
||||
|
||||
Returns:
|
||||
Initialized EndpointRegistry
|
||||
"""
|
||||
global _registry
|
||||
_registry = EndpointRegistry(factory)
|
||||
return _registry
|
||||
683
core/health.py
Normal file
683
core/health.py
Normal file
@@ -0,0 +1,683 @@
|
||||
"""
|
||||
Enhanced Health Monitoring System for MCP Server (Phase 7.2)
|
||||
|
||||
This module provides comprehensive health monitoring capabilities including:
|
||||
- Response time tracking
|
||||
- Error rate monitoring
|
||||
- Historical metrics storage
|
||||
- Alert thresholds
|
||||
- Dependency health checks
|
||||
- System uptime tracking
|
||||
|
||||
Author: Coolify MCP Team
|
||||
Version: 7.2
|
||||
"""
|
||||
|
||||
import json
|
||||
import logging
|
||||
import time
|
||||
from collections import defaultdict, deque
|
||||
from dataclasses import asdict, dataclass, field
|
||||
from datetime import UTC, datetime, timedelta
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from core.audit_log import AuditLogger
|
||||
from core.project_manager import ProjectManager
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@dataclass
|
||||
class HealthMetric:
|
||||
"""Individual health metric data point."""
|
||||
|
||||
timestamp: datetime
|
||||
project_id: str
|
||||
response_time_ms: float
|
||||
success: bool
|
||||
error_message: str | None = None
|
||||
|
||||
def to_dict(self) -> dict[str, Any]:
|
||||
"""Convert to dictionary for JSON serialization."""
|
||||
return {
|
||||
"timestamp": self.timestamp.isoformat(),
|
||||
"project_id": self.project_id,
|
||||
"response_time_ms": self.response_time_ms,
|
||||
"success": self.success,
|
||||
"error_message": self.error_message,
|
||||
}
|
||||
|
||||
@dataclass
|
||||
class SystemMetrics:
|
||||
"""System-wide metrics."""
|
||||
|
||||
uptime_seconds: float
|
||||
total_requests: int
|
||||
successful_requests: int
|
||||
failed_requests: int
|
||||
average_response_time_ms: float
|
||||
error_rate_percent: float
|
||||
requests_per_minute: float
|
||||
|
||||
def to_dict(self) -> dict[str, Any]:
|
||||
"""Convert to dictionary."""
|
||||
return asdict(self)
|
||||
|
||||
@dataclass
|
||||
class ProjectHealthStatus:
|
||||
"""Comprehensive health status for a project."""
|
||||
|
||||
project_id: str
|
||||
healthy: bool
|
||||
last_check: datetime
|
||||
response_time_ms: float
|
||||
error_rate_percent: float
|
||||
recent_errors: list[str] = field(default_factory=list)
|
||||
alerts: list[str] = field(default_factory=list)
|
||||
details: dict[str, Any] = field(default_factory=dict)
|
||||
|
||||
def to_dict(self) -> dict[str, Any]:
|
||||
"""Convert to dictionary for JSON serialization."""
|
||||
return {
|
||||
"project_id": self.project_id,
|
||||
"healthy": self.healthy,
|
||||
"last_check": self.last_check.isoformat(),
|
||||
"response_time_ms": self.response_time_ms,
|
||||
"error_rate_percent": self.error_rate_percent,
|
||||
"recent_errors": self.recent_errors,
|
||||
"alerts": self.alerts,
|
||||
"details": self.details,
|
||||
}
|
||||
|
||||
@dataclass
|
||||
class AlertThreshold:
|
||||
"""Alert threshold configuration."""
|
||||
|
||||
name: str
|
||||
metric: str # "response_time_ms", "error_rate_percent", etc.
|
||||
threshold: float
|
||||
comparison: str # "gt" (greater than), "lt" (less than), "eq" (equal)
|
||||
severity: str = "warning" # "info", "warning", "critical"
|
||||
|
||||
def check(self, value: float) -> bool:
|
||||
"""Check if value exceeds threshold."""
|
||||
if self.comparison == "gt":
|
||||
return value > self.threshold
|
||||
elif self.comparison == "lt":
|
||||
return value < self.threshold
|
||||
elif self.comparison == "eq":
|
||||
return value == self.threshold
|
||||
return False
|
||||
|
||||
class HealthMonitor:
|
||||
"""
|
||||
Enhanced health monitoring system with metrics tracking and alerting.
|
||||
|
||||
Features:
|
||||
- Real-time health checks
|
||||
- Response time tracking
|
||||
- Error rate monitoring
|
||||
- Historical metrics (last 24 hours)
|
||||
- Alert thresholds
|
||||
- System uptime tracking
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
project_manager: ProjectManager,
|
||||
audit_logger: AuditLogger | None = None,
|
||||
metrics_retention_hours: int = 24,
|
||||
max_metrics_per_project: int = 1000,
|
||||
):
|
||||
"""
|
||||
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
|
||||
"""
|
||||
self.project_manager = project_manager
|
||||
self.audit_logger = audit_logger
|
||||
self.metrics_retention_hours = metrics_retention_hours
|
||||
self.max_metrics_per_project = max_metrics_per_project
|
||||
|
||||
# Metrics storage (in-memory)
|
||||
# Using deque for efficient FIFO operations
|
||||
self.metrics_history: dict[str, deque] = defaultdict(
|
||||
lambda: deque(maxlen=max_metrics_per_project)
|
||||
)
|
||||
|
||||
# Request counters
|
||||
self.total_requests = 0
|
||||
self.successful_requests = 0
|
||||
self.failed_requests = 0
|
||||
|
||||
# Response time tracking
|
||||
self.response_times: deque = deque(maxlen=1000) # Last 1000 requests
|
||||
|
||||
# System start time
|
||||
self.start_time = time.time()
|
||||
|
||||
# Alert thresholds (configurable)
|
||||
self.alert_thresholds: dict[str, list[AlertThreshold]] = defaultdict(list)
|
||||
self._setup_default_thresholds()
|
||||
|
||||
# Request rate tracking (for requests per minute)
|
||||
self.request_timestamps: deque = deque(maxlen=1000)
|
||||
|
||||
logger.info("HealthMonitor initialized (Phase 7.2)")
|
||||
|
||||
def _setup_default_thresholds(self):
|
||||
"""Setup default alert thresholds."""
|
||||
# Response time threshold: > 5000ms (5 seconds) is critical
|
||||
self.alert_thresholds["global"].append(
|
||||
AlertThreshold(
|
||||
name="High Response Time",
|
||||
metric="response_time_ms",
|
||||
threshold=5000.0,
|
||||
comparison="gt",
|
||||
severity="critical",
|
||||
)
|
||||
)
|
||||
|
||||
# Error rate threshold: > 10% is warning, > 25% is critical
|
||||
self.alert_thresholds["global"].append(
|
||||
AlertThreshold(
|
||||
name="High Error Rate",
|
||||
metric="error_rate_percent",
|
||||
threshold=10.0,
|
||||
comparison="gt",
|
||||
severity="warning",
|
||||
)
|
||||
)
|
||||
|
||||
self.alert_thresholds["global"].append(
|
||||
AlertThreshold(
|
||||
name="Critical Error Rate",
|
||||
metric="error_rate_percent",
|
||||
threshold=25.0,
|
||||
comparison="gt",
|
||||
severity="critical",
|
||||
)
|
||||
)
|
||||
|
||||
def add_alert_threshold(
|
||||
self,
|
||||
project_id: str,
|
||||
name: str,
|
||||
metric: str,
|
||||
threshold: float,
|
||||
comparison: str = "gt",
|
||||
severity: str = "warning",
|
||||
):
|
||||
"""
|
||||
Add a custom alert threshold for a project.
|
||||
|
||||
Args:
|
||||
project_id: Project ID or "global" for all projects
|
||||
name: Alert name
|
||||
metric: Metric to check
|
||||
threshold: Threshold value
|
||||
comparison: Comparison operator ("gt", "lt", "eq")
|
||||
severity: Alert severity ("info", "warning", "critical")
|
||||
"""
|
||||
alert = AlertThreshold(name, metric, threshold, comparison, severity)
|
||||
self.alert_thresholds[project_id].append(alert)
|
||||
logger.info(f"Added alert threshold '{name}' for {project_id}")
|
||||
|
||||
def record_request(
|
||||
self,
|
||||
project_id: str,
|
||||
response_time_ms: float,
|
||||
success: bool,
|
||||
error_message: str | None = None,
|
||||
):
|
||||
"""
|
||||
Record a request metric.
|
||||
|
||||
Args:
|
||||
project_id: Project that handled the request
|
||||
response_time_ms: Response time in milliseconds
|
||||
success: Whether request succeeded
|
||||
error_message: Error message if failed
|
||||
"""
|
||||
# Create metric
|
||||
metric = HealthMetric(
|
||||
timestamp=datetime.now(UTC),
|
||||
project_id=project_id,
|
||||
response_time_ms=response_time_ms,
|
||||
success=success,
|
||||
error_message=error_message,
|
||||
)
|
||||
|
||||
# Store in history
|
||||
self.metrics_history[project_id].append(metric)
|
||||
|
||||
# Update counters
|
||||
self.total_requests += 1
|
||||
if success:
|
||||
self.successful_requests += 1
|
||||
else:
|
||||
self.failed_requests += 1
|
||||
|
||||
# Track response time
|
||||
self.response_times.append(response_time_ms)
|
||||
|
||||
# Track request timestamp for rate calculation
|
||||
self.request_timestamps.append(time.time())
|
||||
|
||||
# Log to audit if available
|
||||
if self.audit_logger:
|
||||
self.audit_logger.log_system_event(
|
||||
event="health_metric_recorded",
|
||||
details={
|
||||
"project_id": project_id,
|
||||
"response_time_ms": response_time_ms,
|
||||
"success": success,
|
||||
"error_message": error_message,
|
||||
},
|
||||
)
|
||||
|
||||
def _cleanup_old_metrics(self, project_id: str):
|
||||
"""Remove metrics older than retention period."""
|
||||
if project_id not in self.metrics_history:
|
||||
return
|
||||
|
||||
cutoff_time = datetime.now(UTC) - timedelta(hours=self.metrics_retention_hours)
|
||||
metrics = self.metrics_history[project_id]
|
||||
|
||||
# Remove old metrics from the front of deque
|
||||
while metrics and metrics[0].timestamp < cutoff_time:
|
||||
metrics.popleft()
|
||||
|
||||
def get_project_metrics(self, project_id: str, hours: int = 1) -> dict[str, Any]:
|
||||
"""
|
||||
Get metrics for a specific project.
|
||||
|
||||
Args:
|
||||
project_id: Project ID
|
||||
hours: Number of hours of history to analyze
|
||||
|
||||
Returns:
|
||||
Dictionary with metrics
|
||||
"""
|
||||
self._cleanup_old_metrics(project_id)
|
||||
|
||||
if project_id not in self.metrics_history:
|
||||
return {"project_id": project_id, "total_requests": 0, "error": "No metrics available"}
|
||||
|
||||
# Filter metrics by time window
|
||||
cutoff_time = datetime.now(UTC) - timedelta(hours=hours)
|
||||
metrics = [m for m in self.metrics_history[project_id] if m.timestamp >= cutoff_time]
|
||||
|
||||
if not metrics:
|
||||
return {"project_id": project_id, "total_requests": 0, "time_window_hours": hours}
|
||||
|
||||
# Calculate statistics
|
||||
total_requests = len(metrics)
|
||||
successful = sum(1 for m in metrics if m.success)
|
||||
failed = total_requests - successful
|
||||
error_rate = (failed / total_requests * 100) if total_requests > 0 else 0.0
|
||||
|
||||
# Response time statistics
|
||||
response_times = [m.response_time_ms for m in metrics]
|
||||
avg_response = sum(response_times) / len(response_times) if response_times else 0.0
|
||||
min_response = min(response_times) if response_times else 0.0
|
||||
max_response = max(response_times) if response_times else 0.0
|
||||
|
||||
# Recent errors (last 5)
|
||||
recent_errors = [m.error_message for m in metrics if not m.success and m.error_message][-5:]
|
||||
|
||||
return {
|
||||
"project_id": project_id,
|
||||
"time_window_hours": hours,
|
||||
"total_requests": total_requests,
|
||||
"successful_requests": successful,
|
||||
"failed_requests": failed,
|
||||
"error_rate_percent": round(error_rate, 2),
|
||||
"response_time": {
|
||||
"average_ms": round(avg_response, 2),
|
||||
"min_ms": round(min_response, 2),
|
||||
"max_ms": round(max_response, 2),
|
||||
},
|
||||
"recent_errors": recent_errors,
|
||||
}
|
||||
|
||||
def _check_alerts(self, project_id: str, metrics: dict[str, Any]) -> list[str]:
|
||||
"""
|
||||
Check if any alert thresholds are exceeded.
|
||||
|
||||
Args:
|
||||
project_id: Project ID
|
||||
metrics: Current metrics
|
||||
|
||||
Returns:
|
||||
List of alert messages
|
||||
"""
|
||||
alerts = []
|
||||
|
||||
# Check global thresholds
|
||||
for threshold in self.alert_thresholds["global"]:
|
||||
if threshold.metric in metrics:
|
||||
value = metrics[threshold.metric]
|
||||
if threshold.check(value):
|
||||
alerts.append(
|
||||
f"[{threshold.severity.upper()}] {threshold.name}: "
|
||||
f"{threshold.metric}={value} (threshold: {threshold.threshold})"
|
||||
)
|
||||
|
||||
# Check project-specific thresholds
|
||||
for threshold in self.alert_thresholds.get(project_id, []):
|
||||
if threshold.metric in metrics:
|
||||
value = metrics[threshold.metric]
|
||||
if threshold.check(value):
|
||||
alerts.append(
|
||||
f"[{threshold.severity.upper()}] {threshold.name}: "
|
||||
f"{threshold.metric}={value} (threshold: {threshold.threshold})"
|
||||
)
|
||||
|
||||
return alerts
|
||||
|
||||
async def check_project_health(
|
||||
self, project_id: str, include_metrics: bool = True
|
||||
) -> ProjectHealthStatus:
|
||||
"""
|
||||
Perform comprehensive health check on a project.
|
||||
|
||||
Args:
|
||||
project_id: Project ID to check
|
||||
include_metrics: Whether to include historical metrics
|
||||
|
||||
Returns:
|
||||
ProjectHealthStatus object
|
||||
"""
|
||||
start_time = time.time()
|
||||
|
||||
try:
|
||||
# Get plugin instance
|
||||
plugin = self.project_manager.projects.get(project_id)
|
||||
if not plugin:
|
||||
return ProjectHealthStatus(
|
||||
project_id=project_id,
|
||||
healthy=False,
|
||||
last_check=datetime.now(UTC),
|
||||
response_time_ms=0.0,
|
||||
error_rate_percent=100.0,
|
||||
recent_errors=["Project not found"],
|
||||
alerts=["CRITICAL: Project not found"],
|
||||
)
|
||||
|
||||
# Perform health check
|
||||
health_result = await plugin.health_check()
|
||||
response_time_ms = (time.time() - start_time) * 1000
|
||||
|
||||
# Handle both dict and string (JSON) responses
|
||||
if isinstance(health_result, str):
|
||||
try:
|
||||
import json
|
||||
|
||||
health_result = json.loads(health_result)
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
# If not valid JSON, treat as error message
|
||||
health_result = {"healthy": False, "message": health_result}
|
||||
|
||||
# Ensure health_result is a dict
|
||||
if not isinstance(health_result, dict):
|
||||
health_result = {"healthy": False, "message": str(health_result)}
|
||||
|
||||
# Record this health check
|
||||
is_healthy = health_result.get("healthy", False) or health_result.get("success", False)
|
||||
self.record_request(
|
||||
project_id=project_id,
|
||||
response_time_ms=response_time_ms,
|
||||
success=is_healthy,
|
||||
error_message=(
|
||||
health_result.get("message") or health_result.get("error")
|
||||
if not is_healthy
|
||||
else None
|
||||
),
|
||||
)
|
||||
|
||||
# Get metrics if requested
|
||||
metrics_data = {}
|
||||
error_rate = 0.0
|
||||
recent_errors = []
|
||||
|
||||
if include_metrics:
|
||||
metrics_data = self.get_project_metrics(project_id, hours=1)
|
||||
error_rate = metrics_data.get("error_rate_percent", 0.0)
|
||||
recent_errors = metrics_data.get("recent_errors", [])
|
||||
|
||||
# Check alerts
|
||||
alert_check_data = {
|
||||
"response_time_ms": response_time_ms,
|
||||
"error_rate_percent": error_rate,
|
||||
}
|
||||
alerts = self._check_alerts(project_id, alert_check_data)
|
||||
|
||||
return ProjectHealthStatus(
|
||||
project_id=project_id,
|
||||
healthy=is_healthy,
|
||||
last_check=datetime.now(UTC),
|
||||
response_time_ms=response_time_ms,
|
||||
error_rate_percent=error_rate,
|
||||
recent_errors=recent_errors,
|
||||
alerts=alerts,
|
||||
details=health_result,
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
response_time_ms = (time.time() - start_time) * 1000
|
||||
error_msg = str(e)
|
||||
|
||||
# Record failed health check
|
||||
self.record_request(
|
||||
project_id=project_id,
|
||||
response_time_ms=response_time_ms,
|
||||
success=False,
|
||||
error_message=error_msg,
|
||||
)
|
||||
|
||||
return ProjectHealthStatus(
|
||||
project_id=project_id,
|
||||
healthy=False,
|
||||
last_check=datetime.now(UTC),
|
||||
response_time_ms=response_time_ms,
|
||||
error_rate_percent=100.0,
|
||||
recent_errors=[error_msg],
|
||||
alerts=[f"CRITICAL: Health check failed - {error_msg}"],
|
||||
)
|
||||
|
||||
async def check_all_projects_health(self, include_metrics: bool = True) -> dict[str, Any]:
|
||||
"""
|
||||
Check health of all projects.
|
||||
|
||||
Args:
|
||||
include_metrics: Whether to include historical metrics
|
||||
|
||||
Returns:
|
||||
Dictionary with overall health status
|
||||
"""
|
||||
health_statuses = {}
|
||||
|
||||
# Check each project
|
||||
for project_id in self.project_manager.projects.keys():
|
||||
status = await self.check_project_health(project_id, include_metrics)
|
||||
health_statuses[project_id] = status.to_dict()
|
||||
|
||||
# Calculate summary
|
||||
total_projects = len(health_statuses)
|
||||
healthy_projects = sum(1 for s in health_statuses.values() if s["healthy"])
|
||||
unhealthy_projects = total_projects - healthy_projects
|
||||
|
||||
# Collect all alerts
|
||||
all_alerts = []
|
||||
for status in health_statuses.values():
|
||||
all_alerts.extend(status.get("alerts", []))
|
||||
|
||||
return {
|
||||
"timestamp": datetime.now(UTC).isoformat(),
|
||||
"status": (
|
||||
"healthy"
|
||||
if unhealthy_projects == 0
|
||||
else ("degraded" if healthy_projects > 0 else "unhealthy")
|
||||
),
|
||||
"summary": {
|
||||
"total_projects": total_projects,
|
||||
"healthy": healthy_projects,
|
||||
"unhealthy": unhealthy_projects,
|
||||
},
|
||||
"alerts": all_alerts,
|
||||
"projects": health_statuses,
|
||||
}
|
||||
|
||||
def get_system_metrics(self) -> SystemMetrics:
|
||||
"""
|
||||
Get overall system metrics.
|
||||
|
||||
Returns:
|
||||
SystemMetrics object
|
||||
"""
|
||||
# Calculate uptime
|
||||
uptime_seconds = time.time() - self.start_time
|
||||
|
||||
# Calculate average response time
|
||||
avg_response_time = (
|
||||
sum(self.response_times) / len(self.response_times) if self.response_times else 0.0
|
||||
)
|
||||
|
||||
# Calculate error rate
|
||||
error_rate = (
|
||||
(self.failed_requests / self.total_requests * 100) if self.total_requests > 0 else 0.0
|
||||
)
|
||||
|
||||
# Calculate requests per minute
|
||||
now = time.time()
|
||||
one_minute_ago = now - 60
|
||||
recent_requests = sum(1 for ts in self.request_timestamps if ts >= one_minute_ago)
|
||||
|
||||
return SystemMetrics(
|
||||
uptime_seconds=uptime_seconds,
|
||||
total_requests=self.total_requests,
|
||||
successful_requests=self.successful_requests,
|
||||
failed_requests=self.failed_requests,
|
||||
average_response_time_ms=round(avg_response_time, 2),
|
||||
error_rate_percent=round(error_rate, 2),
|
||||
requests_per_minute=recent_requests,
|
||||
)
|
||||
|
||||
def get_uptime(self) -> dict[str, Any]:
|
||||
"""
|
||||
Get system uptime information.
|
||||
|
||||
Returns:
|
||||
Dictionary with uptime details
|
||||
"""
|
||||
uptime_seconds = time.time() - self.start_time
|
||||
uptime_minutes = uptime_seconds / 60
|
||||
uptime_hours = uptime_minutes / 60
|
||||
uptime_days = uptime_hours / 24
|
||||
|
||||
return {
|
||||
"start_time": datetime.fromtimestamp(self.start_time, tz=UTC).isoformat(),
|
||||
"current_time": datetime.now(UTC).isoformat(),
|
||||
"uptime_seconds": round(uptime_seconds, 2),
|
||||
"uptime_minutes": round(uptime_minutes, 2),
|
||||
"uptime_hours": round(uptime_hours, 2),
|
||||
"uptime_days": round(uptime_days, 2),
|
||||
"uptime_formatted": self._format_uptime(uptime_seconds),
|
||||
}
|
||||
|
||||
def _format_uptime(self, seconds: float) -> str:
|
||||
"""Format uptime as human-readable string."""
|
||||
days = int(seconds // 86400)
|
||||
hours = int((seconds % 86400) // 3600)
|
||||
minutes = int((seconds % 3600) // 60)
|
||||
secs = int(seconds % 60)
|
||||
|
||||
parts = []
|
||||
if days > 0:
|
||||
parts.append(f"{days}d")
|
||||
if hours > 0:
|
||||
parts.append(f"{hours}h")
|
||||
if minutes > 0:
|
||||
parts.append(f"{minutes}m")
|
||||
parts.append(f"{secs}s")
|
||||
|
||||
return " ".join(parts)
|
||||
|
||||
def export_metrics(self, output_path: str | None = None, format: str = "json") -> str:
|
||||
"""
|
||||
Export all metrics to file.
|
||||
|
||||
Args:
|
||||
output_path: Output file path (default: logs/metrics_export.json)
|
||||
format: Export format ("json" only for now)
|
||||
|
||||
Returns:
|
||||
Path to exported file
|
||||
"""
|
||||
if output_path is None:
|
||||
output_path = "logs/metrics_export.json"
|
||||
|
||||
# Prepare export data
|
||||
export_data = {
|
||||
"export_time": datetime.now(UTC).isoformat(),
|
||||
"system_metrics": self.get_system_metrics().to_dict(),
|
||||
"uptime": self.get_uptime(),
|
||||
"projects": {},
|
||||
}
|
||||
|
||||
# Add per-project metrics
|
||||
for project_id in self.metrics_history.keys():
|
||||
export_data["projects"][project_id] = {
|
||||
"metrics": self.get_project_metrics(project_id, hours=24),
|
||||
"history": [m.to_dict() for m in self.metrics_history[project_id]],
|
||||
}
|
||||
|
||||
# Write to file
|
||||
output_file = Path(output_path)
|
||||
output_file.parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
with open(output_file, "w", encoding="utf-8") as f:
|
||||
json.dump(export_data, f, indent=2, ensure_ascii=False)
|
||||
|
||||
logger.info(f"Metrics exported to {output_path}")
|
||||
return str(output_file)
|
||||
|
||||
def reset_metrics(self):
|
||||
"""Reset all metrics (use with caution)."""
|
||||
self.metrics_history.clear()
|
||||
self.total_requests = 0
|
||||
self.successful_requests = 0
|
||||
self.failed_requests = 0
|
||||
self.response_times.clear()
|
||||
self.request_timestamps.clear()
|
||||
logger.warning("All metrics have been reset")
|
||||
|
||||
# Singleton instance
|
||||
_health_monitor: HealthMonitor | None = None
|
||||
|
||||
def get_health_monitor() -> HealthMonitor | None:
|
||||
"""Get the global health monitor instance."""
|
||||
return _health_monitor
|
||||
|
||||
def initialize_health_monitor(
|
||||
project_manager: ProjectManager, audit_logger: AuditLogger | None = None, **kwargs
|
||||
) -> HealthMonitor:
|
||||
"""
|
||||
Initialize the global health monitor.
|
||||
|
||||
Args:
|
||||
project_manager: Project manager instance
|
||||
audit_logger: Optional audit logger
|
||||
**kwargs: Additional configuration options
|
||||
|
||||
Returns:
|
||||
HealthMonitor instance
|
||||
"""
|
||||
global _health_monitor
|
||||
_health_monitor = HealthMonitor(project_manager, audit_logger, **kwargs)
|
||||
return _health_monitor
|
||||
194
core/i18n.py
Normal file
194
core/i18n.py
Normal file
@@ -0,0 +1,194 @@
|
||||
"""
|
||||
Internationalization (i18n) utilities for OAuth Authorization pages.
|
||||
|
||||
Supports English (en) and Persian/Farsi (fa) languages.
|
||||
Language is auto-detected from Accept-Language header or query parameter.
|
||||
"""
|
||||
|
||||
# Translation dictionary for OAuth authorization pages
|
||||
TRANSLATIONS: dict[str, dict[str, str]] = {
|
||||
"en": {
|
||||
# Page title
|
||||
"page_title": "Authorization Required - MCP Hub",
|
||||
# Header section
|
||||
"auth_required": "Authorization Required",
|
||||
"wants_access": "{client_name} wants to access your MCP Hub",
|
||||
# Client information
|
||||
"client_info": "Client Information",
|
||||
"client_id_label": "Client ID:",
|
||||
"redirect_uri_label": "Redirect URI:",
|
||||
# Permissions section
|
||||
"requested_permissions": "Requested Permissions:",
|
||||
"no_permissions": "No specific permissions requested",
|
||||
# Form section
|
||||
"enter_api_key": "Enter your API Key to authorize",
|
||||
"api_key_placeholder": "cmp_xxxxxxxxxxxxxxxx",
|
||||
"api_key_note": "Your API key will be validated and used to grant permissions to this application.",
|
||||
# Buttons
|
||||
"approve": "Authorize",
|
||||
"deny": "Deny",
|
||||
# Security notice
|
||||
"security_note": "Security Note:",
|
||||
"security_message": "Only authorize applications you trust. The OAuth token will inherit the permissions from your API key.",
|
||||
# Error page
|
||||
"error_title": "Authorization Error - MCP Hub",
|
||||
"auth_error": "Authorization Error",
|
||||
"unable_to_complete": "Unable to complete the authorization request",
|
||||
"error_code": "Error Code:",
|
||||
"error_description": "Description:",
|
||||
"common_solutions": "Common Solutions:",
|
||||
"solution_1": "Verify your API key is correct and active",
|
||||
"solution_2": "Check that the client application is properly configured",
|
||||
"solution_3": "Ensure the redirect URI matches the registered URI",
|
||||
"return_to_app": "Return to Application",
|
||||
"close_window": "Close Window",
|
||||
"need_help": "Need help? Check the",
|
||||
"documentation": "documentation",
|
||||
# Footer
|
||||
"secured_with": "This connection is secured with OAuth 2.1 + PKCE",
|
||||
},
|
||||
"fa": {
|
||||
# Page title
|
||||
"page_title": "احراز هویت مورد نیاز - MCP Hub",
|
||||
# Header section
|
||||
"auth_required": "احراز هویت مورد نیاز",
|
||||
"wants_access": "{client_name} میخواهد به MCP Hub شما دسترسی داشته باشد",
|
||||
# Client information
|
||||
"client_info": "اطلاعات برنامه",
|
||||
"client_id_label": "شناسه برنامه:",
|
||||
"redirect_uri_label": "آدرس بازگشت:",
|
||||
# Permissions section
|
||||
"requested_permissions": "دسترسیهای درخواستی:",
|
||||
"no_permissions": "دسترسی خاصی درخواست نشده",
|
||||
# Form section
|
||||
"enter_api_key": "API Key خود را برای احراز هویت وارد کنید",
|
||||
"api_key_placeholder": "cmp_xxxxxxxxxxxxxxxx",
|
||||
"api_key_note": "API Key شما اعتبارسنجی شده و برای اعطای دسترسی به این برنامه استفاده خواهد شد.",
|
||||
# Buttons
|
||||
"approve": "تایید",
|
||||
"deny": "رد",
|
||||
# Security notice
|
||||
"security_note": "نکته امنیتی:",
|
||||
"security_message": "فقط برنامههایی را که به آنها اعتماد دارید تایید کنید. توکن OAuth دسترسیهای API Key شما را به ارث خواهد برد.",
|
||||
# Error page
|
||||
"error_title": "خطای احراز هویت - MCP Hub",
|
||||
"auth_error": "خطای احراز هویت",
|
||||
"unable_to_complete": "امکان تکمیل درخواست احراز هویت وجود ندارد",
|
||||
"error_code": "کد خطا:",
|
||||
"error_description": "توضیحات:",
|
||||
"common_solutions": "راهحلهای رایج:",
|
||||
"solution_1": "اطمینان حاصل کنید که API Key شما صحیح و فعال است",
|
||||
"solution_2": "بررسی کنید که برنامه به درستی پیکربندی شده است",
|
||||
"solution_3": "اطمینان حاصل کنید که آدرس بازگشت با آدرس ثبتشده مطابقت دارد",
|
||||
"return_to_app": "بازگشت به برنامه",
|
||||
"close_window": "بستن پنجره",
|
||||
"need_help": "نیاز به کمک دارید؟",
|
||||
"documentation": "مستندات",
|
||||
# Footer
|
||||
"secured_with": "این اتصال با OAuth 2.1 + PKCE امن شده است",
|
||||
},
|
||||
}
|
||||
|
||||
def detect_language(accept_language: str | None = None, query_lang: str | None = None) -> str:
|
||||
"""
|
||||
Detect user's preferred language from Accept-Language header or query parameter.
|
||||
|
||||
Args:
|
||||
accept_language: Accept-Language header value (e.g., "en-US,en;q=0.9,fa;q=0.8")
|
||||
query_lang: Explicit language parameter from query string (takes priority)
|
||||
|
||||
Returns:
|
||||
Language code: "en" or "fa"
|
||||
"""
|
||||
# Priority 1: Explicit query parameter
|
||||
if query_lang:
|
||||
lang = query_lang.lower().strip()
|
||||
if lang in ["fa", "persian", "farsi"]:
|
||||
return "fa"
|
||||
if lang in ["en", "english"]:
|
||||
return "en"
|
||||
|
||||
# Priority 2: Accept-Language header
|
||||
if accept_language:
|
||||
# Parse Accept-Language header
|
||||
# Format: "en-US,en;q=0.9,fa;q=0.8,fa-IR;q=0.7"
|
||||
languages = []
|
||||
for lang_entry in accept_language.split(","):
|
||||
# Extract language code (ignore quality value)
|
||||
lang = lang_entry.split(";")[0].strip().lower()
|
||||
languages.append(lang)
|
||||
|
||||
# Check for Persian/Farsi
|
||||
for lang in languages:
|
||||
if lang.startswith("fa"): # fa, fa-IR, fa-AF
|
||||
return "fa"
|
||||
|
||||
# Check for English
|
||||
for lang in languages:
|
||||
if lang.startswith("en"): # en, en-US, en-GB
|
||||
return "en"
|
||||
|
||||
# Default: English
|
||||
return "en"
|
||||
|
||||
def get_translation(lang: str, key: str, **kwargs) -> str:
|
||||
"""
|
||||
Get translated string for the given language and key.
|
||||
|
||||
Args:
|
||||
lang: Language code ("en" or "fa")
|
||||
key: Translation key
|
||||
**kwargs: Format arguments for string interpolation
|
||||
|
||||
Returns:
|
||||
Translated string with format arguments applied
|
||||
|
||||
Example:
|
||||
>>> get_translation("en", "wants_access", client_name="ChatGPT")
|
||||
'ChatGPT wants to access your MCP Hub'
|
||||
|
||||
>>> get_translation("fa", "wants_access", client_name="ChatGPT")
|
||||
'ChatGPT میخواهد به MCP Hub شما دسترسی داشته باشد'
|
||||
"""
|
||||
# Get language dictionary (fallback to English)
|
||||
lang_dict = TRANSLATIONS.get(lang, TRANSLATIONS["en"])
|
||||
|
||||
# Get translation (fallback to key itself)
|
||||
text = lang_dict.get(key, key)
|
||||
|
||||
# Apply format arguments if provided
|
||||
if kwargs:
|
||||
try:
|
||||
return text.format(**kwargs)
|
||||
except (KeyError, ValueError):
|
||||
# If formatting fails, return raw text
|
||||
return text
|
||||
|
||||
return text
|
||||
|
||||
def get_all_translations(lang: str) -> dict[str, str]:
|
||||
"""
|
||||
Get all translations for a language as a dictionary.
|
||||
|
||||
Useful for passing to templates.
|
||||
|
||||
Args:
|
||||
lang: Language code ("en" or "fa")
|
||||
|
||||
Returns:
|
||||
Dictionary of all translations for the language
|
||||
"""
|
||||
return TRANSLATIONS.get(lang, TRANSLATIONS["en"])
|
||||
|
||||
def get_language_name(lang: str) -> str:
|
||||
"""
|
||||
Get human-readable language name.
|
||||
|
||||
Args:
|
||||
lang: Language code
|
||||
|
||||
Returns:
|
||||
Language name in its native form
|
||||
"""
|
||||
names = {"en": "English", "fa": "فارسی"}
|
||||
return names.get(lang, "English")
|
||||
12
core/middleware/__init__.py
Normal file
12
core/middleware/__init__.py
Normal file
@@ -0,0 +1,12 @@
|
||||
"""
|
||||
Middleware Package
|
||||
|
||||
Organized middleware for MCP server.
|
||||
Part of Option B clean architecture refactoring.
|
||||
"""
|
||||
|
||||
from core.middleware.audit import AuditLoggingMiddleware
|
||||
from core.middleware.auth import UserAuthMiddleware
|
||||
from core.middleware.rate_limit import RateLimitMiddleware
|
||||
|
||||
__all__ = ["UserAuthMiddleware", "AuditLoggingMiddleware", "RateLimitMiddleware"]
|
||||
65
core/oauth/__init__.py
Normal file
65
core/oauth/__init__.py
Normal file
@@ -0,0 +1,65 @@
|
||||
"""
|
||||
OAuth 2.1 Infrastructure for MCP Hub
|
||||
|
||||
This module provides OAuth 2.1 compliant authentication and authorization
|
||||
infrastructure with PKCE support, refresh token rotation, and security best practices.
|
||||
"""
|
||||
|
||||
# Schemas
|
||||
# Client Registry
|
||||
from .client_registry import ClientRegistry, get_client_registry
|
||||
|
||||
# CSRF Protection (Phase E)
|
||||
from .csrf import CSRFTokenManager, get_csrf_manager
|
||||
|
||||
# PKCE utilities
|
||||
from .pkce import generate_code_challenge, generate_code_verifier, validate_code_challenge
|
||||
from .schemas import (
|
||||
AccessToken,
|
||||
AuthorizationCode,
|
||||
OAuthClient,
|
||||
RefreshToken,
|
||||
TokenRequest,
|
||||
TokenResponse,
|
||||
)
|
||||
|
||||
# OAuth Server
|
||||
from .server import OAuthError, OAuthServer, get_oauth_server
|
||||
|
||||
# Storage
|
||||
from .storage import BaseStorage, JSONStorage, get_storage
|
||||
|
||||
# Token Manager
|
||||
from .token_manager import SecurityError, TokenManager, get_token_manager
|
||||
|
||||
__all__ = [
|
||||
# Schemas
|
||||
"OAuthClient",
|
||||
"AuthorizationCode",
|
||||
"AccessToken",
|
||||
"RefreshToken",
|
||||
"TokenRequest",
|
||||
"TokenResponse",
|
||||
# PKCE
|
||||
"generate_code_verifier",
|
||||
"generate_code_challenge",
|
||||
"validate_code_challenge",
|
||||
# Storage
|
||||
"BaseStorage",
|
||||
"JSONStorage",
|
||||
"get_storage",
|
||||
# Client Registry
|
||||
"ClientRegistry",
|
||||
"get_client_registry",
|
||||
# Token Manager
|
||||
"TokenManager",
|
||||
"SecurityError",
|
||||
"get_token_manager",
|
||||
# OAuth Server
|
||||
"OAuthServer",
|
||||
"OAuthError",
|
||||
"get_oauth_server",
|
||||
# CSRF Protection
|
||||
"CSRFTokenManager",
|
||||
"get_csrf_manager",
|
||||
]
|
||||
154
core/oauth/client_registry.py
Normal file
154
core/oauth/client_registry.py
Normal file
@@ -0,0 +1,154 @@
|
||||
"""
|
||||
OAuth 2.1 Client Registry
|
||||
Manages OAuth client registration and validation
|
||||
"""
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import logging
|
||||
import secrets
|
||||
from pathlib import Path
|
||||
|
||||
from .schemas import OAuthClient
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
class ClientRegistry:
|
||||
"""
|
||||
OAuth Client Registry with JSON storage.
|
||||
|
||||
Storage: data/oauth_clients.json
|
||||
"""
|
||||
|
||||
def __init__(self, data_dir: str = "/app/data"):
|
||||
self.data_dir = Path(data_dir)
|
||||
self.data_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
self.clients_file = self.data_dir / "oauth_clients.json"
|
||||
|
||||
# Initialize if not exists
|
||||
if not self.clients_file.exists():
|
||||
self._write_clients({})
|
||||
|
||||
# Load clients into memory (small dataset)
|
||||
self.clients: dict[str, OAuthClient] = self._load_clients()
|
||||
|
||||
def _read_clients(self) -> dict:
|
||||
"""Read clients JSON file"""
|
||||
try:
|
||||
with open(self.clients_file) as f:
|
||||
return json.load(f)
|
||||
except Exception as e:
|
||||
logger.error(f"Error reading clients file: {e}")
|
||||
return {}
|
||||
|
||||
def _write_clients(self, data: dict) -> bool:
|
||||
"""Write clients JSON file"""
|
||||
try:
|
||||
with open(self.clients_file, "w") as f:
|
||||
json.dump(data, f, indent=2, default=str)
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.error(f"Error writing clients file: {e}")
|
||||
return False
|
||||
|
||||
def _load_clients(self) -> dict[str, OAuthClient]:
|
||||
"""Load clients from file"""
|
||||
data = self._read_clients()
|
||||
return {client_id: OAuthClient(**client_data) for client_id, client_data in data.items()}
|
||||
|
||||
def _save_clients(self) -> bool:
|
||||
"""Save clients to file"""
|
||||
data = {
|
||||
client_id: client.model_dump(mode="json") for client_id, client in self.clients.items()
|
||||
}
|
||||
return self._write_clients(data)
|
||||
|
||||
def create_client(
|
||||
self,
|
||||
client_name: str,
|
||||
redirect_uris: list[str],
|
||||
grant_types: list[str] | None = None,
|
||||
allowed_scopes: list[str] | None = None,
|
||||
metadata: dict | None = None,
|
||||
) -> tuple[str, str]:
|
||||
"""
|
||||
Create new OAuth client.
|
||||
|
||||
Returns:
|
||||
(client_id, client_secret) tuple
|
||||
"""
|
||||
# Generate client_id and client_secret
|
||||
client_id = f"cmp_client_{secrets.token_urlsafe(16)}"
|
||||
client_secret = secrets.token_urlsafe(32)
|
||||
|
||||
# Hash client secret
|
||||
client_secret_hash = hashlib.sha256(client_secret.encode()).hexdigest()
|
||||
|
||||
# Create client
|
||||
client = OAuthClient(
|
||||
client_id=client_id,
|
||||
client_secret_hash=client_secret_hash,
|
||||
client_name=client_name,
|
||||
redirect_uris=redirect_uris,
|
||||
grant_types=grant_types or ["authorization_code", "refresh_token"],
|
||||
allowed_scopes=allowed_scopes or ["read", "write"],
|
||||
metadata=metadata or {},
|
||||
)
|
||||
|
||||
# Save
|
||||
self.clients[client_id] = client
|
||||
self._save_clients()
|
||||
|
||||
logger.info(f"Created OAuth client: {client_id} ({client_name})")
|
||||
|
||||
# Return client_secret in plain text (only time it's visible)
|
||||
return client_id, client_secret
|
||||
|
||||
def get_client(self, client_id: str) -> OAuthClient | None:
|
||||
"""Get client by ID"""
|
||||
return self.clients.get(client_id)
|
||||
|
||||
def list_clients(self) -> list[OAuthClient]:
|
||||
"""List all clients"""
|
||||
return list(self.clients.values())
|
||||
|
||||
def validate_client_secret(self, client_id: str, client_secret: str) -> bool:
|
||||
"""
|
||||
Validate client secret.
|
||||
|
||||
Args:
|
||||
client_id: Client ID
|
||||
client_secret: Plain text client secret
|
||||
|
||||
Returns:
|
||||
True if valid, False otherwise
|
||||
"""
|
||||
client = self.get_client(client_id)
|
||||
if not client:
|
||||
return False
|
||||
|
||||
# Hash provided secret
|
||||
secret_hash = hashlib.sha256(client_secret.encode()).hexdigest()
|
||||
|
||||
# Constant-time comparison
|
||||
return secrets.compare_digest(secret_hash, client.client_secret_hash)
|
||||
|
||||
def delete_client(self, client_id: str) -> bool:
|
||||
"""Delete OAuth client"""
|
||||
if client_id in self.clients:
|
||||
del self.clients[client_id]
|
||||
self._save_clients()
|
||||
logger.info(f"Deleted OAuth client: {client_id}")
|
||||
return True
|
||||
return False
|
||||
|
||||
# Singleton instance
|
||||
_client_registry: ClientRegistry | None = None
|
||||
|
||||
def get_client_registry() -> ClientRegistry:
|
||||
"""Get singleton ClientRegistry instance"""
|
||||
global _client_registry
|
||||
if _client_registry is None:
|
||||
_client_registry = ClientRegistry()
|
||||
return _client_registry
|
||||
123
core/oauth/csrf.py
Normal file
123
core/oauth/csrf.py
Normal file
@@ -0,0 +1,123 @@
|
||||
"""
|
||||
CSRF Protection for OAuth Authorization
|
||||
|
||||
Implements token-based CSRF protection for the OAuth authorization flow.
|
||||
Tokens are stored in-memory with 10-minute expiry.
|
||||
|
||||
Part of Phase E: Custom OAuth Authorization Page
|
||||
"""
|
||||
|
||||
import secrets
|
||||
import time
|
||||
|
||||
class CSRFTokenManager:
|
||||
"""
|
||||
Manages CSRF tokens for OAuth authorization requests.
|
||||
|
||||
Features:
|
||||
- Generates cryptographically secure random tokens
|
||||
- In-memory storage with automatic expiry
|
||||
- 10-minute token lifetime
|
||||
- Automatic cleanup of expired tokens
|
||||
"""
|
||||
|
||||
def __init__(self, token_lifetime_seconds: int = 600):
|
||||
"""
|
||||
Initialize CSRF token manager.
|
||||
|
||||
Args:
|
||||
token_lifetime_seconds: Token lifetime in seconds (default: 600 = 10 minutes)
|
||||
"""
|
||||
self._tokens: dict[str, float] = {} # token -> expiry_timestamp
|
||||
self._token_lifetime = token_lifetime_seconds
|
||||
|
||||
def generate_token(self) -> str:
|
||||
"""
|
||||
Generate a new CSRF token.
|
||||
|
||||
Returns:
|
||||
Secure random token (32 bytes, hex-encoded = 64 characters)
|
||||
"""
|
||||
token = secrets.token_hex(32)
|
||||
expiry = time.time() + self._token_lifetime
|
||||
self._tokens[token] = expiry
|
||||
|
||||
# Cleanup expired tokens (lazy cleanup)
|
||||
self._cleanup_expired()
|
||||
|
||||
return token
|
||||
|
||||
def validate_token(self, token: str, consume: bool = True) -> bool:
|
||||
"""
|
||||
Validate a CSRF token.
|
||||
|
||||
Args:
|
||||
token: CSRF token to validate
|
||||
consume: If True, token is removed after validation (one-time use)
|
||||
|
||||
Returns:
|
||||
True if token is valid and not expired, False otherwise
|
||||
"""
|
||||
if not token or token not in self._tokens:
|
||||
return False
|
||||
|
||||
expiry = self._tokens[token]
|
||||
now = time.time()
|
||||
|
||||
# Check if token is expired
|
||||
if now > expiry:
|
||||
# Remove expired token
|
||||
self._tokens.pop(token, None)
|
||||
return False
|
||||
|
||||
# Token is valid
|
||||
if consume:
|
||||
# Remove token (one-time use)
|
||||
self._tokens.pop(token, None)
|
||||
|
||||
return True
|
||||
|
||||
def _cleanup_expired(self):
|
||||
"""
|
||||
Remove expired tokens from storage.
|
||||
|
||||
Called automatically during token generation to prevent memory leaks.
|
||||
"""
|
||||
now = time.time()
|
||||
expired_tokens = [token for token, expiry in self._tokens.items() if now > expiry]
|
||||
|
||||
for token in expired_tokens:
|
||||
self._tokens.pop(token, None)
|
||||
|
||||
def get_stats(self) -> dict[str, int]:
|
||||
"""
|
||||
Get statistics about stored tokens.
|
||||
|
||||
Returns:
|
||||
Dictionary with token statistics
|
||||
"""
|
||||
now = time.time()
|
||||
active_tokens = sum(1 for expiry in self._tokens.values() if expiry > now)
|
||||
expired_tokens = len(self._tokens) - active_tokens
|
||||
|
||||
return {
|
||||
"total_tokens": len(self._tokens),
|
||||
"active_tokens": active_tokens,
|
||||
"expired_tokens": expired_tokens,
|
||||
"token_lifetime_seconds": self._token_lifetime,
|
||||
}
|
||||
|
||||
# Global CSRF token manager instance
|
||||
_csrf_manager: CSRFTokenManager | None = None
|
||||
|
||||
def get_csrf_manager() -> CSRFTokenManager:
|
||||
"""
|
||||
Get the global CSRF token manager instance.
|
||||
|
||||
Returns:
|
||||
Global CSRFTokenManager instance (singleton)
|
||||
"""
|
||||
global _csrf_manager
|
||||
if _csrf_manager is None:
|
||||
_csrf_manager = CSRFTokenManager()
|
||||
return _csrf_manager
|
||||
91
core/oauth/pkce.py
Normal file
91
core/oauth/pkce.py
Normal file
@@ -0,0 +1,91 @@
|
||||
"""
|
||||
PKCE (Proof Key for Code Exchange) Implementation
|
||||
RFC 7636 - OAuth 2.1 Mandatory
|
||||
"""
|
||||
|
||||
import base64
|
||||
import hashlib
|
||||
import secrets
|
||||
from typing import Literal
|
||||
|
||||
def generate_code_verifier(length: int = 64) -> str:
|
||||
"""
|
||||
Generate PKCE code verifier.
|
||||
|
||||
Args:
|
||||
length: Length of verifier (43-128 characters)
|
||||
|
||||
Returns:
|
||||
URL-safe random string
|
||||
|
||||
OAuth 2.1 Spec:
|
||||
- Length: 43-128 characters
|
||||
- Character set: [A-Za-z0-9-._~] (unreserved characters)
|
||||
"""
|
||||
if not 43 <= length <= 128:
|
||||
raise ValueError("Code verifier length must be between 43-128")
|
||||
|
||||
# Generate random bytes and encode as URL-safe base64
|
||||
random_bytes = secrets.token_bytes(length)
|
||||
verifier = base64.urlsafe_b64encode(random_bytes).decode("utf-8")
|
||||
|
||||
# Remove padding and truncate to desired length
|
||||
verifier = verifier.rstrip("=")[:length]
|
||||
|
||||
return verifier
|
||||
|
||||
def generate_code_challenge(code_verifier: str, method: Literal["S256"] = "S256") -> str:
|
||||
"""
|
||||
Generate PKCE code challenge from verifier.
|
||||
|
||||
Args:
|
||||
code_verifier: Code verifier string
|
||||
method: Challenge method (OAuth 2.1: only S256)
|
||||
|
||||
Returns:
|
||||
Base64 URL-encoded SHA256 hash of verifier
|
||||
|
||||
OAuth 2.1 Spec:
|
||||
- Only S256 method is allowed (plain is removed)
|
||||
- code_challenge = BASE64URL(SHA256(code_verifier))
|
||||
"""
|
||||
if method != "S256":
|
||||
raise ValueError("OAuth 2.1 only supports S256 method (plain is removed)")
|
||||
|
||||
if not code_verifier:
|
||||
raise ValueError("Code verifier cannot be empty")
|
||||
|
||||
# SHA256 hash
|
||||
digest = hashlib.sha256(code_verifier.encode("utf-8")).digest()
|
||||
|
||||
# Base64 URL encode (no padding)
|
||||
challenge = base64.urlsafe_b64encode(digest).decode("utf-8").rstrip("=")
|
||||
|
||||
return challenge
|
||||
|
||||
def validate_code_challenge(
|
||||
code_verifier: str, code_challenge: str, method: Literal["S256"] = "S256"
|
||||
) -> bool:
|
||||
"""
|
||||
Validate PKCE code verifier against challenge.
|
||||
|
||||
Args:
|
||||
code_verifier: Code verifier from client
|
||||
code_challenge: Code challenge from authorization request
|
||||
method: Challenge method
|
||||
|
||||
Returns:
|
||||
True if valid, False otherwise
|
||||
"""
|
||||
if method != "S256":
|
||||
raise ValueError("Only S256 method is supported")
|
||||
|
||||
try:
|
||||
# Generate challenge from verifier
|
||||
expected_challenge = generate_code_challenge(code_verifier, method)
|
||||
|
||||
# Constant-time comparison to prevent timing attacks
|
||||
return secrets.compare_digest(expected_challenge, code_challenge)
|
||||
|
||||
except Exception:
|
||||
return False
|
||||
145
core/oauth/schemas.py
Normal file
145
core/oauth/schemas.py
Normal file
@@ -0,0 +1,145 @@
|
||||
"""
|
||||
OAuth 2.1 Pydantic Schemas
|
||||
"""
|
||||
|
||||
from datetime import UTC, datetime
|
||||
|
||||
from pydantic import BaseModel, Field, field_validator
|
||||
|
||||
class OAuthClient(BaseModel):
|
||||
"""OAuth 2.1 Client Model"""
|
||||
|
||||
client_id: str = Field(..., description="Client identifier (e.g., cmp_client_xxx)")
|
||||
client_secret_hash: str = Field(..., description="SHA256 hash of client secret")
|
||||
client_name: str = Field(..., description="Human-readable client name")
|
||||
redirect_uris: list[str] = Field(..., description="Allowed redirect URIs (exact match)")
|
||||
grant_types: list[str] = Field(
|
||||
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")
|
||||
allowed_scopes: list[str] = Field(
|
||||
default=["read", "write"], 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)
|
||||
|
||||
@field_validator("redirect_uris")
|
||||
def validate_redirect_uris(cls, v):
|
||||
"""Validate redirect URIs format"""
|
||||
for uri in v:
|
||||
if not uri.startswith(("http://", "https://")):
|
||||
raise ValueError(f"Invalid redirect URI: {uri}")
|
||||
return v
|
||||
|
||||
@field_validator("grant_types")
|
||||
def validate_grant_types(cls, v):
|
||||
"""Validate grant types"""
|
||||
allowed = ["authorization_code", "refresh_token", "client_credentials"]
|
||||
for grant in v:
|
||||
if grant not in allowed:
|
||||
raise ValueError(f"Invalid grant type: {grant}")
|
||||
return v
|
||||
|
||||
class AuthorizationCode(BaseModel):
|
||||
"""Authorization Code Model"""
|
||||
|
||||
code: str = Field(..., description="Authorization code (token_urlsafe)")
|
||||
client_id: str
|
||||
redirect_uri: str
|
||||
scope: str
|
||||
code_challenge: str = Field(..., description="PKCE code challenge")
|
||||
code_challenge_method: str = Field(default="S256")
|
||||
expires_at: datetime
|
||||
used: bool = Field(default=False)
|
||||
user_id: str | None = None # If user-based auth
|
||||
|
||||
# API Key-based authorization
|
||||
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
|
||||
|
||||
def is_expired(self) -> bool:
|
||||
"""Check if code is expired"""
|
||||
now = datetime.now(UTC)
|
||||
expires = self.expires_at
|
||||
if expires.tzinfo is None:
|
||||
expires = expires.replace(tzinfo=UTC)
|
||||
return now > expires
|
||||
|
||||
@field_validator("code_challenge_method")
|
||||
def validate_challenge_method(cls, v):
|
||||
"""OAuth 2.1: Only S256 is allowed"""
|
||||
if v != "S256":
|
||||
raise ValueError("Only S256 code_challenge_method is supported (OAuth 2.1)")
|
||||
return v
|
||||
|
||||
class AccessToken(BaseModel):
|
||||
"""Access Token Model"""
|
||||
|
||||
token: str = Field(..., description="JWT access token")
|
||||
client_id: str
|
||||
scope: str
|
||||
expires_at: datetime
|
||||
user_id: str | None = None
|
||||
project_id: str = Field(default="*", description="Project ID for scoping")
|
||||
issued_at: datetime = Field(default_factory=lambda: datetime.now(UTC))
|
||||
|
||||
def is_expired(self) -> bool:
|
||||
"""Check if token is expired"""
|
||||
now = datetime.now(UTC)
|
||||
expires = self.expires_at
|
||||
if expires.tzinfo is None:
|
||||
expires = expires.replace(tzinfo=UTC)
|
||||
return now > expires
|
||||
|
||||
class RefreshToken(BaseModel):
|
||||
"""Refresh Token Model"""
|
||||
|
||||
token: str = Field(..., description="Refresh token (secure random)")
|
||||
client_id: str
|
||||
access_token: str | None = None
|
||||
expires_at: datetime
|
||||
revoked: bool = Field(default=False)
|
||||
rotation_count: int = Field(default=0, description="Number of times rotated")
|
||||
issued_at: datetime = Field(default_factory=lambda: datetime.now(UTC))
|
||||
|
||||
def is_expired(self) -> bool:
|
||||
"""Check if token is expired"""
|
||||
now = datetime.now(UTC)
|
||||
expires = self.expires_at
|
||||
if expires.tzinfo is None:
|
||||
expires = expires.replace(tzinfo=UTC)
|
||||
return now > expires
|
||||
|
||||
class TokenRequest(BaseModel):
|
||||
"""Token Request Model"""
|
||||
|
||||
grant_type: str = Field(
|
||||
..., description="authorization_code | refresh_token | client_credentials"
|
||||
)
|
||||
|
||||
# For authorization_code
|
||||
code: str | None = None
|
||||
redirect_uri: str | None = None
|
||||
code_verifier: str | None = None
|
||||
|
||||
# For refresh_token
|
||||
refresh_token: str | None = None
|
||||
|
||||
# Common
|
||||
client_id: str
|
||||
client_secret: str | None = None
|
||||
scope: str | None = None
|
||||
|
||||
class TokenResponse(BaseModel):
|
||||
"""Token Response Model"""
|
||||
|
||||
access_token: str
|
||||
token_type: str = "Bearer"
|
||||
expires_in: int
|
||||
refresh_token: str | None = None
|
||||
scope: str
|
||||
396
core/oauth/server.py
Normal file
396
core/oauth/server.py
Normal file
@@ -0,0 +1,396 @@
|
||||
"""
|
||||
OAuth 2.1 Authorization Server
|
||||
Handles OAuth flows: authorization_code, refresh_token, client_credentials
|
||||
"""
|
||||
|
||||
import logging
|
||||
import secrets
|
||||
from datetime import UTC, datetime, timedelta
|
||||
from typing import Any
|
||||
|
||||
from .client_registry import get_client_registry
|
||||
from .pkce import validate_code_challenge
|
||||
from .schemas import AuthorizationCode, TokenResponse
|
||||
from .storage import get_storage
|
||||
from .token_manager import get_token_manager
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
class OAuthError(Exception):
|
||||
"""OAuth error with error code and description"""
|
||||
|
||||
def __init__(self, error: str, error_description: str, status_code: int = 400):
|
||||
self.error = error
|
||||
self.error_description = error_description
|
||||
self.status_code = status_code
|
||||
super().__init__(error_description)
|
||||
|
||||
class OAuthServer:
|
||||
"""
|
||||
OAuth 2.1 Authorization Server
|
||||
|
||||
Implements:
|
||||
- Authorization Code Grant with PKCE (mandatory)
|
||||
- Refresh Token Grant with rotation
|
||||
- Client Credentials Grant (machine-to-machine)
|
||||
"""
|
||||
|
||||
def __init__(self):
|
||||
self.client_registry = get_client_registry()
|
||||
self.token_manager = get_token_manager()
|
||||
self.storage = get_storage()
|
||||
|
||||
# Authorization code TTL (5 minutes)
|
||||
self.auth_code_ttl = 300
|
||||
|
||||
def validate_authorization_request(
|
||||
self,
|
||||
client_id: str,
|
||||
redirect_uri: str,
|
||||
response_type: str,
|
||||
code_challenge: str,
|
||||
code_challenge_method: str,
|
||||
scope: str | None = None,
|
||||
state: str | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""
|
||||
Validate OAuth authorization request (Step 1 of Authorization Code flow)
|
||||
|
||||
Args:
|
||||
client_id: OAuth client ID
|
||||
redirect_uri: Callback URI for authorization code
|
||||
response_type: Must be "code" (OAuth 2.1)
|
||||
code_challenge: PKCE code challenge
|
||||
code_challenge_method: Must be "S256" (OAuth 2.1)
|
||||
scope: Requested scopes (space-separated)
|
||||
state: Optional state parameter for CSRF protection
|
||||
|
||||
Returns:
|
||||
Dict with validated parameters
|
||||
|
||||
Raises:
|
||||
OAuthError: If validation fails
|
||||
"""
|
||||
# Validate client
|
||||
client = self.client_registry.get_client(client_id)
|
||||
if not client:
|
||||
raise OAuthError(
|
||||
error="invalid_client",
|
||||
error_description=f"Client {client_id} not found",
|
||||
status_code=401,
|
||||
)
|
||||
|
||||
# Validate response_type (OAuth 2.1: only "code" is allowed)
|
||||
if response_type != "code":
|
||||
raise OAuthError(
|
||||
error="unsupported_response_type",
|
||||
error_description="Only 'code' response_type is supported (OAuth 2.1)",
|
||||
)
|
||||
|
||||
if "authorization_code" not in client.grant_types:
|
||||
raise OAuthError(
|
||||
error="unauthorized_client",
|
||||
error_description="Client not authorized for authorization_code grant",
|
||||
)
|
||||
|
||||
# Validate redirect_uri (exact match)
|
||||
if redirect_uri not in client.redirect_uris:
|
||||
raise OAuthError(
|
||||
error="invalid_request", error_description=f"Invalid redirect_uri: {redirect_uri}"
|
||||
)
|
||||
|
||||
# Validate PKCE (mandatory in OAuth 2.1)
|
||||
if not code_challenge or not code_challenge_method:
|
||||
raise OAuthError(
|
||||
error="invalid_request",
|
||||
error_description="code_challenge and code_challenge_method are required (OAuth 2.1)",
|
||||
)
|
||||
|
||||
if code_challenge_method != "S256":
|
||||
raise OAuthError(
|
||||
error="invalid_request",
|
||||
error_description="Only S256 code_challenge_method is supported (OAuth 2.1)",
|
||||
)
|
||||
|
||||
# Validate scope
|
||||
requested_scopes = scope.split() if scope else ["read"]
|
||||
for s in requested_scopes:
|
||||
if s not in client.allowed_scopes:
|
||||
raise OAuthError(
|
||||
error="invalid_scope",
|
||||
error_description=f"Scope '{s}' not allowed for this client",
|
||||
)
|
||||
|
||||
return {
|
||||
"client_id": client_id,
|
||||
"redirect_uri": redirect_uri,
|
||||
"scope": " ".join(requested_scopes),
|
||||
"code_challenge": code_challenge,
|
||||
"code_challenge_method": code_challenge_method,
|
||||
"state": state,
|
||||
}
|
||||
|
||||
def create_authorization_code(
|
||||
self,
|
||||
client_id: str,
|
||||
redirect_uri: str,
|
||||
scope: str,
|
||||
code_challenge: str,
|
||||
code_challenge_method: str = "S256",
|
||||
user_id: str | None = None,
|
||||
api_key_id: str | None = None,
|
||||
api_key_project_id: str | None = None,
|
||||
api_key_scope: str | None = None,
|
||||
) -> str:
|
||||
"""
|
||||
Create authorization code (Step 2 of Authorization Code flow)
|
||||
|
||||
Args:
|
||||
client_id: OAuth client ID
|
||||
redirect_uri: Redirect URI
|
||||
scope: Granted scopes
|
||||
code_challenge: PKCE code challenge
|
||||
code_challenge_method: PKCE method (S256)
|
||||
user_id: Optional user ID (for user-based auth)
|
||||
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
|
||||
|
||||
Returns:
|
||||
Authorization code (valid for 5 minutes)
|
||||
"""
|
||||
# Generate secure random code
|
||||
code = f"auth_{secrets.token_urlsafe(32)}"
|
||||
|
||||
# Create authorization code
|
||||
auth_code = AuthorizationCode(
|
||||
code=code,
|
||||
client_id=client_id,
|
||||
redirect_uri=redirect_uri,
|
||||
scope=scope,
|
||||
code_challenge=code_challenge,
|
||||
code_challenge_method=code_challenge_method,
|
||||
expires_at=datetime.now(UTC) + timedelta(seconds=self.auth_code_ttl),
|
||||
used=False,
|
||||
user_id=user_id,
|
||||
api_key_id=api_key_id,
|
||||
api_key_project_id=api_key_project_id,
|
||||
api_key_scope=api_key_scope,
|
||||
)
|
||||
|
||||
# Save to storage
|
||||
self.storage.save_authorization_code(auth_code)
|
||||
|
||||
logger.info(f"Created authorization code for client {client_id}")
|
||||
|
||||
return code
|
||||
|
||||
def exchange_code_for_tokens(
|
||||
self, client_id: str, client_secret: str, code: str, redirect_uri: str, code_verifier: str
|
||||
) -> TokenResponse:
|
||||
"""
|
||||
Exchange authorization code for tokens (Step 3 of Authorization Code flow)
|
||||
|
||||
Args:
|
||||
client_id: OAuth client ID
|
||||
client_secret: Client secret
|
||||
code: Authorization code from /authorize
|
||||
redirect_uri: Same redirect_uri used in /authorize
|
||||
code_verifier: PKCE code verifier
|
||||
|
||||
Returns:
|
||||
TokenResponse with access_token and refresh_token
|
||||
|
||||
Raises:
|
||||
OAuthError: If validation fails
|
||||
"""
|
||||
# Validate client credentials
|
||||
if not self.client_registry.validate_client_secret(client_id, client_secret):
|
||||
raise OAuthError(
|
||||
error="invalid_client",
|
||||
error_description="Invalid client credentials",
|
||||
status_code=401,
|
||||
)
|
||||
|
||||
# Get authorization code
|
||||
auth_code = self.storage.get_authorization_code(code)
|
||||
if not auth_code:
|
||||
raise OAuthError(
|
||||
error="invalid_grant", error_description="Invalid or expired authorization code"
|
||||
)
|
||||
|
||||
# Check if already used (prevents replay attacks)
|
||||
if auth_code.used:
|
||||
# Revoke all tokens for this client (security measure)
|
||||
logger.critical(
|
||||
f"Authorization code reuse detected for client {client_id}! "
|
||||
f"Code: {code[:20]}..."
|
||||
)
|
||||
raise OAuthError(
|
||||
error="invalid_grant", error_description="Authorization code already used"
|
||||
)
|
||||
|
||||
# Validate client_id match
|
||||
if auth_code.client_id != client_id:
|
||||
raise OAuthError(error="invalid_grant", error_description="Client ID mismatch")
|
||||
|
||||
# Validate redirect_uri match
|
||||
if auth_code.redirect_uri != redirect_uri:
|
||||
raise OAuthError(error="invalid_grant", error_description="Redirect URI mismatch")
|
||||
|
||||
# Validate PKCE code_verifier
|
||||
if not validate_code_challenge(
|
||||
code_verifier, auth_code.code_challenge, auth_code.code_challenge_method
|
||||
):
|
||||
raise OAuthError(
|
||||
error="invalid_grant",
|
||||
error_description="Invalid code_verifier (PKCE validation failed)",
|
||||
)
|
||||
|
||||
# Mark code as used
|
||||
auth_code.used = True
|
||||
self.storage.update_authorization_code(code, auth_code)
|
||||
|
||||
# Generate tokens with API Key's project and scope
|
||||
# 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
|
||||
|
||||
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,
|
||||
)
|
||||
|
||||
refresh_token = self.token_manager.generate_refresh_token(
|
||||
client_id=client_id, access_token=access_token
|
||||
)
|
||||
|
||||
logger.info(
|
||||
f"Exchanged authorization code for tokens: {client_id} "
|
||||
f"(project_id={project_id}, scope={token_scope})"
|
||||
)
|
||||
|
||||
return TokenResponse(
|
||||
access_token=access_token,
|
||||
token_type="Bearer",
|
||||
expires_in=self.token_manager.access_token_ttl,
|
||||
refresh_token=refresh_token,
|
||||
scope=auth_code.scope,
|
||||
)
|
||||
|
||||
def handle_refresh_token_grant(
|
||||
self, client_id: str, client_secret: str, refresh_token: str
|
||||
) -> TokenResponse:
|
||||
"""
|
||||
Handle refresh token grant (refresh access token)
|
||||
|
||||
Args:
|
||||
client_id: OAuth client ID
|
||||
client_secret: Client secret
|
||||
refresh_token: Current refresh token
|
||||
|
||||
Returns:
|
||||
TokenResponse with new access_token and refresh_token
|
||||
|
||||
Raises:
|
||||
OAuthError: If validation fails
|
||||
"""
|
||||
# Validate client credentials
|
||||
if not self.client_registry.validate_client_secret(client_id, client_secret):
|
||||
raise OAuthError(
|
||||
error="invalid_client",
|
||||
error_description="Invalid client credentials",
|
||||
status_code=401,
|
||||
)
|
||||
|
||||
# Check grant type is allowed
|
||||
client = self.client_registry.get_client(client_id)
|
||||
if "refresh_token" not in client.grant_types:
|
||||
raise OAuthError(
|
||||
error="unauthorized_client",
|
||||
error_description="Client not authorized for refresh_token grant",
|
||||
)
|
||||
|
||||
try:
|
||||
# Rotate refresh token
|
||||
new_tokens = self.token_manager.rotate_refresh_token(
|
||||
refresh_token=refresh_token, client_id=client_id
|
||||
)
|
||||
|
||||
return TokenResponse(**new_tokens)
|
||||
|
||||
except ValueError as e:
|
||||
raise OAuthError(error="invalid_grant", error_description=str(e))
|
||||
except Exception as e:
|
||||
logger.error(f"Error rotating refresh token: {e}")
|
||||
raise OAuthError(
|
||||
error="server_error", error_description="Internal server error", status_code=500
|
||||
)
|
||||
|
||||
def handle_client_credentials_grant(
|
||||
self, client_id: str, client_secret: str, scope: str | None = None
|
||||
) -> TokenResponse:
|
||||
"""
|
||||
Handle client credentials grant (machine-to-machine)
|
||||
|
||||
Args:
|
||||
client_id: OAuth client ID
|
||||
client_secret: Client secret
|
||||
scope: Requested scopes (space-separated)
|
||||
|
||||
Returns:
|
||||
TokenResponse with access_token (no refresh_token)
|
||||
|
||||
Raises:
|
||||
OAuthError: If validation fails
|
||||
"""
|
||||
# Validate client credentials
|
||||
if not self.client_registry.validate_client_secret(client_id, client_secret):
|
||||
raise OAuthError(
|
||||
error="invalid_client",
|
||||
error_description="Invalid client credentials",
|
||||
status_code=401,
|
||||
)
|
||||
|
||||
# Check grant type is allowed
|
||||
client = self.client_registry.get_client(client_id)
|
||||
if "client_credentials" not in client.grant_types:
|
||||
raise OAuthError(
|
||||
error="unauthorized_client",
|
||||
error_description="Client not authorized for client_credentials grant",
|
||||
)
|
||||
|
||||
# Validate scope
|
||||
requested_scopes = scope.split() if scope else [client.scope]
|
||||
for s in requested_scopes:
|
||||
if s not in client.allowed_scopes:
|
||||
raise OAuthError(
|
||||
error="invalid_scope",
|
||||
error_description=f"Scope '{s}' not allowed for this client",
|
||||
)
|
||||
|
||||
# Generate access token (no refresh token for client credentials)
|
||||
access_token = self.token_manager.generate_access_token(
|
||||
client_id=client_id, scope=" ".join(requested_scopes)
|
||||
)
|
||||
|
||||
logger.info(f"Generated client credentials token for {client_id}")
|
||||
|
||||
return TokenResponse(
|
||||
access_token=access_token,
|
||||
token_type="Bearer",
|
||||
expires_in=self.token_manager.access_token_ttl,
|
||||
scope=" ".join(requested_scopes),
|
||||
)
|
||||
|
||||
# Singleton
|
||||
_oauth_server: OAuthServer | None = None
|
||||
|
||||
def get_oauth_server() -> OAuthServer:
|
||||
"""Get singleton OAuthServer instance"""
|
||||
global _oauth_server
|
||||
if _oauth_server is None:
|
||||
_oauth_server = OAuthServer()
|
||||
return _oauth_server
|
||||
221
core/oauth/storage.py
Normal file
221
core/oauth/storage.py
Normal file
@@ -0,0 +1,221 @@
|
||||
"""
|
||||
OAuth 2.1 Token Storage
|
||||
Supports JSON files (default) and Redis (optional)
|
||||
"""
|
||||
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from .schemas import AccessToken, AuthorizationCode, RefreshToken
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
class BaseStorage:
|
||||
"""Base storage interface"""
|
||||
|
||||
def save_authorization_code(self, code_data: AuthorizationCode) -> bool:
|
||||
raise NotImplementedError
|
||||
|
||||
def get_authorization_code(self, code: str) -> AuthorizationCode | None:
|
||||
raise NotImplementedError
|
||||
|
||||
def update_authorization_code(self, code: str, code_data: AuthorizationCode) -> bool:
|
||||
raise NotImplementedError
|
||||
|
||||
def delete_authorization_code(self, code: str) -> bool:
|
||||
raise NotImplementedError
|
||||
|
||||
def save_access_token(self, token_data: AccessToken) -> bool:
|
||||
raise NotImplementedError
|
||||
|
||||
def get_access_token(self, token: str) -> AccessToken | None:
|
||||
raise NotImplementedError
|
||||
|
||||
def save_refresh_token(self, token_data: RefreshToken) -> bool:
|
||||
raise NotImplementedError
|
||||
|
||||
def get_refresh_token(self, token: str) -> RefreshToken | None:
|
||||
raise NotImplementedError
|
||||
|
||||
def revoke_refresh_token(self, token: str) -> bool:
|
||||
raise NotImplementedError
|
||||
|
||||
class JSONStorage(BaseStorage):
|
||||
"""
|
||||
JSON file storage for OAuth tokens.
|
||||
|
||||
Storage structure:
|
||||
data/oauth_codes.json - Authorization codes (short-lived)
|
||||
data/oauth_access_tokens.json - Access tokens
|
||||
data/oauth_refresh_tokens.json - Refresh tokens
|
||||
"""
|
||||
|
||||
def __init__(self, data_dir: str = "/app/data"):
|
||||
self.data_dir = Path(data_dir)
|
||||
self.data_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
self.codes_file = self.data_dir / "oauth_codes.json"
|
||||
self.access_tokens_file = self.data_dir / "oauth_access_tokens.json"
|
||||
self.refresh_tokens_file = self.data_dir / "oauth_refresh_tokens.json"
|
||||
|
||||
# Initialize files if not exist
|
||||
for file in [self.codes_file, self.access_tokens_file, self.refresh_tokens_file]:
|
||||
if not file.exists():
|
||||
self._write_json(file, {})
|
||||
|
||||
def _read_json(self, file_path: Path) -> dict[str, Any]:
|
||||
"""Read JSON file"""
|
||||
try:
|
||||
with open(file_path) as f:
|
||||
return json.load(f)
|
||||
except Exception as e:
|
||||
logger.error(f"Error reading {file_path}: {e}")
|
||||
return {}
|
||||
|
||||
def _write_json(self, file_path: Path, data: dict[str, Any]) -> bool:
|
||||
"""Write JSON file"""
|
||||
try:
|
||||
with open(file_path, "w") as f:
|
||||
json.dump(data, f, indent=2, default=str)
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.error(f"Error writing {file_path}: {e}")
|
||||
return False
|
||||
|
||||
def save_authorization_code(self, code_data: AuthorizationCode) -> bool:
|
||||
"""Save authorization code"""
|
||||
codes = self._read_json(self.codes_file)
|
||||
codes[code_data.code] = code_data.model_dump(mode="json")
|
||||
return self._write_json(self.codes_file, codes)
|
||||
|
||||
def get_authorization_code(self, code: str) -> AuthorizationCode | None:
|
||||
"""Get authorization code"""
|
||||
codes = self._read_json(self.codes_file)
|
||||
code_data = codes.get(code)
|
||||
|
||||
if not code_data:
|
||||
return None
|
||||
|
||||
# Parse and check expiry
|
||||
auth_code = AuthorizationCode(**code_data)
|
||||
|
||||
if auth_code.is_expired():
|
||||
# Auto-cleanup expired codes
|
||||
self.delete_authorization_code(code)
|
||||
return None
|
||||
|
||||
return auth_code
|
||||
|
||||
def update_authorization_code(self, code: str, code_data: AuthorizationCode) -> bool:
|
||||
"""Update authorization code (e.g., mark as used)"""
|
||||
return self.save_authorization_code(code_data)
|
||||
|
||||
def delete_authorization_code(self, code: str) -> bool:
|
||||
"""Delete authorization code"""
|
||||
codes = self._read_json(self.codes_file)
|
||||
if code in codes:
|
||||
del codes[code]
|
||||
return self._write_json(self.codes_file, codes)
|
||||
return False
|
||||
|
||||
def save_access_token(self, token_data: AccessToken) -> bool:
|
||||
"""Save access token"""
|
||||
tokens = self._read_json(self.access_tokens_file)
|
||||
tokens[token_data.token] = token_data.model_dump(mode="json")
|
||||
return self._write_json(self.access_tokens_file, tokens)
|
||||
|
||||
def get_access_token(self, token: str) -> AccessToken | None:
|
||||
"""Get access token"""
|
||||
tokens = self._read_json(self.access_tokens_file)
|
||||
token_data = tokens.get(token)
|
||||
|
||||
if not token_data:
|
||||
return None
|
||||
|
||||
access_token = AccessToken(**token_data)
|
||||
|
||||
if access_token.is_expired():
|
||||
# Auto-cleanup
|
||||
tokens.pop(token, None)
|
||||
self._write_json(self.access_tokens_file, tokens)
|
||||
return None
|
||||
|
||||
return access_token
|
||||
|
||||
def save_refresh_token(self, token_data: RefreshToken) -> bool:
|
||||
"""Save refresh token"""
|
||||
tokens = self._read_json(self.refresh_tokens_file)
|
||||
tokens[token_data.token] = token_data.model_dump(mode="json")
|
||||
return self._write_json(self.refresh_tokens_file, tokens)
|
||||
|
||||
def get_refresh_token(self, token: str, include_revoked: bool = False) -> RefreshToken | None:
|
||||
"""Get refresh token.
|
||||
|
||||
Args:
|
||||
token: Refresh token string
|
||||
include_revoked: If True, return revoked tokens (for reuse detection)
|
||||
"""
|
||||
tokens = self._read_json(self.refresh_tokens_file)
|
||||
token_data = tokens.get(token)
|
||||
|
||||
if not token_data:
|
||||
return None
|
||||
|
||||
refresh_token = RefreshToken(**token_data)
|
||||
|
||||
if refresh_token.is_expired():
|
||||
return None
|
||||
|
||||
if refresh_token.revoked and not include_revoked:
|
||||
return None
|
||||
|
||||
return refresh_token
|
||||
|
||||
def revoke_refresh_token(self, token: str) -> bool:
|
||||
"""Revoke refresh token"""
|
||||
tokens = self._read_json(self.refresh_tokens_file)
|
||||
|
||||
if token in tokens:
|
||||
tokens[token]["revoked"] = True
|
||||
return self._write_json(self.refresh_tokens_file, tokens)
|
||||
|
||||
return False
|
||||
|
||||
def cleanup_expired(self):
|
||||
"""Cleanup expired tokens (run periodically)"""
|
||||
# Cleanup authorization codes
|
||||
codes = self._read_json(self.codes_file)
|
||||
cleaned_codes = {k: v for k, v in codes.items() if not AuthorizationCode(**v).is_expired()}
|
||||
self._write_json(self.codes_file, cleaned_codes)
|
||||
|
||||
# Cleanup access tokens
|
||||
tokens = self._read_json(self.access_tokens_file)
|
||||
cleaned_tokens = {k: v for k, v in tokens.items() if not AccessToken(**v).is_expired()}
|
||||
self._write_json(self.access_tokens_file, cleaned_tokens)
|
||||
|
||||
logger.info(f"Cleaned up {len(codes) - len(cleaned_codes)} expired authorization codes")
|
||||
logger.info(f"Cleaned up {len(tokens) - len(cleaned_tokens)} expired access tokens")
|
||||
|
||||
def get_storage() -> BaseStorage:
|
||||
"""
|
||||
Get storage instance based on environment.
|
||||
|
||||
Environment Variables:
|
||||
OAUTH_STORAGE_TYPE: "json" (default) | "redis"
|
||||
OAUTH_STORAGE_PATH: Path for JSON files (default: /app/data)
|
||||
"""
|
||||
storage_type = os.getenv("OAUTH_STORAGE_TYPE", "json")
|
||||
|
||||
if storage_type == "json":
|
||||
storage_path = os.getenv("OAUTH_STORAGE_PATH", "/app/data")
|
||||
return JSONStorage(storage_path)
|
||||
|
||||
# Future: Redis support
|
||||
# elif storage_type == "redis":
|
||||
# return RedisStorage()
|
||||
|
||||
else:
|
||||
raise ValueError(f"Unknown storage type: {storage_type}")
|
||||
313
core/oauth/token_manager.py
Normal file
313
core/oauth/token_manager.py
Normal file
@@ -0,0 +1,313 @@
|
||||
"""
|
||||
OAuth 2.1 Token Manager
|
||||
JWT generation, validation, and refresh token rotation
|
||||
"""
|
||||
|
||||
import logging
|
||||
import os
|
||||
import secrets
|
||||
import time
|
||||
from datetime import UTC, datetime, timedelta
|
||||
from typing import Any
|
||||
|
||||
import jwt
|
||||
|
||||
from .schemas import AccessToken, RefreshToken
|
||||
from .storage import get_storage
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
class SecurityError(Exception):
|
||||
"""Security-related error (e.g., token reuse)"""
|
||||
|
||||
pass
|
||||
|
||||
class TokenManager:
|
||||
"""
|
||||
OAuth 2.1 Token Manager.
|
||||
|
||||
Features:
|
||||
- JWT access tokens (1 hour default)
|
||||
- Refresh tokens with rotation (7 days default)
|
||||
- Refresh token reuse detection
|
||||
- Audit logging
|
||||
"""
|
||||
|
||||
def __init__(self):
|
||||
self.storage = get_storage()
|
||||
|
||||
# JWT Configuration — require explicit secret or generate random fallback
|
||||
self.jwt_secret = os.getenv("OAUTH_JWT_SECRET_KEY")
|
||||
if not self.jwt_secret:
|
||||
self.jwt_secret = secrets.token_urlsafe(64)
|
||||
logger.warning(
|
||||
"OAUTH_JWT_SECRET_KEY not set. Generated random JWT secret. "
|
||||
"All tokens will be invalidated on restart. "
|
||||
"Set OAUTH_JWT_SECRET_KEY in your .env for persistent tokens."
|
||||
)
|
||||
self.jwt_algorithm = os.getenv("OAUTH_JWT_ALGORITHM", "HS256")
|
||||
|
||||
# Token TTLs (seconds)
|
||||
self.access_token_ttl = int(os.getenv("OAUTH_ACCESS_TOKEN_TTL", "3600")) # 1 hour
|
||||
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 = "*"
|
||||
) -> str:
|
||||
"""
|
||||
Generate JWT access token.
|
||||
|
||||
Args:
|
||||
client_id: OAuth client ID
|
||||
scope: Granted scopes (space-separated)
|
||||
user_id: User ID (optional, for user-based auth)
|
||||
project_id: Project ID for scoping (default: "*" for global)
|
||||
|
||||
Returns:
|
||||
JWT access token
|
||||
"""
|
||||
now = datetime.now(UTC)
|
||||
now_ts = int(time.time())
|
||||
exp_ts = now_ts + self.access_token_ttl
|
||||
expires_at = now + timedelta(seconds=self.access_token_ttl)
|
||||
|
||||
# JWT payload (use time.time() for correct UTC timestamps)
|
||||
payload = {
|
||||
"client_id": client_id,
|
||||
"scope": scope,
|
||||
"project_id": project_id,
|
||||
"iat": now_ts,
|
||||
"exp": exp_ts,
|
||||
"nbf": now_ts, # Not before
|
||||
"jti": secrets.token_urlsafe(16), # JWT ID (unique)
|
||||
}
|
||||
|
||||
if user_id:
|
||||
payload["sub"] = user_id # Subject (user ID)
|
||||
|
||||
# Encode JWT
|
||||
token = jwt.encode(payload, self.jwt_secret, algorithm=self.jwt_algorithm)
|
||||
|
||||
# Store token metadata
|
||||
token_data = AccessToken(
|
||||
token=token,
|
||||
client_id=client_id,
|
||||
scope=scope,
|
||||
expires_at=expires_at,
|
||||
user_id=user_id,
|
||||
project_id=project_id,
|
||||
issued_at=now,
|
||||
)
|
||||
self.storage.save_access_token(token_data)
|
||||
|
||||
logger.info(f"Generated access token for client {client_id} (scope: {scope})")
|
||||
|
||||
return token
|
||||
|
||||
def validate_access_token(self, token: str) -> dict[str, Any]:
|
||||
"""
|
||||
Validate JWT access token.
|
||||
|
||||
Args:
|
||||
token: JWT access token
|
||||
|
||||
Returns:
|
||||
Decoded payload
|
||||
|
||||
Raises:
|
||||
jwt.ExpiredSignatureError: Token expired
|
||||
jwt.InvalidTokenError: Invalid token
|
||||
"""
|
||||
try:
|
||||
# Decode and verify JWT
|
||||
payload = jwt.decode(
|
||||
token,
|
||||
self.jwt_secret,
|
||||
algorithms=[self.jwt_algorithm],
|
||||
options={
|
||||
"verify_signature": True,
|
||||
"verify_exp": True,
|
||||
"verify_nbf": True,
|
||||
},
|
||||
)
|
||||
|
||||
return payload
|
||||
|
||||
except jwt.ExpiredSignatureError:
|
||||
logger.warning("Expired access token")
|
||||
raise
|
||||
except jwt.InvalidTokenError as e:
|
||||
logger.warning(f"Invalid access token: {e}")
|
||||
raise
|
||||
|
||||
def generate_refresh_token(self, client_id: str, access_token: str) -> str:
|
||||
"""
|
||||
Generate refresh token.
|
||||
|
||||
Args:
|
||||
client_id: OAuth client ID
|
||||
access_token: Associated access token
|
||||
|
||||
Returns:
|
||||
Refresh token (secure random string)
|
||||
"""
|
||||
now = datetime.now(UTC)
|
||||
expires_at = now + timedelta(seconds=self.refresh_token_ttl)
|
||||
|
||||
# Generate secure random token
|
||||
token = f"rt_{secrets.token_urlsafe(32)}"
|
||||
|
||||
# Store refresh token
|
||||
token_data = RefreshToken(
|
||||
token=token,
|
||||
client_id=client_id,
|
||||
access_token=access_token,
|
||||
expires_at=expires_at,
|
||||
revoked=False,
|
||||
rotation_count=0,
|
||||
issued_at=now,
|
||||
)
|
||||
self.storage.save_refresh_token(token_data)
|
||||
|
||||
logger.info(f"Generated refresh token for client {client_id}")
|
||||
|
||||
return token
|
||||
|
||||
def rotate_refresh_token(self, refresh_token: str, client_id: str) -> dict[str, Any]:
|
||||
"""
|
||||
Rotate refresh token (OAuth 2.1 best practice).
|
||||
|
||||
Security:
|
||||
- Old refresh token is immediately revoked
|
||||
- Reuse detection: if old token is used again, revoke all tokens
|
||||
|
||||
Args:
|
||||
refresh_token: Current refresh token
|
||||
client_id: OAuth client ID
|
||||
|
||||
Returns:
|
||||
{
|
||||
"access_token": str,
|
||||
"refresh_token": str,
|
||||
"token_type": "Bearer",
|
||||
"expires_in": int,
|
||||
"scope": str
|
||||
}
|
||||
|
||||
Raises:
|
||||
ValueError: Invalid/expired refresh token
|
||||
SecurityError: Refresh token reuse detected
|
||||
"""
|
||||
# Get refresh token (include revoked for reuse detection)
|
||||
token_data = self.storage.get_refresh_token(refresh_token, include_revoked=True)
|
||||
|
||||
if not token_data:
|
||||
raise ValueError("Invalid or expired refresh token")
|
||||
|
||||
# Check if revoked (reuse detection!)
|
||||
if token_data.revoked:
|
||||
logger.critical(
|
||||
f"Refresh token reuse detected for client {client_id}! "
|
||||
f"Revoking all tokens for this client."
|
||||
)
|
||||
|
||||
# Log security event
|
||||
try:
|
||||
from core.audit_log import LogLevel, get_audit_logger
|
||||
|
||||
audit_logger = get_audit_logger()
|
||||
audit_logger.log_system_event(
|
||||
event=f"SECURITY: Refresh token reuse detected: {client_id}",
|
||||
details={"client_id": client_id, "token": refresh_token[:20] + "..."},
|
||||
level=LogLevel.CRITICAL,
|
||||
)
|
||||
except (ImportError, Exception):
|
||||
# Audit logging not available or failed
|
||||
pass
|
||||
|
||||
# Revoke all tokens for this client (TODO: implement)
|
||||
# For now, just raise error
|
||||
raise SecurityError("Refresh token reuse detected - all tokens revoked")
|
||||
|
||||
# Validate client_id match
|
||||
if token_data.client_id != client_id:
|
||||
raise ValueError("Client ID mismatch")
|
||||
|
||||
# Get scope from old access token (if available)
|
||||
scope = "read" # Default
|
||||
if token_data.access_token:
|
||||
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
|
||||
|
||||
# Generate new tokens
|
||||
new_access_token = self.generate_access_token(
|
||||
client_id=client_id,
|
||||
scope=scope,
|
||||
user_id=None, # TODO: get from old token
|
||||
project_id="*",
|
||||
)
|
||||
|
||||
new_refresh_token = self.generate_refresh_token(
|
||||
client_id=client_id, access_token=new_access_token
|
||||
)
|
||||
|
||||
# Revoke old refresh token
|
||||
self.storage.revoke_refresh_token(refresh_token)
|
||||
|
||||
# Increment rotation count
|
||||
token_data.rotation_count += 1
|
||||
|
||||
logger.info(
|
||||
f"Rotated refresh token for client {client_id} "
|
||||
f"(rotation #{token_data.rotation_count})"
|
||||
)
|
||||
|
||||
# Log audit event
|
||||
try:
|
||||
from core.audit_log import LogLevel, get_audit_logger
|
||||
|
||||
audit_logger = get_audit_logger()
|
||||
audit_logger.log_system_event(
|
||||
event=f"Refresh token rotated for {client_id}",
|
||||
details={"client_id": client_id, "rotation_count": token_data.rotation_count},
|
||||
level=LogLevel.INFO,
|
||||
)
|
||||
except (ImportError, Exception):
|
||||
# Audit logging not available or failed
|
||||
pass
|
||||
|
||||
return {
|
||||
"access_token": new_access_token,
|
||||
"refresh_token": new_refresh_token,
|
||||
"token_type": "Bearer",
|
||||
"expires_in": self.access_token_ttl,
|
||||
"scope": scope,
|
||||
}
|
||||
|
||||
def revoke_token(self, token: str, token_type: str = "refresh"):
|
||||
"""
|
||||
Revoke token.
|
||||
|
||||
Args:
|
||||
token: Token to revoke
|
||||
token_type: "refresh" or "access"
|
||||
"""
|
||||
if token_type == "refresh":
|
||||
self.storage.revoke_refresh_token(token)
|
||||
logger.info("Revoked refresh token")
|
||||
|
||||
# Access tokens cannot be revoked (JWT stateless)
|
||||
# They expire naturally after TTL
|
||||
|
||||
# Singleton
|
||||
_token_manager: TokenManager | None = None
|
||||
|
||||
def get_token_manager() -> TokenManager:
|
||||
"""Get singleton TokenManager instance"""
|
||||
global _token_manager
|
||||
if _token_manager is None:
|
||||
_token_manager = TokenManager()
|
||||
return _token_manager
|
||||
245
core/project_manager.py
Normal file
245
core/project_manager.py
Normal file
@@ -0,0 +1,245 @@
|
||||
"""
|
||||
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() + "_"
|
||||
|
||||
# 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():
|
||||
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.error(
|
||||
f"Failed to create {plugin_type} project '{project_id}': {e}", exc_info=True
|
||||
)
|
||||
|
||||
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
|
||||
414
core/rate_limiter.py
Normal file
414
core/rate_limiter.py
Normal file
@@ -0,0 +1,414 @@
|
||||
"""
|
||||
Rate Limiting & Throttling for MCP Server (Phase 7.3)
|
||||
|
||||
This module implements Token Bucket-based rate limiting to prevent API abuse
|
||||
and ensure fair resource usage across all MCP clients.
|
||||
|
||||
Features:
|
||||
- Multi-level rate limits (per minute, hour, day)
|
||||
- Per-client tracking with token bucket algorithm
|
||||
- Configurable limits per plugin type
|
||||
- Statistics and monitoring capabilities
|
||||
- Integration with audit logging
|
||||
|
||||
Author: Phase 7.3 Implementation
|
||||
Date: 2025-01-11
|
||||
"""
|
||||
|
||||
import logging
|
||||
import os
|
||||
import time
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import UTC, datetime
|
||||
from typing import Any
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@dataclass
|
||||
class RateLimitConfig:
|
||||
"""Configuration for rate limits at different time intervals."""
|
||||
|
||||
per_minute: int = 60
|
||||
per_hour: int = 1000
|
||||
per_day: int = 10000
|
||||
|
||||
@classmethod
|
||||
def from_env(cls, prefix: str = "") -> "RateLimitConfig":
|
||||
"""Create config from environment variables."""
|
||||
env_prefix = f"{prefix}_" if prefix else ""
|
||||
return cls(
|
||||
per_minute=int(os.getenv(f"{env_prefix}RATE_LIMIT_PER_MINUTE", "60")),
|
||||
per_hour=int(os.getenv(f"{env_prefix}RATE_LIMIT_PER_HOUR", "1000")),
|
||||
per_day=int(os.getenv(f"{env_prefix}RATE_LIMIT_PER_DAY", "10000")),
|
||||
)
|
||||
|
||||
@dataclass
|
||||
class TokenBucket:
|
||||
"""
|
||||
Token Bucket implementation for rate limiting.
|
||||
|
||||
The token bucket algorithm allows for burst traffic while maintaining
|
||||
an average rate limit over time.
|
||||
"""
|
||||
|
||||
capacity: int
|
||||
refill_rate: float # tokens per second
|
||||
tokens: float = field(default=0.0)
|
||||
last_refill: float = field(default_factory=time.time)
|
||||
|
||||
def __post_init__(self):
|
||||
"""Initialize bucket with full capacity."""
|
||||
if self.tokens == 0.0:
|
||||
self.tokens = float(self.capacity)
|
||||
|
||||
def refill(self) -> None:
|
||||
"""Refill tokens based on elapsed time."""
|
||||
now = time.time()
|
||||
elapsed = now - self.last_refill
|
||||
|
||||
# Add tokens based on elapsed time
|
||||
self.tokens = min(self.capacity, self.tokens + (elapsed * self.refill_rate))
|
||||
self.last_refill = now
|
||||
|
||||
def consume(self, tokens: int = 1) -> bool:
|
||||
"""
|
||||
Attempt to consume tokens from the bucket.
|
||||
|
||||
Args:
|
||||
tokens: Number of tokens to consume
|
||||
|
||||
Returns:
|
||||
True if tokens were available and consumed, False otherwise
|
||||
"""
|
||||
self.refill()
|
||||
|
||||
if self.tokens >= tokens:
|
||||
self.tokens -= tokens
|
||||
return True
|
||||
return False
|
||||
|
||||
def get_available_tokens(self) -> int:
|
||||
"""Get current number of available tokens."""
|
||||
self.refill()
|
||||
return int(self.tokens)
|
||||
|
||||
def get_wait_time(self, tokens: int = 1) -> float:
|
||||
"""
|
||||
Calculate wait time in seconds until enough tokens are available.
|
||||
|
||||
Args:
|
||||
tokens: Number of tokens needed
|
||||
|
||||
Returns:
|
||||
Wait time in seconds (0 if tokens already available)
|
||||
"""
|
||||
self.refill()
|
||||
|
||||
if self.tokens >= tokens:
|
||||
return 0.0
|
||||
|
||||
tokens_needed = tokens - self.tokens
|
||||
return tokens_needed / self.refill_rate
|
||||
|
||||
@dataclass
|
||||
class ClientRateLimitState:
|
||||
"""Track rate limit state for a single client."""
|
||||
|
||||
client_id: str
|
||||
minute_bucket: TokenBucket
|
||||
hour_bucket: TokenBucket
|
||||
day_bucket: TokenBucket
|
||||
total_requests: int = 0
|
||||
rejected_requests: int = 0
|
||||
last_request_time: float = field(default_factory=time.time)
|
||||
first_request_time: float = field(default_factory=time.time)
|
||||
|
||||
def check_and_consume(self) -> tuple[bool, str, float]:
|
||||
"""
|
||||
Check if request is allowed and consume tokens.
|
||||
|
||||
Returns:
|
||||
Tuple of (allowed, reason, retry_after_seconds)
|
||||
"""
|
||||
# Check each time window (most restrictive first)
|
||||
if not self.minute_bucket.consume():
|
||||
wait_time = self.minute_bucket.get_wait_time()
|
||||
self.rejected_requests += 1
|
||||
return False, "Rate limit exceeded: too many requests per minute", wait_time
|
||||
|
||||
if not self.hour_bucket.consume():
|
||||
wait_time = self.hour_bucket.get_wait_time()
|
||||
# Refund the minute token since we're rejecting
|
||||
self.minute_bucket.tokens = min(
|
||||
self.minute_bucket.capacity, self.minute_bucket.tokens + 1
|
||||
)
|
||||
self.rejected_requests += 1
|
||||
return False, "Rate limit exceeded: too many requests per hour", wait_time
|
||||
|
||||
if not self.day_bucket.consume():
|
||||
wait_time = self.day_bucket.get_wait_time()
|
||||
# Refund tokens since we're rejecting
|
||||
self.minute_bucket.tokens = min(
|
||||
self.minute_bucket.capacity, self.minute_bucket.tokens + 1
|
||||
)
|
||||
self.hour_bucket.tokens = min(self.hour_bucket.capacity, self.hour_bucket.tokens + 1)
|
||||
self.rejected_requests += 1
|
||||
return False, "Rate limit exceeded: daily limit reached", wait_time
|
||||
|
||||
# All checks passed
|
||||
self.total_requests += 1
|
||||
self.last_request_time = time.time()
|
||||
return True, "", 0.0
|
||||
|
||||
def get_stats(self) -> dict[str, Any]:
|
||||
"""Get statistics for this client."""
|
||||
now = time.time()
|
||||
uptime = now - self.first_request_time
|
||||
|
||||
return {
|
||||
"client_id": self.client_id,
|
||||
"total_requests": self.total_requests,
|
||||
"rejected_requests": self.rejected_requests,
|
||||
"success_rate": (
|
||||
(self.total_requests - self.rejected_requests) / self.total_requests
|
||||
if self.total_requests > 0
|
||||
else 1.0
|
||||
),
|
||||
"available_tokens": {
|
||||
"per_minute": self.minute_bucket.get_available_tokens(),
|
||||
"per_hour": self.hour_bucket.get_available_tokens(),
|
||||
"per_day": self.day_bucket.get_available_tokens(),
|
||||
},
|
||||
"limits": {
|
||||
"per_minute": self.minute_bucket.capacity,
|
||||
"per_hour": self.hour_bucket.capacity,
|
||||
"per_day": self.day_bucket.capacity,
|
||||
},
|
||||
"last_request": datetime.fromtimestamp(self.last_request_time, tz=UTC).isoformat(),
|
||||
"uptime_seconds": uptime,
|
||||
}
|
||||
|
||||
class RateLimiter:
|
||||
"""
|
||||
Rate limiter using Token Bucket algorithm.
|
||||
|
||||
Provides multi-level rate limiting (per minute, hour, day) with
|
||||
per-client tracking and configurable limits.
|
||||
"""
|
||||
|
||||
def __init__(self):
|
||||
"""Initialize rate limiter with default configuration."""
|
||||
self.clients: dict[str, ClientRateLimitState] = {}
|
||||
self.global_stats = {"total_requests": 0, "total_rejected": 0, "start_time": time.time()}
|
||||
|
||||
# Load default configuration from environment
|
||||
self.default_config = RateLimitConfig.from_env()
|
||||
|
||||
# Plugin-specific configurations
|
||||
self.plugin_configs: dict[str, RateLimitConfig] = {
|
||||
"wordpress": RateLimitConfig.from_env("WORDPRESS"),
|
||||
"woocommerce": RateLimitConfig.from_env("WOOCOMMERCE"),
|
||||
}
|
||||
|
||||
logger.info(
|
||||
"Rate limiter initialized with default limits: "
|
||||
f"{self.default_config.per_minute}/min, "
|
||||
f"{self.default_config.per_hour}/hour, "
|
||||
f"{self.default_config.per_day}/day"
|
||||
)
|
||||
|
||||
def _get_or_create_client_state(
|
||||
self, client_id: str, plugin_type: str | None = None
|
||||
) -> ClientRateLimitState:
|
||||
"""Get or create rate limit state for a client."""
|
||||
if client_id not in self.clients:
|
||||
# Determine which config to use
|
||||
config = self.plugin_configs.get(plugin_type, self.default_config)
|
||||
|
||||
# Create token buckets for each time window
|
||||
minute_bucket = TokenBucket(
|
||||
capacity=config.per_minute,
|
||||
refill_rate=config.per_minute / 60.0, # tokens per second
|
||||
)
|
||||
hour_bucket = TokenBucket(
|
||||
capacity=config.per_hour, refill_rate=config.per_hour / 3600.0
|
||||
)
|
||||
day_bucket = TokenBucket(capacity=config.per_day, refill_rate=config.per_day / 86400.0)
|
||||
|
||||
self.clients[client_id] = ClientRateLimitState(
|
||||
client_id=client_id,
|
||||
minute_bucket=minute_bucket,
|
||||
hour_bucket=hour_bucket,
|
||||
day_bucket=day_bucket,
|
||||
)
|
||||
|
||||
logger.debug(f"Created rate limit state for client: {client_id}")
|
||||
|
||||
return self.clients[client_id]
|
||||
|
||||
def check_rate_limit(
|
||||
self, client_id: str, tool_name: str | None = None, plugin_type: str | None = None
|
||||
) -> tuple[bool, str, float]:
|
||||
"""
|
||||
Check if request should be allowed based on rate limits.
|
||||
|
||||
Args:
|
||||
client_id: Identifier for the client (e.g., auth token hash)
|
||||
tool_name: Name of the tool being called (for logging)
|
||||
plugin_type: Type of plugin (wordpress, woocommerce, etc.)
|
||||
|
||||
Returns:
|
||||
Tuple of (allowed, message, retry_after_seconds)
|
||||
"""
|
||||
# Get or create client state
|
||||
client_state = self._get_or_create_client_state(client_id, plugin_type)
|
||||
|
||||
# Update global stats
|
||||
self.global_stats["total_requests"] += 1
|
||||
|
||||
# Check and consume tokens
|
||||
allowed, message, retry_after = client_state.check_and_consume()
|
||||
|
||||
if not allowed:
|
||||
# Track rejection
|
||||
client_state.rejected_requests += 1
|
||||
self.global_stats["total_rejected"] += 1
|
||||
|
||||
logger.warning(
|
||||
f"Rate limit exceeded for client {client_id[:8]}... "
|
||||
f"(tool: {tool_name}, reason: {message}, "
|
||||
f"retry_after: {retry_after:.1f}s)"
|
||||
)
|
||||
else:
|
||||
logger.debug(
|
||||
f"Rate limit check passed for client {client_id[:8]}... " f"(tool: {tool_name})"
|
||||
)
|
||||
|
||||
return allowed, message, retry_after
|
||||
|
||||
def get_client_stats(self, client_id: str) -> dict[str, Any] | None:
|
||||
"""
|
||||
Get statistics for a specific client.
|
||||
|
||||
Args:
|
||||
client_id: Client identifier
|
||||
|
||||
Returns:
|
||||
Client statistics or None if client not found
|
||||
"""
|
||||
if client_id not in self.clients:
|
||||
return None
|
||||
|
||||
return self.clients[client_id].get_stats()
|
||||
|
||||
def get_all_stats(self) -> dict[str, Any]:
|
||||
"""Get global rate limiter statistics."""
|
||||
now = time.time()
|
||||
uptime = now - self.global_stats["start_time"]
|
||||
|
||||
# Calculate per-client stats
|
||||
client_stats = []
|
||||
for _client_id, client_state in self.clients.items():
|
||||
client_stats.append(client_state.get_stats())
|
||||
|
||||
return {
|
||||
"global": {
|
||||
"total_requests": self.global_stats["total_requests"],
|
||||
"total_rejected": self.global_stats["total_rejected"],
|
||||
"rejection_rate": (
|
||||
self.global_stats["total_rejected"] / self.global_stats["total_requests"]
|
||||
if self.global_stats["total_requests"] > 0
|
||||
else 0.0
|
||||
),
|
||||
"active_clients": len(self.clients),
|
||||
"uptime_seconds": uptime,
|
||||
"start_time": datetime.fromtimestamp(
|
||||
self.global_stats["start_time"], tz=UTC
|
||||
).isoformat(),
|
||||
},
|
||||
"default_limits": {
|
||||
"per_minute": self.default_config.per_minute,
|
||||
"per_hour": self.default_config.per_hour,
|
||||
"per_day": self.default_config.per_day,
|
||||
},
|
||||
"plugin_limits": {
|
||||
plugin: {
|
||||
"per_minute": config.per_minute,
|
||||
"per_hour": config.per_hour,
|
||||
"per_day": config.per_day,
|
||||
}
|
||||
for plugin, config in self.plugin_configs.items()
|
||||
},
|
||||
"clients": client_stats,
|
||||
}
|
||||
|
||||
def reset_client(self, client_id: str) -> bool:
|
||||
"""
|
||||
Reset rate limit state for a specific client.
|
||||
|
||||
Args:
|
||||
client_id: Client identifier
|
||||
|
||||
Returns:
|
||||
True if client was reset, False if client not found
|
||||
"""
|
||||
if client_id in self.clients:
|
||||
del self.clients[client_id]
|
||||
logger.info(f"Reset rate limit state for client: {client_id}")
|
||||
return True
|
||||
return False
|
||||
|
||||
def reset_all(self) -> int:
|
||||
"""
|
||||
Reset all client rate limit states.
|
||||
|
||||
Returns:
|
||||
Number of clients reset
|
||||
"""
|
||||
count = len(self.clients)
|
||||
self.clients.clear()
|
||||
self.global_stats = {"total_requests": 0, "total_rejected": 0, "start_time": time.time()}
|
||||
logger.info(f"Reset rate limit state for {count} clients")
|
||||
return count
|
||||
|
||||
def configure_limits(
|
||||
self,
|
||||
plugin_type: str,
|
||||
per_minute: int | None = None,
|
||||
per_hour: int | None = None,
|
||||
per_day: int | None = None,
|
||||
) -> None:
|
||||
"""
|
||||
Configure rate limits for a specific plugin type.
|
||||
|
||||
Args:
|
||||
plugin_type: Plugin type identifier
|
||||
per_minute: Requests per minute limit
|
||||
per_hour: Requests per hour limit
|
||||
per_day: Requests per day limit
|
||||
"""
|
||||
if plugin_type not in self.plugin_configs:
|
||||
self.plugin_configs[plugin_type] = RateLimitConfig()
|
||||
|
||||
config = self.plugin_configs[plugin_type]
|
||||
if per_minute is not None:
|
||||
config.per_minute = per_minute
|
||||
if per_hour is not None:
|
||||
config.per_hour = per_hour
|
||||
if per_day is not None:
|
||||
config.per_day = per_day
|
||||
|
||||
logger.info(
|
||||
f"Updated rate limits for {plugin_type}: "
|
||||
f"{config.per_minute}/min, {config.per_hour}/hour, {config.per_day}/day"
|
||||
)
|
||||
|
||||
# Singleton instance
|
||||
_rate_limiter: RateLimiter | None = None
|
||||
|
||||
def get_rate_limiter() -> RateLimiter:
|
||||
"""Get or create the global rate limiter instance."""
|
||||
global _rate_limiter
|
||||
if _rate_limiter is None:
|
||||
_rate_limiter = RateLimiter()
|
||||
return _rate_limiter
|
||||
539
core/site_manager.py
Normal file
539
core/site_manager.py
Normal file
@@ -0,0 +1,539 @@
|
||||
"""
|
||||
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
|
||||
"""
|
||||
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
from typing import Any
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, Field, ValidationInfo, field_validator
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
class SiteConfig(BaseModel):
|
||||
"""
|
||||
Type-safe site configuration.
|
||||
|
||||
Represents configuration for a single site with validation.
|
||||
|
||||
Attributes:
|
||||
site_id: Unique site identifier (e.g., 'site1')
|
||||
plugin_type: Plugin type (e.g., 'wordpress')
|
||||
alias: Optional friendly name
|
||||
config: Site-specific configuration (URL, credentials, etc.)
|
||||
|
||||
Examples:
|
||||
>>> config = SiteConfig(
|
||||
... site_id="site1",
|
||||
... plugin_type="wordpress",
|
||||
... url="https://example.com",
|
||||
... username="admin",
|
||||
... app_password="xxxx"
|
||||
... )
|
||||
"""
|
||||
|
||||
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")
|
||||
|
||||
# Common config fields (plugins may require additional fields)
|
||||
url: str | None = Field(None, description="Site URL")
|
||||
username: str | None = Field(None, description="Username for authentication")
|
||||
app_password: str | None = Field(None, description="Application password")
|
||||
container: str | None = Field(None, description="Docker container name (for WP-CLI)")
|
||||
|
||||
# Allow additional fields for plugin-specific configuration
|
||||
model_config = ConfigDict(extra="allow")
|
||||
|
||||
@field_validator("alias", mode="before")
|
||||
@classmethod
|
||||
def default_alias(cls, v: str | None, info: ValidationInfo) -> str | None:
|
||||
"""Set alias to site_id if not provided."""
|
||||
return v if v is not None else info.data.get("site_id")
|
||||
|
||||
def get_full_id(self) -> str:
|
||||
"""
|
||||
Get full site identifier.
|
||||
|
||||
Returns:
|
||||
Full ID in format: plugin_type_site_id
|
||||
|
||||
Examples:
|
||||
>>> config.get_full_id()
|
||||
'wordpress_site1'
|
||||
"""
|
||||
return f"{self.plugin_type}_{self.site_id}"
|
||||
|
||||
def get_display_name(self) -> str:
|
||||
"""
|
||||
Get display name for the site.
|
||||
|
||||
Returns:
|
||||
Alias if available, otherwise site_id
|
||||
|
||||
Examples:
|
||||
>>> config.get_display_name()
|
||||
'myblog' # or 'site1' if no alias
|
||||
"""
|
||||
return self.alias or self.site_id
|
||||
|
||||
def to_dict(self) -> dict[str, Any]:
|
||||
"""
|
||||
Convert to dictionary for plugin consumption.
|
||||
|
||||
Returns:
|
||||
Dictionary with all configuration
|
||||
|
||||
Examples:
|
||||
>>> config_dict = config.to_dict()
|
||||
>>> plugin = WordPressPlugin(config_dict)
|
||||
"""
|
||||
return self.model_dump()
|
||||
|
||||
class SiteManager:
|
||||
"""
|
||||
Manage site configurations with type safety.
|
||||
|
||||
Discovers, validates, and provides access to site configurations.
|
||||
|
||||
Attributes:
|
||||
sites: Dictionary mapping plugin_type to site configurations
|
||||
aliases: Dictionary mapping aliases to (plugin_type, site_id)
|
||||
logger: Logger instance
|
||||
|
||||
Examples:
|
||||
>>> manager = SiteManager()
|
||||
>>> manager.discover_sites(['wordpress', 'gitea'])
|
||||
>>> config = manager.get_site_config('wordpress', 'myblog')
|
||||
>>> sites = manager.list_sites('wordpress')
|
||||
"""
|
||||
|
||||
def __init__(self):
|
||||
"""Initialize site manager with empty registries."""
|
||||
# Nested dict: plugin_type -> site_id/alias -> SiteConfig
|
||||
self.sites: dict[str, dict[str, SiteConfig]] = {}
|
||||
|
||||
# Map aliases to full_id for quick lookup
|
||||
self.aliases: dict[str, str] = {} # alias -> full_id
|
||||
|
||||
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() + "_"
|
||||
|
||||
# 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():
|
||||
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.
|
||||
|
||||
Args:
|
||||
config: Site configuration to register
|
||||
|
||||
Examples:
|
||||
>>> config = SiteConfig(site_id="site1", plugin_type="wordpress", ...)
|
||||
>>> manager.register_site(config)
|
||||
"""
|
||||
plugin_type = config.plugin_type
|
||||
|
||||
# Initialize plugin_type dict if needed
|
||||
if plugin_type not in self.sites:
|
||||
self.sites[plugin_type] = {}
|
||||
|
||||
# Register by site_id
|
||||
self.sites[plugin_type][config.site_id] = config
|
||||
|
||||
# Register by alias if different from site_id
|
||||
if config.alias and config.alias != config.site_id:
|
||||
self.sites[plugin_type][config.alias] = config
|
||||
# Also register in global aliases map
|
||||
self.aliases[config.alias] = config.get_full_id()
|
||||
|
||||
# Register full_id
|
||||
full_id = config.get_full_id()
|
||||
self.aliases[full_id] = full_id
|
||||
|
||||
# Register site_id
|
||||
self.aliases[config.site_id] = full_id
|
||||
|
||||
self.logger.info(
|
||||
f"Registered site: {full_id} " f"(alias: {config.alias or config.site_id})"
|
||||
)
|
||||
|
||||
def get_site_config(self, plugin_type: str, site: str) -> SiteConfig:
|
||||
"""
|
||||
Get site configuration by ID or alias.
|
||||
|
||||
Args:
|
||||
plugin_type: Plugin type (e.g., 'wordpress')
|
||||
site: Site ID, alias, or full_id
|
||||
|
||||
Returns:
|
||||
Site configuration
|
||||
|
||||
Raises:
|
||||
ValueError: If site not found
|
||||
|
||||
Examples:
|
||||
>>> config = manager.get_site_config('wordpress', 'myblog')
|
||||
>>> config = manager.get_site_config('wordpress', 'site1')
|
||||
"""
|
||||
if plugin_type not in self.sites:
|
||||
# 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."
|
||||
)
|
||||
|
||||
# Try direct lookup
|
||||
config = self.sites[plugin_type].get(site)
|
||||
if config:
|
||||
return config
|
||||
|
||||
# SECURITY: Don't reveal available sites in multi-tenant environment
|
||||
# Only log available sites count for debugging (not in error message)
|
||||
available_count = len(self.sites[plugin_type])
|
||||
self.logger.debug(
|
||||
f"Site '{site}' not found for {plugin_type}. "
|
||||
f"Total configured sites: {available_count}"
|
||||
)
|
||||
raise ValueError(
|
||||
f"Site '{site}' not configured for {plugin_type}. "
|
||||
f"Please verify the site alias/ID and check environment variables."
|
||||
)
|
||||
|
||||
def list_sites(self, plugin_type: str) -> list[str]:
|
||||
"""
|
||||
List available site IDs and aliases for a plugin type.
|
||||
|
||||
Args:
|
||||
plugin_type: Plugin type
|
||||
|
||||
Returns:
|
||||
List of valid site identifiers (IDs and aliases)
|
||||
|
||||
Examples:
|
||||
>>> sites = manager.list_sites('wordpress')
|
||||
>>> print(sites) # ['site1', 'site2', 'myblog']
|
||||
"""
|
||||
if plugin_type not in self.sites:
|
||||
return []
|
||||
|
||||
# Get unique site identifiers (both IDs and aliases)
|
||||
identifiers = set()
|
||||
for config in self.sites[plugin_type].values():
|
||||
identifiers.add(config.site_id)
|
||||
if config.alias and config.alias != config.site_id:
|
||||
identifiers.add(config.alias)
|
||||
|
||||
# Remove duplicates and sort
|
||||
return sorted(set(identifiers))
|
||||
|
||||
def get_sites_by_type(self, plugin_type: str) -> list[SiteConfig]:
|
||||
"""
|
||||
Get all site configurations for a plugin type.
|
||||
|
||||
Args:
|
||||
plugin_type: Plugin type
|
||||
|
||||
Returns:
|
||||
List of site configurations
|
||||
|
||||
Examples:
|
||||
>>> configs = manager.get_sites_by_type('wordpress')
|
||||
>>> for config in configs:
|
||||
... print(config.get_display_name())
|
||||
"""
|
||||
if plugin_type not in self.sites:
|
||||
return []
|
||||
|
||||
# Return unique configs (since aliases may point to same config)
|
||||
seen_site_ids = set()
|
||||
unique_configs = []
|
||||
|
||||
for config in self.sites[plugin_type].values():
|
||||
if config.site_id not in seen_site_ids:
|
||||
seen_site_ids.add(config.site_id)
|
||||
unique_configs.append(config)
|
||||
|
||||
return unique_configs
|
||||
|
||||
def list_all_sites(self) -> list[dict[str, Any]]:
|
||||
"""
|
||||
List all discovered sites across all plugin types.
|
||||
|
||||
Returns:
|
||||
List of site info dictionaries
|
||||
|
||||
Examples:
|
||||
>>> all_sites = manager.list_all_sites()
|
||||
>>> for site in all_sites:
|
||||
... print(f"{site['full_id']}: {site['alias']}")
|
||||
"""
|
||||
all_sites = []
|
||||
for plugin_type in self.sites:
|
||||
for config in self.get_sites_by_type(plugin_type):
|
||||
all_sites.append(
|
||||
{
|
||||
"plugin_type": config.plugin_type,
|
||||
"site_id": config.site_id,
|
||||
"alias": config.alias,
|
||||
"full_id": config.get_full_id(),
|
||||
}
|
||||
)
|
||||
|
||||
return all_sites
|
||||
|
||||
def get_count(self) -> int:
|
||||
"""
|
||||
Get total number of registered sites.
|
||||
|
||||
Returns:
|
||||
Total site count
|
||||
|
||||
Examples:
|
||||
>>> count = manager.get_count()
|
||||
"""
|
||||
total = 0
|
||||
for plugin_type in self.sites:
|
||||
total += len(self.get_sites_by_type(plugin_type))
|
||||
return total
|
||||
|
||||
def get_count_by_type(self) -> dict[str, int]:
|
||||
"""
|
||||
Get site counts grouped by plugin type.
|
||||
|
||||
Returns:
|
||||
Dictionary mapping plugin type to site count
|
||||
|
||||
Examples:
|
||||
>>> counts = manager.get_count_by_type()
|
||||
>>> print(counts) # {'wordpress': 4, 'gitea': 2}
|
||||
"""
|
||||
return {plugin_type: len(self.get_sites_by_type(plugin_type)) for plugin_type in self.sites}
|
||||
|
||||
def get_effective_path_suffix(self, full_id: str) -> str:
|
||||
"""
|
||||
Get the effective path suffix for a site's endpoint.
|
||||
|
||||
Uses alias if available, otherwise returns full_id.
|
||||
|
||||
Args:
|
||||
full_id: The full site ID (e.g., 'wordpress_site1')
|
||||
|
||||
Returns:
|
||||
Path suffix to use in endpoint URL (alias or full_id)
|
||||
|
||||
Examples:
|
||||
>>> 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
|
||||
|
||||
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
|
||||
|
||||
def get_alias_conflicts(self) -> dict[str, list[str]]:
|
||||
"""
|
||||
Get all alias conflicts.
|
||||
|
||||
Note: SiteManager doesn't track conflicts like SiteRegistry.
|
||||
This is a stub for compatibility.
|
||||
|
||||
Returns:
|
||||
Empty dict (no conflict tracking in SiteManager)
|
||||
"""
|
||||
return {}
|
||||
|
||||
def __repr__(self) -> str:
|
||||
"""String representation of site manager."""
|
||||
counts = self.get_count_by_type()
|
||||
counts_str = ", ".join(f"{k}: {v}" for k, v in counts.items())
|
||||
return f"SiteManager(total={self.get_count()}, {counts_str})"
|
||||
|
||||
# Singleton instance
|
||||
_site_manager: SiteManager | None = None
|
||||
|
||||
def get_site_manager() -> SiteManager:
|
||||
"""
|
||||
Get the singleton site manager instance.
|
||||
|
||||
Returns:
|
||||
Global SiteManager instance
|
||||
|
||||
Examples:
|
||||
>>> manager = get_site_manager()
|
||||
>>> manager.discover_sites(['wordpress'])
|
||||
"""
|
||||
global _site_manager
|
||||
if _site_manager is None:
|
||||
_site_manager = SiteManager()
|
||||
return _site_manager
|
||||
371
core/site_registry.py
Normal file
371
core/site_registry.py
Normal file
@@ -0,0 +1,371 @@
|
||||
"""
|
||||
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.warning("=" * 50)
|
||||
self.logger.warning("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.warning(
|
||||
f" Alias '{alias}': {winner} (winner), {losers} (using full_id)"
|
||||
)
|
||||
self.logger.warning("=" * 50)
|
||||
|
||||
# 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.warning(
|
||||
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
|
||||
511
core/tool_generator.py
Normal file
511
core/tool_generator.py
Normal file
@@ -0,0 +1,511 @@
|
||||
"""
|
||||
Tool Generator - Direct tool generation without per-site wrapper
|
||||
|
||||
Generates MCP tools directly from plugin specifications.
|
||||
Part of Option B clean architecture refactoring.
|
||||
"""
|
||||
|
||||
import copy
|
||||
import logging
|
||||
from collections.abc import Callable
|
||||
from typing import Any
|
||||
|
||||
from core.tool_registry import ToolDefinition
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Plugin type fallback mapping - used when a plugin has no sites configured
|
||||
# WooCommerce can fallback to WordPress sites (same URL, credentials)
|
||||
# NOTE: Using fallback is NOT recommended in production. Always configure
|
||||
# explicit WOOCOMMERCE_SITE*_... environment variables for stability.
|
||||
PLUGIN_SITE_FALLBACK = {
|
||||
"woocommerce": "wordpress", # WooCommerce can use WordPress site configs
|
||||
# Add more fallbacks as needed
|
||||
}
|
||||
|
||||
def get_site_plugin_type_with_fallback(plugin_type: str, site_manager) -> str:
|
||||
"""
|
||||
Get the site configuration plugin type for a given plugin.
|
||||
|
||||
Checks if the plugin has its own sites configured first.
|
||||
If not, falls back to a related plugin type (e.g., woocommerce -> wordpress).
|
||||
|
||||
WARNING: Using fallback is not recommended for production use.
|
||||
Always configure explicit environment variables for each plugin type
|
||||
to avoid issues with alias mismatches and credential problems.
|
||||
|
||||
Args:
|
||||
plugin_type: The plugin type
|
||||
site_manager: SiteManager instance to check for configured sites
|
||||
|
||||
Returns:
|
||||
The plugin type to use for site configuration lookup
|
||||
"""
|
||||
# First check if the plugin has its own sites
|
||||
if plugin_type in site_manager.sites and site_manager.sites[plugin_type]:
|
||||
return plugin_type
|
||||
|
||||
# Fallback to related plugin type if available
|
||||
fallback_type = PLUGIN_SITE_FALLBACK.get(plugin_type)
|
||||
if fallback_type and fallback_type in site_manager.sites:
|
||||
# Log a warning - fallback usage is not recommended
|
||||
logger.warning(
|
||||
f"FALLBACK: Using {fallback_type} site config for {plugin_type}. "
|
||||
f"This is NOT recommended for production. "
|
||||
f"Please configure explicit {plugin_type.upper()}_SITE*_... environment variables "
|
||||
f"to avoid potential issues with alias mismatches and credentials."
|
||||
)
|
||||
return fallback_type
|
||||
|
||||
# Return original type (may have no sites)
|
||||
return plugin_type
|
||||
|
||||
class ToolGenerator:
|
||||
"""
|
||||
Generate tools directly from plugin classes.
|
||||
|
||||
No longer wraps per-site tools - generates tools directly
|
||||
from plugin specifications with site parameter routing.
|
||||
|
||||
Attributes:
|
||||
site_manager: Manages site configurations
|
||||
logger: Logger instance
|
||||
|
||||
Examples:
|
||||
>>> generator = ToolGenerator(site_manager)
|
||||
>>> tools = generator.generate_tools(WordPressPlugin, "wordpress")
|
||||
>>> print(f"Generated {len(tools)} tools")
|
||||
"""
|
||||
|
||||
def __init__(self, site_manager):
|
||||
"""
|
||||
Initialize tool generator.
|
||||
|
||||
Args:
|
||||
site_manager: SiteManager instance for site configuration
|
||||
"""
|
||||
self.site_manager = site_manager
|
||||
self.logger = logging.getLogger("ToolGenerator")
|
||||
|
||||
def generate_tools(self, plugin_class: type, plugin_type: str) -> list[ToolDefinition]:
|
||||
"""
|
||||
Generate tools directly from plugin class.
|
||||
|
||||
Args:
|
||||
plugin_class: Plugin class (not instance)
|
||||
plugin_type: Plugin type name (e.g., 'wordpress')
|
||||
|
||||
Returns:
|
||||
List of tool definitions
|
||||
|
||||
Raises:
|
||||
ValueError: If plugin_class doesn't have get_tool_specifications
|
||||
|
||||
Examples:
|
||||
>>> tools = generator.generate_tools(WordPressPlugin, "wordpress")
|
||||
"""
|
||||
# Verify plugin class has required method
|
||||
if not hasattr(plugin_class, "get_tool_specifications"):
|
||||
raise ValueError(
|
||||
f"Plugin class {plugin_class.__name__} must implement "
|
||||
"get_tool_specifications() static method"
|
||||
)
|
||||
|
||||
# Get tool specifications from plugin
|
||||
try:
|
||||
tool_specs = plugin_class.get_tool_specifications()
|
||||
except Exception as e:
|
||||
self.logger.error(
|
||||
f"Failed to get tool specifications from {plugin_class.__name__}: {e}",
|
||||
exc_info=True,
|
||||
)
|
||||
return []
|
||||
|
||||
self.logger.info(
|
||||
f"Generating tools for {plugin_type} " f"from {len(tool_specs)} specifications"
|
||||
)
|
||||
|
||||
tools = []
|
||||
for spec in tool_specs:
|
||||
try:
|
||||
tool = self._create_tool_from_spec(plugin_class, plugin_type, spec)
|
||||
if tool:
|
||||
tools.append(tool)
|
||||
except Exception as e:
|
||||
self.logger.error(
|
||||
f"Failed to create tool from spec {spec.get('name', 'unknown')}: {e}",
|
||||
exc_info=True,
|
||||
)
|
||||
|
||||
self.logger.info(f"Generated {len(tools)} tools for {plugin_type}")
|
||||
return tools
|
||||
|
||||
def _create_tool_from_spec(
|
||||
self, plugin_class: type, plugin_type: str, spec: dict[str, Any]
|
||||
) -> ToolDefinition:
|
||||
"""
|
||||
Create a tool definition from a specification.
|
||||
|
||||
Args:
|
||||
plugin_class: Plugin class
|
||||
plugin_type: Plugin type name
|
||||
spec: Tool specification dictionary
|
||||
|
||||
Returns:
|
||||
Tool definition
|
||||
|
||||
Raises:
|
||||
ValueError: If spec is invalid
|
||||
|
||||
Tool spec format:
|
||||
{
|
||||
'name': 'list_posts',
|
||||
'method_name': 'list_posts',
|
||||
'description': 'List WordPress posts',
|
||||
'schema': {...}, # Input schema (without site param)
|
||||
'scope': 'read' # Optional, defaults to 'read'
|
||||
}
|
||||
"""
|
||||
# Validate required fields
|
||||
required_fields = ["name", "method_name", "description", "schema"]
|
||||
for field in required_fields:
|
||||
if field not in spec:
|
||||
raise ValueError(f"Tool spec missing required field: {field}")
|
||||
|
||||
# Extract spec fields
|
||||
action_name = spec["name"]
|
||||
method_name = spec["method_name"]
|
||||
description = spec["description"]
|
||||
schema = spec["schema"]
|
||||
scope = spec.get("scope", "read")
|
||||
|
||||
# Create full tool name
|
||||
tool_name = f"{plugin_type}_{action_name}"
|
||||
|
||||
# Add site parameter to schema
|
||||
enhanced_schema = self._add_site_parameter(schema, plugin_type)
|
||||
|
||||
# Add [UNIFIED] prefix to description if not present
|
||||
if not description.startswith("[UNIFIED]"):
|
||||
description = f"[UNIFIED] {description}"
|
||||
|
||||
# Create handler with site routing
|
||||
handler = self._create_handler(plugin_class, plugin_type, method_name)
|
||||
|
||||
return ToolDefinition(
|
||||
name=tool_name,
|
||||
description=description,
|
||||
input_schema=enhanced_schema,
|
||||
handler=handler,
|
||||
required_scope=scope,
|
||||
plugin_type=plugin_type,
|
||||
)
|
||||
|
||||
def _add_site_parameter(
|
||||
self, original_schema: dict[str, Any], plugin_type: str
|
||||
) -> dict[str, Any]:
|
||||
"""
|
||||
Add 'site' parameter to input schema.
|
||||
|
||||
Args:
|
||||
original_schema: Original input schema
|
||||
plugin_type: Plugin type for site options
|
||||
|
||||
Returns:
|
||||
Schema with site parameter added
|
||||
|
||||
Examples:
|
||||
>>> schema = {'type': 'object', 'properties': {'post_id': {...}}}
|
||||
>>> enhanced = generator._add_site_parameter(schema, 'wordpress')
|
||||
>>> assert 'site' in enhanced['properties']
|
||||
"""
|
||||
# Deep copy to avoid modifying original
|
||||
schema = copy.deepcopy(original_schema)
|
||||
|
||||
# Ensure schema has required structure
|
||||
if "properties" not in schema:
|
||||
schema["properties"] = {}
|
||||
if "required" not in schema:
|
||||
schema["required"] = []
|
||||
|
||||
# Get available sites for this plugin type
|
||||
# Use fallback if no sites configured (e.g., woocommerce -> wordpress)
|
||||
site_plugin_type = get_site_plugin_type_with_fallback(plugin_type, self.site_manager)
|
||||
site_options = self.site_manager.list_sites(site_plugin_type)
|
||||
|
||||
if not site_options:
|
||||
# No sites configured - add site param anyway for future use
|
||||
site_options = []
|
||||
|
||||
# Phase K.2.6: For single-site MCPs, make site parameter optional
|
||||
is_single_site = len(site_options) == 1
|
||||
|
||||
if is_single_site:
|
||||
# Single site - parameter is optional with helpful description
|
||||
single_site = site_options[0]
|
||||
schema["properties"] = {
|
||||
"site": {
|
||||
"type": "string",
|
||||
"description": (
|
||||
f"🔗 SINGLE SITE: Connected to '{single_site}'. "
|
||||
f"This parameter is OPTIONAL - you can omit it or use any value."
|
||||
),
|
||||
"default": single_site,
|
||||
},
|
||||
**schema["properties"],
|
||||
}
|
||||
# Don't add 'site' to required for single-site MCPs
|
||||
else:
|
||||
# Multiple sites or no sites - parameter is required
|
||||
schema["properties"] = {
|
||||
"site": {
|
||||
"type": "string",
|
||||
"description": (
|
||||
f"Site ID or alias. "
|
||||
f"Available options: {', '.join(site_options) if site_options else 'None configured'}. "
|
||||
f"Use list_sites() to see all configured sites."
|
||||
),
|
||||
**({"enum": site_options} if site_options else {}),
|
||||
},
|
||||
**schema["properties"],
|
||||
}
|
||||
# Make 'site' required for multi-site MCPs
|
||||
if "site" not in schema["required"]:
|
||||
schema["required"].insert(0, "site")
|
||||
|
||||
return schema
|
||||
|
||||
def _create_handler(self, plugin_class: type, plugin_type: str, method_name: str) -> Callable:
|
||||
"""
|
||||
Create async handler with site routing.
|
||||
|
||||
The handler:
|
||||
1. Extracts site from parameters
|
||||
2. Gets site configuration
|
||||
3. Creates plugin instance for this request
|
||||
4. Calls the specified method
|
||||
5. Returns result
|
||||
|
||||
Args:
|
||||
plugin_class: Plugin class to instantiate
|
||||
plugin_type: Plugin type name
|
||||
method_name: Method name to call on plugin instance
|
||||
|
||||
Returns:
|
||||
Async handler function
|
||||
|
||||
Examples:
|
||||
>>> handler = generator._create_handler(
|
||||
... WordPressPlugin, "wordpress", "list_posts"
|
||||
... )
|
||||
>>> result = await handler(site="site1", per_page=10)
|
||||
"""
|
||||
|
||||
async def unified_handler(site: str = None, **kwargs):
|
||||
"""
|
||||
Unified handler that routes to the correct site plugin.
|
||||
|
||||
Args:
|
||||
site: Site ID or alias (optional for single-site MCPs)
|
||||
**kwargs: Other parameters for the tool
|
||||
|
||||
Returns:
|
||||
Result from plugin method
|
||||
|
||||
Raises:
|
||||
ValueError: If site not found or access denied
|
||||
"""
|
||||
try:
|
||||
# Get site configuration
|
||||
# Use fallback if no sites configured (e.g., woocommerce -> wordpress)
|
||||
site_plugin_type = get_site_plugin_type_with_fallback(
|
||||
plugin_type, self.site_manager
|
||||
)
|
||||
|
||||
# Phase K.2.6: Auto-detect site for single-site MCPs
|
||||
if not site:
|
||||
available_sites = self.site_manager.list_sites(site_plugin_type)
|
||||
if len(available_sites) == 1:
|
||||
site = available_sites[0]
|
||||
elif len(available_sites) == 0:
|
||||
return "Error: No sites configured. Please check environment variables."
|
||||
else:
|
||||
return (
|
||||
f"Error: Multiple sites available ({', '.join(available_sites)}). "
|
||||
f"Please specify the 'site' parameter."
|
||||
)
|
||||
|
||||
site_config = self.site_manager.get_site_config(site_plugin_type, site)
|
||||
|
||||
# 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 the current project - always use site_id for consistency
|
||||
# Use site_plugin_type for consistent project naming
|
||||
current_project = f"{site_plugin_type}_{site_config.site_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_config = self.site_manager.get_site_config(
|
||||
allowed_plugin_type, allowed_site_identifier
|
||||
)
|
||||
# Normalize to plugin_type_site_id format
|
||||
allowed_project_normalized = (
|
||||
f"{allowed_plugin_type}_{allowed_site_config.site_id}"
|
||||
)
|
||||
except ValueError:
|
||||
# Site not found, keep original for error message
|
||||
pass
|
||||
|
||||
if allowed_project_normalized != current_project:
|
||||
logger.warning(
|
||||
f"Access denied: API key for project '{allowed_project}' "
|
||||
f"attempted to access '{current_project}'"
|
||||
)
|
||||
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 '{current_project}'."
|
||||
)
|
||||
|
||||
# Create plugin instance for this request
|
||||
# Convert SiteConfig Pydantic model to dict
|
||||
# Use model_dump() for Pydantic V2, fallback to dict() for V1
|
||||
if hasattr(site_config, "model_dump"):
|
||||
config_dict = site_config.model_dump()
|
||||
elif hasattr(site_config, "dict"):
|
||||
config_dict = site_config.dict()
|
||||
else:
|
||||
config_dict = site_config
|
||||
|
||||
plugin_instance = plugin_class(config_dict)
|
||||
|
||||
# Get the method from plugin instance
|
||||
if not hasattr(plugin_instance, method_name):
|
||||
return (
|
||||
f"Error: Method '{method_name}' not found in "
|
||||
f"{plugin_class.__name__}. This is a plugin implementation error."
|
||||
)
|
||||
|
||||
method = getattr(plugin_instance, method_name)
|
||||
|
||||
# Phase K.2.1: Enhanced parameter processing
|
||||
# 1. Parse JSON strings to objects (for billing, shipping, line_items, etc.)
|
||||
# 2. Filter out None and empty values
|
||||
import json as json_module
|
||||
|
||||
def process_value(value):
|
||||
"""Process parameter value - parse JSON strings if needed."""
|
||||
if value is None:
|
||||
return None
|
||||
if isinstance(value, str):
|
||||
# Skip empty strings
|
||||
if value.strip() == "":
|
||||
return None
|
||||
# Try to parse JSON strings
|
||||
stripped = value.strip()
|
||||
if (stripped.startswith("{") and stripped.endswith("}")) or (
|
||||
stripped.startswith("[") and stripped.endswith("]")
|
||||
):
|
||||
try:
|
||||
return json_module.loads(value)
|
||||
except json_module.JSONDecodeError:
|
||||
# Not valid JSON, return original string
|
||||
return value
|
||||
return value
|
||||
|
||||
filtered_kwargs = {}
|
||||
for key, value in kwargs.items():
|
||||
processed = process_value(value)
|
||||
if processed is not None:
|
||||
filtered_kwargs[key] = processed
|
||||
|
||||
# Call the method
|
||||
result = await method(**filtered_kwargs)
|
||||
return result
|
||||
|
||||
except ValueError as e:
|
||||
# Site not found or validation error
|
||||
logger.warning(f"Validation error in {plugin_type}_{method_name}: {e}")
|
||||
return f"Error: {str(e)}"
|
||||
|
||||
except Exception as e:
|
||||
# Import custom exceptions for better error handling
|
||||
from plugins.wordpress.client import AuthenticationError, ConfigurationError
|
||||
|
||||
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"
|
||||
f"Hint: For {plugin_type}, ensure these environment variables are set:\n"
|
||||
f" - {plugin_type.upper()}_SITE*_URL\n"
|
||||
f" - {plugin_type.upper()}_SITE*_USERNAME\n"
|
||||
f" - {plugin_type.upper()}_SITE*_APP_PASSWORD"
|
||||
)
|
||||
|
||||
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)}"
|
||||
|
||||
else:
|
||||
# Unexpected error
|
||||
logger.error(
|
||||
f"Error in unified handler for {plugin_type}_{method_name}: {e}",
|
||||
exc_info=True,
|
||||
)
|
||||
return f"Error ({error_type}): {str(e)}"
|
||||
|
||||
# Set function name for better debugging
|
||||
unified_handler.__name__ = f"{plugin_type}_{method_name}_handler"
|
||||
|
||||
return unified_handler
|
||||
|
||||
def generate_all_tools(self, plugin_classes: dict[str, type]) -> list[ToolDefinition]:
|
||||
"""
|
||||
Generate tools for all plugin classes.
|
||||
|
||||
Args:
|
||||
plugin_classes: Dict mapping plugin_type to plugin class
|
||||
|
||||
Returns:
|
||||
List of all tool definitions
|
||||
|
||||
Examples:
|
||||
>>> plugins = {
|
||||
... 'wordpress': WordPressPlugin,
|
||||
... 'gitea': GiteaPlugin
|
||||
... }
|
||||
>>> all_tools = generator.generate_all_tools(plugins)
|
||||
"""
|
||||
all_tools = []
|
||||
|
||||
for plugin_type, plugin_class in plugin_classes.items():
|
||||
try:
|
||||
tools = self.generate_tools(plugin_class, plugin_type)
|
||||
all_tools.extend(tools)
|
||||
except Exception as e:
|
||||
self.logger.error(f"Failed to generate tools for {plugin_type}: {e}", exc_info=True)
|
||||
|
||||
self.logger.info(
|
||||
f"Generated {len(all_tools)} total tools " f"across {len(plugin_classes)} plugin types"
|
||||
)
|
||||
|
||||
return all_tools
|
||||
228
core/tool_registry.py
Normal file
228
core/tool_registry.py
Normal file
@@ -0,0 +1,228 @@
|
||||
"""
|
||||
Tool Registry - Central tool management for MCP
|
||||
|
||||
Manages tool definitions with type safety and validation.
|
||||
Part of Option B clean architecture refactoring.
|
||||
"""
|
||||
|
||||
import logging
|
||||
from collections.abc import Callable
|
||||
from typing import Any
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, Field
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
class ToolDefinition(BaseModel):
|
||||
"""
|
||||
Type-safe tool definition.
|
||||
|
||||
Represents a single MCP tool with all necessary metadata.
|
||||
|
||||
Attributes:
|
||||
name: Unique tool identifier (e.g., "wordpress_get_post")
|
||||
description: Human-readable tool description
|
||||
input_schema: JSON Schema for tool parameters
|
||||
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")
|
||||
"""
|
||||
|
||||
name: str = Field(..., description="Unique tool identifier")
|
||||
description: str = Field(..., description="Tool description")
|
||||
input_schema: dict[str, Any] = Field(
|
||||
default_factory=lambda: {"type": "object", "properties": {}},
|
||||
description="JSON Schema for parameters",
|
||||
)
|
||||
handler: Callable = Field(..., description="Async handler function")
|
||||
required_scope: str = Field(
|
||||
default="read", description="Required API key scope (read/write/admin)"
|
||||
)
|
||||
plugin_type: str = Field(..., description="Plugin type (wordpress, gitea, etc)")
|
||||
|
||||
model_config = ConfigDict(arbitrary_types_allowed=True) # Allow Callable type
|
||||
|
||||
class ToolRegistry:
|
||||
"""
|
||||
Central registry for all MCP tools.
|
||||
|
||||
Manages tool registration, retrieval, and validation.
|
||||
Ensures unique tool names and provides filtering by plugin type.
|
||||
|
||||
Examples:
|
||||
>>> registry = ToolRegistry()
|
||||
>>> tool = ToolDefinition(
|
||||
... name="wordpress_list_posts",
|
||||
... description="List WordPress posts",
|
||||
... handler=async_handler_func,
|
||||
... plugin_type="wordpress"
|
||||
... )
|
||||
>>> registry.register(tool)
|
||||
>>> all_tools = registry.get_all()
|
||||
>>> wp_tools = registry.get_by_plugin_type("wordpress")
|
||||
"""
|
||||
|
||||
def __init__(self):
|
||||
"""Initialize empty tool registry."""
|
||||
self.tools: dict[str, ToolDefinition] = {}
|
||||
self.logger = logging.getLogger("ToolRegistry")
|
||||
self.logger.info("ToolRegistry initialized")
|
||||
|
||||
def register(self, tool: ToolDefinition) -> None:
|
||||
"""
|
||||
Register a tool in the registry.
|
||||
|
||||
Args:
|
||||
tool: Tool definition to register
|
||||
|
||||
Raises:
|
||||
ValueError: If tool name already exists
|
||||
|
||||
Examples:
|
||||
>>> registry.register(tool_definition)
|
||||
"""
|
||||
if tool.name in self.tools:
|
||||
raise ValueError(f"Tool '{tool.name}' already registered")
|
||||
|
||||
self.tools[tool.name] = tool
|
||||
self.logger.debug(f"Registered tool: {tool.name} ({tool.plugin_type})")
|
||||
|
||||
def register_many(self, tools: list[ToolDefinition]) -> int:
|
||||
"""
|
||||
Register multiple tools at once.
|
||||
|
||||
Args:
|
||||
tools: List of tool definitions
|
||||
|
||||
Returns:
|
||||
Number of tools successfully registered
|
||||
|
||||
Examples:
|
||||
>>> count = registry.register_many([tool1, tool2, tool3])
|
||||
>>> print(f"Registered {count} tools")
|
||||
"""
|
||||
count = 0
|
||||
for tool in tools:
|
||||
try:
|
||||
self.register(tool)
|
||||
count += 1
|
||||
except ValueError as e:
|
||||
self.logger.warning(f"Skipped duplicate tool: {e}")
|
||||
except Exception as e:
|
||||
self.logger.error(f"Failed to register tool: {e}", exc_info=True)
|
||||
|
||||
self.logger.info(f"Registered {count}/{len(tools)} tools")
|
||||
return count
|
||||
|
||||
def get_all(self) -> list[ToolDefinition]:
|
||||
"""
|
||||
Get all registered tools.
|
||||
|
||||
Returns:
|
||||
List of all tool definitions
|
||||
|
||||
Examples:
|
||||
>>> tools = registry.get_all()
|
||||
>>> print(f"Total tools: {len(tools)}")
|
||||
"""
|
||||
return list(self.tools.values())
|
||||
|
||||
def get_by_name(self, name: str) -> ToolDefinition | None:
|
||||
"""
|
||||
Get a tool by its name.
|
||||
|
||||
Args:
|
||||
name: Tool name
|
||||
|
||||
Returns:
|
||||
Tool definition if found, None otherwise
|
||||
|
||||
Examples:
|
||||
>>> tool = registry.get_by_name("wordpress_get_post")
|
||||
>>> if tool:
|
||||
... print(f"Found: {tool.description}")
|
||||
"""
|
||||
return self.tools.get(name)
|
||||
|
||||
def get_by_plugin_type(self, plugin_type: str) -> list[ToolDefinition]:
|
||||
"""
|
||||
Get all tools for a specific plugin type.
|
||||
|
||||
Args:
|
||||
plugin_type: Plugin type (e.g., "wordpress", "gitea")
|
||||
|
||||
Returns:
|
||||
List of tools for the specified plugin type
|
||||
|
||||
Examples:
|
||||
>>> wp_tools = registry.get_by_plugin_type("wordpress")
|
||||
>>> print(f"WordPress tools: {len(wp_tools)}")
|
||||
"""
|
||||
return [tool for tool in self.tools.values() if tool.plugin_type == plugin_type]
|
||||
|
||||
def get_count(self) -> int:
|
||||
"""
|
||||
Get total number of registered tools.
|
||||
|
||||
Returns:
|
||||
Count of registered tools
|
||||
|
||||
Examples:
|
||||
>>> count = registry.get_count()
|
||||
"""
|
||||
return len(self.tools)
|
||||
|
||||
def get_count_by_plugin(self) -> dict[str, int]:
|
||||
"""
|
||||
Get tool counts grouped by plugin type.
|
||||
|
||||
Returns:
|
||||
Dictionary mapping plugin type to tool count
|
||||
|
||||
Examples:
|
||||
>>> counts = registry.get_count_by_plugin()
|
||||
>>> print(counts) # {'wordpress': 95, 'gitea': 50}
|
||||
"""
|
||||
counts = {}
|
||||
for tool in self.tools.values():
|
||||
plugin_type = tool.plugin_type
|
||||
counts[plugin_type] = counts.get(plugin_type, 0) + 1
|
||||
return counts
|
||||
|
||||
def clear(self) -> None:
|
||||
"""
|
||||
Clear all registered tools.
|
||||
|
||||
Primarily for testing purposes.
|
||||
|
||||
Examples:
|
||||
>>> registry.clear()
|
||||
>>> assert registry.get_count() == 0
|
||||
"""
|
||||
self.tools.clear()
|
||||
self.logger.info("Tool registry cleared")
|
||||
|
||||
def __repr__(self) -> str:
|
||||
"""String representation of registry."""
|
||||
counts = self.get_count_by_plugin()
|
||||
counts_str = ", ".join(f"{k}: {v}" for k, v in counts.items())
|
||||
return f"ToolRegistry(total={self.get_count()}, {counts_str})"
|
||||
|
||||
# Singleton instance
|
||||
_tool_registry: ToolRegistry | None = None
|
||||
|
||||
def get_tool_registry() -> ToolRegistry:
|
||||
"""
|
||||
Get the singleton tool registry instance.
|
||||
|
||||
Returns:
|
||||
Global ToolRegistry instance
|
||||
|
||||
Examples:
|
||||
>>> registry = get_tool_registry()
|
||||
>>> registry.register(tool)
|
||||
"""
|
||||
global _tool_registry
|
||||
if _tool_registry is None:
|
||||
_tool_registry = ToolRegistry()
|
||||
return _tool_registry
|
||||
362
core/unified_tools.py
Normal file
362
core/unified_tools.py
Normal file
@@ -0,0 +1,362 @@
|
||||
"""
|
||||
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
|
||||
110
docker-compose.yaml
Normal file
110
docker-compose.yaml
Normal file
@@ -0,0 +1,110 @@
|
||||
# ===================================
|
||||
# Coolify Projects MCP Server
|
||||
# Docker Compose Configuration
|
||||
# ===================================
|
||||
#
|
||||
# Build Pack: Docker Compose
|
||||
# ⚠️ CRITICAL RULES FOR COOLIFY:
|
||||
# 1. NO host port mappings: Use "8000" NOT "8000:8000"
|
||||
# 2. Listen on 0.0.0.0 (NOT localhost)
|
||||
# 3. Health checks for ALL services
|
||||
# 4. Use environment variables for ALL configs
|
||||
# ===================================
|
||||
|
||||
version: '3.8'
|
||||
|
||||
services:
|
||||
mcp-server:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile
|
||||
container_name: mcphub
|
||||
restart: unless-stopped
|
||||
|
||||
# ⚠️ CRITICAL: Only container port (NO host port mapping)
|
||||
# Coolify will handle routing via domain
|
||||
ports:
|
||||
- "8000"
|
||||
|
||||
# Environment variables
|
||||
environment:
|
||||
# Master API key
|
||||
- MASTER_API_KEY=${MASTER_API_KEY}
|
||||
|
||||
# Logging
|
||||
- LOG_LEVEL=${LOG_LEVEL:-INFO}
|
||||
- PYTHONUNBUFFERED=1
|
||||
|
||||
# === OAuth 2.1 Configuration (Phase B) ===
|
||||
# Required for OAuth authentication
|
||||
- OAUTH_JWT_SECRET_KEY=${OAUTH_JWT_SECRET_KEY}
|
||||
- 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}
|
||||
|
||||
# OAuth Authorization Security
|
||||
# IMPORTANT: API Key is always required for authorization (OAuth manual mode)
|
||||
# Use 'required' (recommended) to enforce API Key authentication
|
||||
- OAUTH_AUTH_MODE=${OAUTH_AUTH_MODE:-required}
|
||||
# OAUTH_TRUSTED_DOMAINS is deprecated - API Key always required for security
|
||||
- OAUTH_BASE_URL=${OAUTH_BASE_URL}
|
||||
|
||||
# === 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.
|
||||
|
||||
# === Future Plugins ===
|
||||
# Gitea, Supabase, and other plugins will auto-discover
|
||||
# their environment variables when implemented.
|
||||
# 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 (Phase 5+)
|
||||
# Required for: wp_cache_flush, wp_cache_type, wp_transient_*, etc.
|
||||
volumes:
|
||||
- /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
|
||||
group_add:
|
||||
- "988" # Docker group GID (adjust if needed)
|
||||
|
||||
# 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
|
||||
585
docs/API_KEYS_GUIDE.md
Normal file
585
docs/API_KEYS_GUIDE.md
Normal file
@@ -0,0 +1,585 @@
|
||||
# 🔐 API Keys Management Guide
|
||||
|
||||
Complete guide for managing API keys in MCP Hub.
|
||||
|
||||
---
|
||||
|
||||
## Table of Contents
|
||||
|
||||
- [Overview](#overview)
|
||||
- [Key Types](#key-types)
|
||||
- [Scopes & Permissions](#scopes--permissions)
|
||||
- [Creating Keys](#creating-keys)
|
||||
- [Managing Keys](#managing-keys)
|
||||
- [Best Practices](#best-practices)
|
||||
- [Examples](#examples)
|
||||
- [Troubleshooting](#troubleshooting)
|
||||
|
||||
---
|
||||
|
||||
## Overview
|
||||
|
||||
MCP Hub supports two types of API keys for authentication:
|
||||
|
||||
1. **Master API Key** - Full access to all operations and projects
|
||||
2. **Per-Project API Keys** - Scoped access with granular permissions
|
||||
|
||||
Per-project keys provide:
|
||||
- ✅ **Project-level isolation** - Limit access to specific projects
|
||||
- ✅ **Scope-based permissions** - Control read/write/admin operations
|
||||
- ✅ **Expiration support** - Automatic key rotation
|
||||
- ✅ **Usage tracking** - Monitor key usage and last access
|
||||
- ✅ **Audit trail** - All key operations are logged
|
||||
- ✅ **Easy rotation** - Rotate all keys for a project with one command
|
||||
|
||||
---
|
||||
|
||||
## Key Types
|
||||
|
||||
### Master API Key
|
||||
|
||||
- **Source**: `MASTER_API_KEY` environment variable
|
||||
- **Access Level**: Full admin access to all projects
|
||||
- **Use Case**: Server administration, initial setup
|
||||
- **Lifetime**: Permanent (until manually changed)
|
||||
- **Format**: Any string (recommended: 32+ characters)
|
||||
|
||||
**Example**:
|
||||
```bash
|
||||
MASTER_API_KEY=your_secure_master_key_here
|
||||
```
|
||||
|
||||
### Per-Project API Keys
|
||||
|
||||
- **Storage**: JSON file (`data/api_keys.json`)
|
||||
- **Access Level**: Configurable per key (read/write/admin)
|
||||
- **Use Case**: Application integration, team access, CI/CD
|
||||
- **Lifetime**: Optional expiration (days)
|
||||
- **Format**: `cmp_` prefix + random string
|
||||
|
||||
**Example**:
|
||||
```
|
||||
cmp_AQECAHhoZXJlIGlzIGEgcmFuZG9tIGtleQ
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Scopes & Permissions
|
||||
|
||||
### Read Scope
|
||||
|
||||
**Per-Project Keys** (`project_id` specific):
|
||||
- ✅ List and get operations for assigned project
|
||||
- ✅ Read WordPress content (posts, pages, comments, media)
|
||||
- ✅ Read WooCommerce data (products, orders, customers)
|
||||
- ✅ Read taxonomies, menus, and settings
|
||||
- ❌ Cannot access system tools (requires global key)
|
||||
- ❌ Cannot create, update, or delete
|
||||
|
||||
**Tools Allowed**:
|
||||
- `wordpress_list_posts`, `wordpress_get_post`
|
||||
- `wordpress_list_products`, `wordpress_get_product`
|
||||
- `wordpress_list_orders`, `wordpress_get_order`
|
||||
- All WordPress/WooCommerce `get_*` and `list_*` tools
|
||||
|
||||
**Global Keys** (`project_id="*"`):
|
||||
- ✅ All per-project permissions for ALL projects
|
||||
- ✅ System tools: `list_projects`, `get_project_info`
|
||||
- ✅ Monitoring: `check_all_projects_health`, `get_system_metrics`
|
||||
- ✅ Rate limits: `get_rate_limit_stats`
|
||||
- ✅ API Keys: `manage_api_keys_list` (read-only)
|
||||
|
||||
**Use Cases**:
|
||||
- **Per-Project**: Client access to their specific site
|
||||
- **Global**: Admin dashboards, monitoring, analytics
|
||||
|
||||
### Write Scope
|
||||
|
||||
**Per-Project Keys**:
|
||||
- ✅ All read operations for assigned project
|
||||
- ✅ Create, update, delete content
|
||||
- ✅ Upload and modify media
|
||||
- ✅ Manage products, orders, customers
|
||||
- ❌ Cannot access system tools (requires global key)
|
||||
- ❌ Cannot manage API keys or system settings
|
||||
|
||||
**Tools Allowed**:
|
||||
- All read scope tools for the project, plus:
|
||||
- `wordpress_create_post`, `wordpress_update_post`, `wordpress_delete_post`
|
||||
- `wordpress_create_product`, `wordpress_update_product`
|
||||
- `wordpress_create_order`, `wordpress_update_order_status`
|
||||
- `wordpress_upload_media_from_url`
|
||||
- All `create_*`, `update_*`, `delete_*` tools for the project
|
||||
|
||||
**Global Keys** (`project_id="*"`):
|
||||
- ✅ All per-project permissions for ALL projects
|
||||
- ✅ System tools access (read-only)
|
||||
|
||||
**Use Cases**:
|
||||
- **Per-Project**: Client content management for their site
|
||||
- **Global**: Multi-site management, automated publishing
|
||||
|
||||
### Admin Scope
|
||||
|
||||
**Per-Project Keys**:
|
||||
- ✅ All read and write operations for assigned project
|
||||
- ✅ Advanced WordPress management (WP-CLI tools)
|
||||
- ✅ Database operations (check, optimize, export)
|
||||
- ❌ Cannot access system tools (requires global key)
|
||||
- ❌ Cannot manage API keys (requires global key)
|
||||
|
||||
**Global Keys** (`project_id="*"`):
|
||||
- ✅ All per-project permissions for ALL projects
|
||||
- ✅ Full system access: `manage_api_keys_*`, `reset_rate_limit`
|
||||
- ✅ System monitoring and administration
|
||||
- ✅ Health metrics export
|
||||
|
||||
**Tools Allowed**:
|
||||
- All read and write scope tools, plus:
|
||||
- `manage_api_keys_*` tools (global keys only)
|
||||
- `reset_rate_limit` (global keys only)
|
||||
- `export_health_metrics`
|
||||
- WP-CLI tools: `wp_cache_flush`, `wp_db_optimize`, etc.
|
||||
|
||||
**Use Cases**:
|
||||
- **Per-Project**: Full site administration for specific client
|
||||
- **Global**: Platform administration, multi-tenant management
|
||||
- System maintenance
|
||||
|
||||
---
|
||||
|
||||
## Creating Keys
|
||||
|
||||
### Basic Key Creation
|
||||
|
||||
Create a read-only key for a specific project:
|
||||
|
||||
```python
|
||||
result = manage_api_keys_create(
|
||||
project_id="wordpress_site1",
|
||||
scope="read"
|
||||
)
|
||||
|
||||
# Save the key - it won't be shown again!
|
||||
api_key = result["key"] # cmp_...
|
||||
key_id = result["key_id"] # key_...
|
||||
```
|
||||
|
||||
### Key with Description
|
||||
|
||||
Add a description for better organization:
|
||||
|
||||
```python
|
||||
result = manage_api_keys_create(
|
||||
project_id="wordpress_site1",
|
||||
scope="write",
|
||||
description="CI/CD pipeline key for automated deployments"
|
||||
)
|
||||
```
|
||||
|
||||
### Expiring Key
|
||||
|
||||
Create a temporary key that expires after 30 days:
|
||||
|
||||
```python
|
||||
result = manage_api_keys_create(
|
||||
project_id="wordpress_site2",
|
||||
scope="read",
|
||||
expires_in_days=30,
|
||||
description="Temporary access for contractor"
|
||||
)
|
||||
```
|
||||
|
||||
### Global Key
|
||||
|
||||
Create a key that works for all projects:
|
||||
|
||||
```python
|
||||
result = manage_api_keys_create(
|
||||
project_id="*", # All projects
|
||||
scope="admin",
|
||||
description="Backup admin key"
|
||||
)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Managing Keys
|
||||
|
||||
### List All Keys
|
||||
|
||||
```python
|
||||
result = manage_api_keys_list()
|
||||
|
||||
print(f"Total keys: {result['total']}")
|
||||
for key in result['keys']:
|
||||
print(f"- {key['key_id']}: {key['project_id']} ({key['scope']})")
|
||||
```
|
||||
|
||||
### List Keys for Specific Project
|
||||
|
||||
```python
|
||||
result = manage_api_keys_list(
|
||||
project_id="wordpress_site1"
|
||||
)
|
||||
```
|
||||
|
||||
### Include Revoked Keys
|
||||
|
||||
```python
|
||||
result = manage_api_keys_list(
|
||||
include_revoked=True
|
||||
)
|
||||
```
|
||||
|
||||
### Get Key Information
|
||||
|
||||
```python
|
||||
result = manage_api_keys_get_info(
|
||||
key_id="key_abc123"
|
||||
)
|
||||
|
||||
if result['success']:
|
||||
key_info = result['key']
|
||||
print(f"Project: {key_info['project_id']}")
|
||||
print(f"Scope: {key_info['scope']}")
|
||||
print(f"Created: {key_info['created_at']}")
|
||||
print(f"Last used: {key_info['last_used_at']}")
|
||||
print(f"Usage count: {key_info['usage_count']}")
|
||||
print(f"Valid: {key_info['valid']}")
|
||||
```
|
||||
|
||||
### Revoke a Key
|
||||
|
||||
Soft delete (can view in history):
|
||||
|
||||
```python
|
||||
result = manage_api_keys_revoke(
|
||||
key_id="key_abc123"
|
||||
)
|
||||
```
|
||||
|
||||
### Delete a Key
|
||||
|
||||
Permanent deletion:
|
||||
|
||||
```python
|
||||
result = manage_api_keys_delete(
|
||||
key_id="key_abc123"
|
||||
)
|
||||
```
|
||||
|
||||
### Rotate All Project Keys
|
||||
|
||||
Create new keys and revoke old ones:
|
||||
|
||||
```python
|
||||
result = manage_api_keys_rotate(
|
||||
project_id="wordpress_site1"
|
||||
)
|
||||
|
||||
# Save the new keys!
|
||||
for new_key in result['new_keys']:
|
||||
print(f"New key: {new_key['key']}")
|
||||
print(f"Scope: {new_key['scope']}")
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Best Practices
|
||||
|
||||
### 1. Use Principle of Least Privilege
|
||||
|
||||
**❌ Don't**:
|
||||
```python
|
||||
# Giving admin access when read is enough
|
||||
manage_api_keys_create("wordpress_site1", scope="admin")
|
||||
```
|
||||
|
||||
**✅ Do**:
|
||||
```python
|
||||
# Use minimal required scope
|
||||
manage_api_keys_create("wordpress_site1", scope="read")
|
||||
```
|
||||
|
||||
### 2. Set Expiration for Temporary Access
|
||||
|
||||
**❌ Don't**:
|
||||
```python
|
||||
# Permanent key for temporary contractor
|
||||
manage_api_keys_create("wordpress_site1", scope="write")
|
||||
```
|
||||
|
||||
**✅ Do**:
|
||||
```python
|
||||
# Expiring key for contractor
|
||||
manage_api_keys_create(
|
||||
"wordpress_site1",
|
||||
scope="write",
|
||||
expires_in_days=90,
|
||||
description="Q4 contractor access"
|
||||
)
|
||||
```
|
||||
|
||||
### 3. Use Descriptive Names
|
||||
|
||||
**❌ Don't**:
|
||||
```python
|
||||
manage_api_keys_create("wordpress_site1", "write")
|
||||
```
|
||||
|
||||
**✅ Do**:
|
||||
```python
|
||||
manage_api_keys_create(
|
||||
"wordpress_site1",
|
||||
scope="write",
|
||||
description="Production deployment key for CI/CD pipeline"
|
||||
)
|
||||
```
|
||||
|
||||
### 4. Regular Key Rotation
|
||||
|
||||
Rotate keys quarterly or after team changes:
|
||||
|
||||
```python
|
||||
# Every 3 months
|
||||
result = manage_api_keys_rotate("wordpress_site1")
|
||||
|
||||
# Update all integrations with new keys
|
||||
for key in result['new_keys']:
|
||||
# Update CI/CD, monitoring tools, etc.
|
||||
update_integration(key['key'])
|
||||
```
|
||||
|
||||
### 5. Monitor Key Usage
|
||||
|
||||
```python
|
||||
# Check if keys are being used
|
||||
result = manage_api_keys_list()
|
||||
|
||||
for key in result['keys']:
|
||||
if key['usage_count'] == 0:
|
||||
print(f"Warning: Key {key['key_id']} has never been used")
|
||||
|
||||
if key['last_used_at']:
|
||||
# Check if key hasn't been used in 30+ days
|
||||
# Consider revoking inactive keys
|
||||
pass
|
||||
```
|
||||
|
||||
### 6. Revoke Compromised Keys Immediately
|
||||
|
||||
```python
|
||||
# If a key is compromised
|
||||
manage_api_keys_revoke("key_compromised")
|
||||
|
||||
# Create a new key
|
||||
new_key = manage_api_keys_create(
|
||||
"wordpress_site1",
|
||||
scope="write",
|
||||
description="Replacement for compromised key"
|
||||
)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Examples
|
||||
|
||||
### Example 1: CI/CD Pipeline
|
||||
|
||||
```python
|
||||
# 1. Create a write-scoped key for CI/CD
|
||||
result = manage_api_keys_create(
|
||||
project_id="wordpress_site1",
|
||||
scope="write",
|
||||
description="GitHub Actions deployment key"
|
||||
)
|
||||
|
||||
ci_key = result['key']
|
||||
|
||||
# 2. Add to GitHub Secrets as MCP_API_KEY
|
||||
|
||||
# 3. Use in workflow:
|
||||
# headers = {"Authorization": f"Bearer {os.getenv('MCP_API_KEY')}"}
|
||||
```
|
||||
|
||||
### Example 2: Monitoring Dashboard
|
||||
|
||||
```python
|
||||
# Create read-only key for monitoring
|
||||
result = manage_api_keys_create(
|
||||
project_id="*", # All projects
|
||||
scope="read",
|
||||
description="Grafana monitoring dashboard"
|
||||
)
|
||||
|
||||
monitoring_key = result['key']
|
||||
|
||||
# Use for health checks, metrics collection
|
||||
```
|
||||
|
||||
### Example 3: Team Member Access
|
||||
|
||||
```python
|
||||
# Create keys for team members
|
||||
team_keys = {}
|
||||
|
||||
for member in ["alice", "bob", "charlie"]:
|
||||
result = manage_api_keys_create(
|
||||
project_id="wordpress_site1",
|
||||
scope="write",
|
||||
description=f"Key for {member}"
|
||||
)
|
||||
team_keys[member] = result['key']
|
||||
|
||||
# Distribute keys securely (1Password, etc.)
|
||||
```
|
||||
|
||||
### Example 4: Temporary Contractor Access
|
||||
|
||||
```python
|
||||
# 90-day expiring key for contractor
|
||||
result = manage_api_keys_create(
|
||||
project_id="wordpress_site2",
|
||||
scope="read",
|
||||
expires_in_days=90,
|
||||
description="Contractor access - expires Q1 2026"
|
||||
)
|
||||
|
||||
contractor_key = result['key']
|
||||
|
||||
# Key automatically becomes invalid after 90 days
|
||||
```
|
||||
|
||||
### Example 5: Key Rotation Schedule
|
||||
|
||||
```python
|
||||
# Quarterly rotation script
|
||||
import schedule
|
||||
|
||||
def rotate_all_projects():
|
||||
projects = ["wordpress_site1", "wordpress_site2", "wordpress_site3"]
|
||||
|
||||
for project in projects:
|
||||
result = manage_api_keys_rotate(project)
|
||||
print(f"Rotated {result['rotated_count']} keys for {project}")
|
||||
|
||||
# Email new keys to team
|
||||
notify_team(project, result['new_keys'])
|
||||
|
||||
# Run every 90 days
|
||||
schedule.every(90).days.do(rotate_all_projects)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Key Not Working
|
||||
|
||||
**Problem**: API key returns "Authentication failed"
|
||||
|
||||
**Solutions**:
|
||||
|
||||
1. Check if key is revoked:
|
||||
```python
|
||||
info = manage_api_keys_get_info("key_abc123")
|
||||
if info['key']['revoked']:
|
||||
print("Key has been revoked")
|
||||
```
|
||||
|
||||
2. Check if key expired:
|
||||
```python
|
||||
if info['key']['expired']:
|
||||
print("Key has expired")
|
||||
```
|
||||
|
||||
3. Verify scope matches operation:
|
||||
```python
|
||||
# Read-only key cannot write
|
||||
if info['key']['scope'] == 'read':
|
||||
print("Cannot use read key for write operations")
|
||||
```
|
||||
|
||||
### Key Not Found
|
||||
|
||||
**Problem**: "Key not found: key_abc123"
|
||||
|
||||
**Solution**: List all keys to find correct ID:
|
||||
```python
|
||||
result = manage_api_keys_list(include_revoked=True)
|
||||
for key in result['keys']:
|
||||
print(f"{key['key_id']}: {key['description']}")
|
||||
```
|
||||
|
||||
### Permission Denied
|
||||
|
||||
**Problem**: "Insufficient scope"
|
||||
|
||||
**Solution**: Check required scope for operation:
|
||||
```python
|
||||
# Operation requires 'write' but key has 'read'
|
||||
# Create new key with correct scope:
|
||||
manage_api_keys_create(project_id="site1", scope="write")
|
||||
```
|
||||
|
||||
### Storage File Issues
|
||||
|
||||
**Problem**: "Failed to load keys" or "Failed to save keys"
|
||||
|
||||
**Solutions**:
|
||||
|
||||
1. Check file permissions:
|
||||
```bash
|
||||
ls -l data/api_keys.json
|
||||
chmod 600 data/api_keys.json # Read/write for owner only
|
||||
```
|
||||
|
||||
2. Check directory exists:
|
||||
```bash
|
||||
mkdir -p data
|
||||
```
|
||||
|
||||
3. Validate JSON format:
|
||||
```bash
|
||||
python -m json.tool data/api_keys.json
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Security Considerations
|
||||
|
||||
### Storage Security
|
||||
|
||||
- API keys are stored as SHA256 hashes
|
||||
- Only the key hash is saved, not the actual key
|
||||
- Storage file should have restricted permissions (600)
|
||||
- Consider encrypting the storage file at rest
|
||||
|
||||
### Network Security
|
||||
|
||||
- Always use HTTPS for API requests
|
||||
- Never log API keys in plain text
|
||||
- Use secure channels to distribute keys (1Password, Vault)
|
||||
|
||||
### Audit & Compliance
|
||||
|
||||
- All key operations are logged in audit.log
|
||||
- Track key usage via `usage_count` and `last_used_at`
|
||||
- Regular review of active keys
|
||||
- Compliance with GDPR, SOC 2, ISO 27001
|
||||
|
||||
---
|
||||
|
||||
## Related Documentation
|
||||
|
||||
- [Authentication Guide](AUTH_GUIDE.md)
|
||||
- [Security Policy](../SECURITY.md)
|
||||
- [Audit Logging](AUDIT_LOGGING.md)
|
||||
- [Rate Limiting](RATE_LIMITING.md)
|
||||
|
||||
---
|
||||
|
||||
**Version**: 1.0.0
|
||||
**Last Updated**: 2025-11-11
|
||||
**Maintained by**: Airano (https://mcphub.dev)
|
||||
721
docs/GITEA_GUIDE.md
Normal file
721
docs/GITEA_GUIDE.md
Normal file
@@ -0,0 +1,721 @@
|
||||
# 🦊 Gitea Plugin Guide
|
||||
|
||||
**Complete guide for Gitea integration in MCP Hub**
|
||||
|
||||
Version: 1.0.0 (Phase C)
|
||||
Last Updated: 2025-11-19
|
||||
|
||||
---
|
||||
|
||||
## 📋 Table of Contents
|
||||
|
||||
- [Overview](#overview)
|
||||
- [Features](#features)
|
||||
- [Installation](#installation)
|
||||
- [Configuration](#configuration)
|
||||
- [Tool Categories](#tool-categories)
|
||||
- [Usage Examples](#usage-examples)
|
||||
- [OAuth Integration](#oauth-integration)
|
||||
- [API Reference](#api-reference)
|
||||
- [Troubleshooting](#troubleshooting)
|
||||
- [Best Practices](#best-practices)
|
||||
|
||||
---
|
||||
|
||||
## 🎯 Overview
|
||||
|
||||
The Gitea Plugin provides comprehensive Git repository management through Gitea's REST API. It enables AI assistants (like Claude) to manage repositories, issues, pull requests, and more directly from ChatGPT or other MCP-enabled clients.
|
||||
|
||||
### What is Gitea?
|
||||
|
||||
Gitea is a lightweight, self-hosted Git service similar to GitHub/GitLab. It provides:
|
||||
- Git repository hosting
|
||||
- Issue tracking
|
||||
- Pull requests
|
||||
- Code review
|
||||
- Webhooks
|
||||
- Organizations and teams
|
||||
|
||||
### Why This Plugin?
|
||||
|
||||
✅ **Git Workflow Automation** - Manage repositories from ChatGPT
|
||||
✅ **CI/CD Integration** - Trigger deployments with PR merges
|
||||
✅ **Issue Management** - AI-assisted issue triaging and responses
|
||||
✅ **Code Review** - Automated PR reviews and suggestions
|
||||
✅ **OAuth Support** - Seamless ChatGPT integration
|
||||
✅ **Multi-Site** - Manage multiple Gitea instances
|
||||
|
||||
---
|
||||
|
||||
## ✨ Features
|
||||
|
||||
### 📦 Repository Management (15 Tools)
|
||||
|
||||
- **CRUD Operations**: Create, read, update, delete repositories
|
||||
- **Branch Management**: List, create, delete branches
|
||||
- **Tag Management**: Create and manage tags
|
||||
- **File Operations**: Read, create, update files in repository
|
||||
|
||||
### 🐛 Issue Tracking (12 Tools)
|
||||
|
||||
- **Issue Management**: Create, update, close, reopen issues
|
||||
- **Labels**: Create and manage issue labels
|
||||
- **Milestones**: Track project milestones
|
||||
- **Comments**: Add comments to issues
|
||||
|
||||
### 🔀 Pull Requests (15 Tools)
|
||||
|
||||
- **PR Operations**: Create, update, merge pull requests
|
||||
- **Code Review**: Review PRs, request changes, approve
|
||||
- **PR Details**: View commits, files, diff
|
||||
- **Reviewers**: Request reviewers for PRs
|
||||
|
||||
### 👥 User & Organization (8 Tools)
|
||||
|
||||
- **User Management**: Get user info, list repositories
|
||||
- **Organizations**: Manage organizations and teams
|
||||
- **Teams**: List team members
|
||||
- **Search**: Search for users
|
||||
|
||||
### 🔗 Webhooks (5 Tools)
|
||||
|
||||
- **Webhook Setup**: Create, list, delete webhooks
|
||||
- **Event Configuration**: Configure webhook events
|
||||
- **Testing**: Test webhook delivery
|
||||
|
||||
**Total: 55 Tools**
|
||||
|
||||
---
|
||||
|
||||
## 📦 Installation
|
||||
|
||||
### Prerequisites
|
||||
|
||||
- Gitea instance (self-hosted or managed)
|
||||
- Gitea personal access token OR OAuth setup
|
||||
- MCP Hub installed
|
||||
|
||||
### 1. Enable Gitea Plugin
|
||||
|
||||
The Gitea plugin is built-in. No additional installation needed.
|
||||
|
||||
### 2. Configure Environment
|
||||
|
||||
Edit `.env` file:
|
||||
|
||||
```bash
|
||||
# Site 1 - Token Authentication
|
||||
GITEA_SITE1_URL=https://gitea.mcphub.dev
|
||||
GITEA_SITE1_TOKEN=your_personal_access_token_here
|
||||
GITEA_SITE1_ALIAS=mygitea
|
||||
|
||||
# Site 2 - OAuth Authentication (for ChatGPT)
|
||||
GITEA_SITE2_URL=https://git.company.com
|
||||
GITEA_SITE2_OAUTH_ENABLED=true
|
||||
GITEA_SITE2_ALIAS=workgit
|
||||
```
|
||||
|
||||
### 3. Generate Personal Access Token
|
||||
|
||||
#### In Gitea:
|
||||
|
||||
1. Log in to your Gitea instance
|
||||
2. Go to **Settings → Applications**
|
||||
3. Click **Generate New Token**
|
||||
4. Enter token name: `MCP Server`
|
||||
5. Select permissions:
|
||||
- ✅ **repo** (all) - Full repository access
|
||||
- ✅ **write:org** - Manage organizations
|
||||
- ✅ **read:user** - Read user information
|
||||
- ✅ **write:issue** - Create/edit issues and PRs
|
||||
6. Click **Generate Token**
|
||||
7. **Copy the token** (shown only once!)
|
||||
8. Add to `.env` as `GITEA_SITE1_TOKEN`
|
||||
|
||||
### 4. Restart Server
|
||||
|
||||
```bash
|
||||
# If running locally
|
||||
python server.py
|
||||
|
||||
# If running in Docker
|
||||
docker-compose restart
|
||||
|
||||
# If deployed in Coolify
|
||||
# Restart the service from Coolify dashboard
|
||||
```
|
||||
|
||||
### 5. Verify Installation
|
||||
|
||||
Check server logs for:
|
||||
|
||||
```
|
||||
✅ Gitea plugin loaded: gitea_site1 (mygitea)
|
||||
```
|
||||
|
||||
Or use health check tool:
|
||||
|
||||
```
|
||||
check_project_health(project_id="gitea_site1")
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## ⚙️ Configuration
|
||||
|
||||
### Environment Variables
|
||||
|
||||
| Variable | Required | Default | Description |
|
||||
|----------|----------|---------|-------------|
|
||||
| `GITEA_SITEX_URL` | ✅ Yes | - | Gitea instance URL |
|
||||
| `GITEA_SITEX_TOKEN` | ⚠️ Conditional | - | Personal access token (required if not using OAuth) |
|
||||
| `GITEA_SITEX_ALIAS` | ❌ No | - | Friendly site name |
|
||||
| `GITEA_SITEX_OAUTH_ENABLED` | ❌ No | `false` | Enable OAuth instead of token |
|
||||
|
||||
### Multiple Sites
|
||||
|
||||
You can configure unlimited Gitea instances:
|
||||
|
||||
```bash
|
||||
# Personal Gitea
|
||||
GITEA_SITE1_URL=https://gitea.mcphub.dev
|
||||
GITEA_SITE1_TOKEN=token1
|
||||
GITEA_SITE1_ALIAS=personal
|
||||
|
||||
# Work Gitea
|
||||
GITEA_SITE2_URL=https://git.company.com
|
||||
GITEA_SITE2_TOKEN=token2
|
||||
GITEA_SITE2_ALIAS=work
|
||||
|
||||
# Client Gitea
|
||||
GITEA_SITE3_URL=https://git.client.com
|
||||
GITEA_SITE3_TOKEN=token3
|
||||
GITEA_SITE3_ALIAS=client
|
||||
```
|
||||
|
||||
### OAuth vs Token Authentication
|
||||
|
||||
#### Token Authentication (Recommended for Scripts)
|
||||
|
||||
✅ **Pros:**
|
||||
- Simple setup
|
||||
- No user interaction needed
|
||||
- Suitable for automation
|
||||
|
||||
❌ **Cons:**
|
||||
- Tokens can expire
|
||||
- Manual renewal needed
|
||||
- Less secure if leaked
|
||||
|
||||
#### OAuth Authentication (Recommended for ChatGPT)
|
||||
|
||||
✅ **Pros:**
|
||||
- User-approved access
|
||||
- More secure
|
||||
- Automatic token refresh
|
||||
- Better for third-party apps
|
||||
|
||||
❌ **Cons:**
|
||||
- Requires OAuth setup
|
||||
- More complex configuration
|
||||
|
||||
---
|
||||
|
||||
## 🧰 Tool Categories
|
||||
|
||||
### 1. Repository Tools
|
||||
|
||||
#### List Repositories
|
||||
```
|
||||
gitea_list_repositories(site, owner?, type?, page?, limit?)
|
||||
```
|
||||
|
||||
Example:
|
||||
```
|
||||
# List all my repositories
|
||||
gitea_list_repositories(site="mygitea")
|
||||
|
||||
# List user's repositories
|
||||
gitea_list_repositories(site="mygitea", owner="username")
|
||||
```
|
||||
|
||||
#### Create Repository
|
||||
```
|
||||
gitea_create_repository(site, name, description?, private?, auto_init?)
|
||||
```
|
||||
|
||||
Example:
|
||||
```
|
||||
gitea_create_repository(
|
||||
site="mygitea",
|
||||
name="awesome-project",
|
||||
description="My awesome project",
|
||||
private=False,
|
||||
auto_init=True
|
||||
)
|
||||
```
|
||||
|
||||
#### Manage Branches
|
||||
```
|
||||
gitea_list_branches(site, owner, repo)
|
||||
gitea_create_branch(site, owner, repo, new_branch_name, old_branch_name?)
|
||||
gitea_delete_branch(site, owner, repo, branch)
|
||||
```
|
||||
|
||||
#### File Operations
|
||||
```
|
||||
gitea_get_file(site, owner, repo, path, ref?)
|
||||
gitea_create_file(site, owner, repo, path, content, message)
|
||||
gitea_update_file(site, owner, repo, path, content, sha, message)
|
||||
```
|
||||
|
||||
### 2. Issue Tools
|
||||
|
||||
#### Manage Issues
|
||||
```
|
||||
gitea_list_issues(site, owner, repo, state?, labels?, q?)
|
||||
gitea_create_issue(site, owner, repo, title, body?, labels?)
|
||||
gitea_update_issue(site, owner, repo, issue_number, ...)
|
||||
gitea_close_issue(site, owner, repo, issue_number)
|
||||
```
|
||||
|
||||
#### Labels & Milestones
|
||||
```
|
||||
gitea_list_labels(site, owner, repo)
|
||||
gitea_create_label(site, owner, repo, name, color, description?)
|
||||
gitea_list_milestones(site, owner, repo, state?)
|
||||
gitea_create_milestone(site, owner, repo, title, due_on?)
|
||||
```
|
||||
|
||||
### 3. Pull Request Tools
|
||||
|
||||
#### Manage PRs
|
||||
```
|
||||
gitea_create_pull_request(site, owner, repo, title, head, base, body?)
|
||||
gitea_merge_pull_request(site, owner, repo, pr_number, method?)
|
||||
gitea_list_pr_commits(site, owner, repo, pr_number)
|
||||
gitea_list_pr_files(site, owner, repo, pr_number)
|
||||
```
|
||||
|
||||
#### Code Review
|
||||
```
|
||||
gitea_create_pr_review(site, owner, repo, pr_number, event, body?)
|
||||
gitea_request_pr_reviewers(site, owner, repo, pr_number, reviewers)
|
||||
```
|
||||
|
||||
### 4. User & Organization Tools
|
||||
|
||||
```
|
||||
gitea_get_user(site, username)
|
||||
gitea_list_user_repos(site, username)
|
||||
gitea_list_organizations(site)
|
||||
gitea_list_org_teams(site, org)
|
||||
```
|
||||
|
||||
### 5. Webhook Tools
|
||||
|
||||
```
|
||||
gitea_list_webhooks(site, owner, repo)
|
||||
gitea_create_webhook(site, owner, repo, url, events)
|
||||
gitea_test_webhook(site, owner, repo, webhook_id)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 💡 Usage Examples
|
||||
|
||||
### Example 1: Create a Repository
|
||||
|
||||
```
|
||||
# Create a new repository with README
|
||||
gitea_create_repository(
|
||||
site="mygitea",
|
||||
name="my-new-project",
|
||||
description="A new project for testing",
|
||||
private=False,
|
||||
auto_init=True,
|
||||
license="MIT"
|
||||
)
|
||||
```
|
||||
|
||||
### Example 2: Create an Issue
|
||||
|
||||
```
|
||||
# Create an issue
|
||||
gitea_create_issue(
|
||||
site="mygitea",
|
||||
owner="myuser",
|
||||
repo="my-project",
|
||||
title="Bug: Login not working",
|
||||
body="## Description\n\nUsers cannot log in after the latest update.\n\n## Steps to Reproduce\n1. Go to login page\n2. Enter credentials\n3. Click login\n\n## Expected\nShould log in successfully\n\n## Actual\nError message displayed",
|
||||
labels=[1, 3] # bug, high-priority
|
||||
)
|
||||
```
|
||||
|
||||
### Example 3: Create a Pull Request
|
||||
|
||||
```
|
||||
# Create feature branch
|
||||
gitea_create_branch(
|
||||
site="mygitea",
|
||||
owner="myuser",
|
||||
repo="my-project",
|
||||
new_branch_name="feature/user-authentication",
|
||||
old_branch_name="main"
|
||||
)
|
||||
|
||||
# Make changes to files
|
||||
gitea_update_file(
|
||||
site="mygitea",
|
||||
owner="myuser",
|
||||
repo="my-project",
|
||||
path="src/auth.js",
|
||||
content="// Updated authentication code\n...",
|
||||
sha="abc123",
|
||||
message="Add OAuth support",
|
||||
branch="feature/user-authentication"
|
||||
)
|
||||
|
||||
# Create pull request
|
||||
gitea_create_pull_request(
|
||||
site="mygitea",
|
||||
owner="myuser",
|
||||
repo="my-project",
|
||||
title="Add OAuth Authentication",
|
||||
head="feature/user-authentication",
|
||||
base="main",
|
||||
body="This PR adds OAuth support for Google and GitHub authentication."
|
||||
)
|
||||
```
|
||||
|
||||
### Example 4: Review and Merge PR
|
||||
|
||||
```
|
||||
# List open PRs
|
||||
gitea_list_pull_requests(
|
||||
site="mygitea",
|
||||
owner="myuser",
|
||||
repo="my-project",
|
||||
state="open"
|
||||
)
|
||||
|
||||
# Review the PR
|
||||
gitea_create_pr_review(
|
||||
site="mygitea",
|
||||
owner="myuser",
|
||||
repo="my-project",
|
||||
pr_number=5,
|
||||
event="APPROVED",
|
||||
body="LGTM! Great work on the OAuth implementation."
|
||||
)
|
||||
|
||||
# Merge the PR
|
||||
gitea_merge_pull_request(
|
||||
site="mygitea",
|
||||
owner="myuser",
|
||||
repo="my-project",
|
||||
pr_number=5,
|
||||
method="squash",
|
||||
delete_branch_after_merge=True
|
||||
)
|
||||
```
|
||||
|
||||
### Example 5: Setup Webhook for CI/CD
|
||||
|
||||
```
|
||||
# Create webhook for Coolify deployment
|
||||
gitea_create_webhook(
|
||||
site="mygitea",
|
||||
owner="myuser",
|
||||
repo="my-project",
|
||||
url="https://coolify.example.com/webhooks/deploy",
|
||||
events=["push", "pull_request"],
|
||||
content_type="json",
|
||||
secret="webhook_secret_here"
|
||||
)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🔐 OAuth Integration
|
||||
|
||||
### Setup OAuth for ChatGPT
|
||||
|
||||
#### 1. Configure Server
|
||||
|
||||
In `.env`:
|
||||
```bash
|
||||
# Enable OAuth for Gitea site
|
||||
GITEA_SITE1_URL=https://gitea.mcphub.dev
|
||||
GITEA_SITE1_OAUTH_ENABLED=true
|
||||
GITEA_SITE1_ALIAS=mygitea
|
||||
|
||||
# OAuth settings (if not already set)
|
||||
OAUTH_JWT_SECRET_KEY=your_jwt_secret_here
|
||||
OAUTH_AUTH_MODE=trusted_domains
|
||||
OAUTH_TRUSTED_DOMAINS=chatgpt.com,chat.openai.com,openai.com
|
||||
```
|
||||
|
||||
#### 2. Create GPT Action
|
||||
|
||||
In ChatGPT GPTs:
|
||||
|
||||
1. Go to **Configure → Actions**
|
||||
2. Click **Create new action**
|
||||
3. Import OpenAPI schema from: `https://your-server.com/.well-known/oauth-authorization-server`
|
||||
4. Set OAuth:
|
||||
- **Client ID**: Auto-registered via dynamic registration
|
||||
- **Authorization URL**: `https://your-server.com/oauth/authorize`
|
||||
- **Token URL**: `https://your-server.com/oauth/token`
|
||||
|
||||
#### 3. Use from ChatGPT
|
||||
|
||||
```
|
||||
User: "Create a new repository called 'test-repo' in my Gitea"
|
||||
|
||||
Claude: Sure! I'll create a new repository.
|
||||
[Calls gitea_create_repository with OAuth token]
|
||||
```
|
||||
|
||||
### API Key with OAuth
|
||||
|
||||
For additional security, you can require API Keys even with OAuth:
|
||||
|
||||
```bash
|
||||
OAUTH_AUTH_MODE=required
|
||||
```
|
||||
|
||||
Then users must provide API Key in authorization URL:
|
||||
```
|
||||
/oauth/authorize?client_id=xxx&...&api_key=cmp_your_key_here
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 📚 API Reference
|
||||
|
||||
### Tool Naming Convention
|
||||
|
||||
All Gitea tools follow this pattern:
|
||||
```
|
||||
gitea_<action>_<resource>(site, ...params)
|
||||
```
|
||||
|
||||
Examples:
|
||||
- `gitea_create_repository`
|
||||
- `gitea_list_issues`
|
||||
- `gitea_merge_pull_request`
|
||||
|
||||
### Common Parameters
|
||||
|
||||
| Parameter | Type | Required | Description |
|
||||
|-----------|------|----------|-------------|
|
||||
| `site` | string | ✅ | Site ID or alias (e.g., "mygitea") |
|
||||
| `owner` | string | ✅ | Repository owner username/org |
|
||||
| `repo` | string | ✅ | Repository name |
|
||||
| `page` | integer | ❌ | Page number (default: 1) |
|
||||
| `limit` | integer | ❌ | Items per page (default: 30, max: 100) |
|
||||
|
||||
### Response Format
|
||||
|
||||
All tools return consistent response format:
|
||||
|
||||
**Success:**
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"message": "Operation completed",
|
||||
"data": { ... }
|
||||
}
|
||||
```
|
||||
|
||||
**Error:**
|
||||
```json
|
||||
{
|
||||
"error": true,
|
||||
"message": "Error description",
|
||||
"code": "ERROR_CODE"
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🔧 Troubleshooting
|
||||
|
||||
### Common Issues
|
||||
|
||||
#### 1. "Authentication failed"
|
||||
|
||||
**Cause**: Invalid or expired token
|
||||
|
||||
**Solution**:
|
||||
- Regenerate personal access token in Gitea
|
||||
- Update `GITEA_SITEX_TOKEN` in `.env`
|
||||
- Restart server
|
||||
|
||||
#### 2. "Repository not found"
|
||||
|
||||
**Cause**: Incorrect owner or repo name
|
||||
|
||||
**Solution**:
|
||||
- Verify repository exists in Gitea
|
||||
- Check spelling of owner/repo
|
||||
- Ensure user has access to repository
|
||||
|
||||
#### 3. "Permission denied"
|
||||
|
||||
**Cause**: Token lacks required permissions
|
||||
|
||||
**Solution**:
|
||||
- Regenerate token with correct permissions:
|
||||
- `repo` (all)
|
||||
- `write:org`
|
||||
- `read:user`
|
||||
- `write:issue`
|
||||
|
||||
#### 4. "Webhook creation failed"
|
||||
|
||||
**Cause**: Invalid webhook URL or insufficient permissions
|
||||
|
||||
**Solution**:
|
||||
- Verify webhook URL is accessible
|
||||
- Ensure user has admin access to repository
|
||||
- Check webhook events are valid
|
||||
|
||||
### Debug Mode
|
||||
|
||||
Enable debug logging:
|
||||
|
||||
```bash
|
||||
LOG_LEVEL=DEBUG
|
||||
```
|
||||
|
||||
Check logs:
|
||||
```bash
|
||||
tail -f logs/audit.log | jq
|
||||
```
|
||||
|
||||
### Health Check
|
||||
|
||||
Test Gitea connectivity:
|
||||
|
||||
```
|
||||
check_project_health(project_id="gitea_site1")
|
||||
```
|
||||
|
||||
Expected response:
|
||||
```json
|
||||
{
|
||||
"healthy": true,
|
||||
"message": "Gitea instance is accessible"
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## ✅ Best Practices
|
||||
|
||||
### 1. Security
|
||||
|
||||
- ✅ Use **personal access tokens** with minimal required permissions
|
||||
- ✅ Enable **OAuth** for third-party access (ChatGPT)
|
||||
- ✅ Rotate tokens regularly
|
||||
- ✅ Use **private repositories** for sensitive code
|
||||
- ✅ Enable **webhook secrets** for webhook security
|
||||
- ✅ Use **per-project API keys** instead of master key
|
||||
|
||||
### 2. Git Workflow
|
||||
|
||||
- ✅ Create **feature branches** for new features
|
||||
- ✅ Use **pull requests** for code review
|
||||
- ✅ Add **meaningful commit messages**
|
||||
- ✅ Use **labels** for issue categorization
|
||||
- ✅ Set **milestones** for project planning
|
||||
- ✅ **Squash merge** to keep history clean
|
||||
|
||||
### 3. Automation
|
||||
|
||||
- ✅ Setup **webhooks** for CI/CD integration
|
||||
- ✅ Auto-close issues when PR is merged
|
||||
- ✅ Use **labels** to trigger automation
|
||||
- ✅ Create **templates** for issues and PRs
|
||||
|
||||
### 4. Performance
|
||||
|
||||
- ✅ Use **pagination** for large result sets
|
||||
- ✅ Cache repository information when possible
|
||||
- ✅ Use **search** instead of listing all items
|
||||
- ✅ Batch operations when possible
|
||||
|
||||
---
|
||||
|
||||
## 🎯 Use Cases
|
||||
|
||||
### 1. Automated Issue Triage
|
||||
|
||||
```
|
||||
# AI assistant automatically:
|
||||
1. Lists open issues
|
||||
2. Analyzes issue content
|
||||
3. Adds appropriate labels
|
||||
4. Assigns to team members
|
||||
5. Sets milestone
|
||||
```
|
||||
|
||||
### 2. AI Code Review
|
||||
|
||||
```
|
||||
# AI assistant reviews PRs:
|
||||
1. Lists open PRs
|
||||
2. Gets PR diff
|
||||
3. Analyzes code changes
|
||||
4. Adds review comments
|
||||
5. Approves or requests changes
|
||||
```
|
||||
|
||||
### 3. Automated Releases
|
||||
|
||||
```
|
||||
# Create release workflow:
|
||||
1. Create release branch
|
||||
2. Update version numbers
|
||||
3. Generate changelog
|
||||
4. Create pull request
|
||||
5. After merge: create tag and release
|
||||
```
|
||||
|
||||
### 4. Documentation Updates
|
||||
|
||||
```
|
||||
# Keep docs in sync:
|
||||
1. Detect code changes
|
||||
2. Update corresponding documentation
|
||||
3. Create PR for doc updates
|
||||
4. Request review from maintainers
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 📖 Related Documentation
|
||||
|
||||
- [OAuth Guide](OAUTH_GUIDE.md) - Complete OAuth 2.1 setup
|
||||
- [API Keys Guide](API_KEYS_USAGE.md) - API key management
|
||||
- [Gitea API Docs](https://docs.gitea.io/en-us/api-usage/) - Official Gitea API
|
||||
|
||||
---
|
||||
|
||||
## 🆘 Support
|
||||
|
||||
### Need Help?
|
||||
|
||||
- 📧 **Email**: hello@mcphub.dev
|
||||
- 🐛 **Issues**: [GitHub Issues](https://github.com/airano-ir/mcphub/issues)
|
||||
- 📚 **Docs**: [Full Documentation](../README.md)
|
||||
|
||||
### Contributing
|
||||
|
||||
Contributions welcome! See [CONTRIBUTING.md](../CONTRIBUTING.md)
|
||||
|
||||
---
|
||||
|
||||
**Version**: 3.0.0
|
||||
344
docs/MULTI_ENDPOINT_GUIDE.md
Normal file
344
docs/MULTI_ENDPOINT_GUIDE.md
Normal file
@@ -0,0 +1,344 @@
|
||||
# Multi-Endpoint Architecture Guide
|
||||
|
||||
> Solving the Tool Visibility Problem
|
||||
|
||||
---
|
||||
|
||||
## The Problem
|
||||
|
||||
### Before (Single Endpoint)
|
||||
```
|
||||
Client connects to /mcp
|
||||
↓
|
||||
Sees ALL 188 tools
|
||||
↓
|
||||
API key for site4 can see:
|
||||
- ✅ WordPress tools (but for all sites!)
|
||||
- ❌ Gitea tools (shouldn't see)
|
||||
- ❌ System tools (shouldn't see)
|
||||
- ❌ API key management (security risk!)
|
||||
```
|
||||
|
||||
### Issue
|
||||
- Users saw tools they couldn't use
|
||||
- Wasted AI context on irrelevant tools
|
||||
- Security concern: tool enumeration
|
||||
- Confusing UX
|
||||
|
||||
---
|
||||
|
||||
## The Solution
|
||||
|
||||
### After (Multi-Endpoint)
|
||||
```
|
||||
Client connects to /mcp/wordpress
|
||||
↓
|
||||
Sees ONLY 92 WordPress tools
|
||||
↓
|
||||
Clean, focused, secure
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Endpoint Overview
|
||||
|
||||
| Endpoint | Tools | Requires | Use Case |
|
||||
|----------|-------|----------|----------|
|
||||
| `/mcp` | 188 | Master Key | Admin operations |
|
||||
| `/mcp/wordpress` | 92 | API Key | WordPress management |
|
||||
| `/mcp/wordpress-advanced` | 22 | Admin Key | DB/Bulk/System ops |
|
||||
| `/mcp/gitea` | 55 | API Key | Git management |
|
||||
| `/mcp/project/{id}` | Varies | Project Key | Single project |
|
||||
|
||||
---
|
||||
|
||||
## Migration Guide
|
||||
|
||||
### For Existing Users
|
||||
|
||||
**Before (v1.x)**
|
||||
```json
|
||||
{
|
||||
"mcpServers": {
|
||||
"coolify": {
|
||||
"url": "https://mcp.example.com/mcp",
|
||||
"headers": {
|
||||
"Authorization": "Bearer cmp_xxx"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**After (v2.0)**
|
||||
```json
|
||||
{
|
||||
"mcpServers": {
|
||||
"wordpress": {
|
||||
"url": "https://mcp.example.com/mcp/wordpress",
|
||||
"headers": {
|
||||
"Authorization": "Bearer cmp_xxx"
|
||||
}
|
||||
},
|
||||
"gitea": {
|
||||
"url": "https://mcp.example.com/mcp/gitea",
|
||||
"headers": {
|
||||
"Authorization": "Bearer cmp_yyy"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Benefits After Migration
|
||||
- Faster tool discovery
|
||||
- Less AI context used
|
||||
- Better security
|
||||
- Clearer access control
|
||||
|
||||
---
|
||||
|
||||
## API Key Configuration
|
||||
|
||||
### Creating WordPress-Only Key
|
||||
```bash
|
||||
# Using admin endpoint with Master Key
|
||||
curl -X POST https://mcp.example.com/mcp \
|
||||
-H "Authorization: Bearer sk-master-key" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"tool": "manage_api_keys_create",
|
||||
"arguments": {
|
||||
"project_id": "wordpress_site1",
|
||||
"scope": "write",
|
||||
"name": "Site1 Editor"
|
||||
}
|
||||
}'
|
||||
```
|
||||
|
||||
### Key Scopes
|
||||
|
||||
| Scope | Permissions |
|
||||
|-------|-------------|
|
||||
| `read` | Read-only operations |
|
||||
| `write` | Read + create/update |
|
||||
| `admin` | Full access + system tools |
|
||||
|
||||
---
|
||||
|
||||
## Endpoint Details
|
||||
|
||||
### `/mcp` - Admin Endpoint
|
||||
|
||||
**Access**: Master API Key (`sk-*`) only
|
||||
|
||||
**Tools**: All 188 tools including:
|
||||
- WordPress Core (92)
|
||||
- WordPress Advanced (22)
|
||||
- Gitea (55)
|
||||
- System tools (19)
|
||||
- API key management
|
||||
- OAuth management
|
||||
|
||||
**Use Cases**:
|
||||
- Initial setup
|
||||
- Creating API keys
|
||||
- System monitoring
|
||||
- OAuth client management
|
||||
|
||||
---
|
||||
|
||||
### `/mcp/wordpress` - WordPress Endpoint
|
||||
|
||||
**Access**: Any valid API key
|
||||
|
||||
**Tools**: 92 WordPress tools
|
||||
- Posts & Pages
|
||||
- Media
|
||||
- Taxonomy
|
||||
- Comments
|
||||
- Users
|
||||
- WooCommerce
|
||||
- SEO
|
||||
|
||||
**Blacklisted Tools**:
|
||||
- `manage_api_keys_*`
|
||||
- `oauth_*`
|
||||
- System tools
|
||||
|
||||
**Use Cases**:
|
||||
- Content management
|
||||
- WooCommerce operations
|
||||
- SEO management
|
||||
|
||||
---
|
||||
|
||||
### `/mcp/wordpress-advanced` - Advanced WordPress Endpoint
|
||||
|
||||
**Access**: Admin scope required
|
||||
|
||||
**Tools**: 22 advanced tools
|
||||
- Database operations
|
||||
- Bulk operations
|
||||
- System operations
|
||||
|
||||
**Use Cases**:
|
||||
- Database backup/restore
|
||||
- Bulk content updates
|
||||
- System maintenance
|
||||
|
||||
---
|
||||
|
||||
### `/mcp/gitea` - Gitea Endpoint
|
||||
|
||||
**Access**: Any valid API key (with gitea project)
|
||||
|
||||
**Tools**: 55 Gitea tools
|
||||
- Repository management
|
||||
- Issue tracking
|
||||
- Pull requests
|
||||
- Webhooks
|
||||
|
||||
**Use Cases**:
|
||||
- Git operations
|
||||
- Issue management
|
||||
- PR reviews
|
||||
|
||||
---
|
||||
|
||||
### `/mcp/project/{id}` - Project Endpoint
|
||||
|
||||
**Access**: API key matching project_id
|
||||
|
||||
**Tools**: Plugin tools filtered to single project
|
||||
|
||||
**Example**: `/mcp/project/wordpress_site1`
|
||||
- Only WordPress tools
|
||||
- Site parameter auto-set to `site1`
|
||||
|
||||
**Use Cases**:
|
||||
- Single-site management
|
||||
- Restricted access per client
|
||||
|
||||
---
|
||||
|
||||
## Security Considerations
|
||||
|
||||
### Tool Blacklisting
|
||||
|
||||
System and admin tools are blacklisted from non-admin endpoints:
|
||||
```python
|
||||
tool_blacklist = {
|
||||
"manage_api_keys_create",
|
||||
"manage_api_keys_delete",
|
||||
"manage_api_keys_rotate",
|
||||
"oauth_register_client",
|
||||
"oauth_revoke_client",
|
||||
}
|
||||
```
|
||||
|
||||
### API Key Validation
|
||||
|
||||
Each endpoint validates:
|
||||
1. Token format and validity
|
||||
2. Scope requirements
|
||||
3. Plugin type matching
|
||||
4. Tool-level access
|
||||
|
||||
### Audit Logging
|
||||
|
||||
All tool calls are logged with:
|
||||
- Endpoint path
|
||||
- Tool name
|
||||
- API key ID
|
||||
- Timestamp
|
||||
- Success/failure
|
||||
|
||||
---
|
||||
|
||||
## Running the Server
|
||||
|
||||
### Development
|
||||
```bash
|
||||
# Install dependencies
|
||||
pip install -r requirements.txt
|
||||
|
||||
# Run multi-endpoint server
|
||||
python server_multi.py --port 8000
|
||||
```
|
||||
|
||||
### Production (Docker)
|
||||
```bash
|
||||
# Build
|
||||
docker-compose build
|
||||
|
||||
# Run
|
||||
docker-compose up -d
|
||||
```
|
||||
|
||||
### Environment Variables
|
||||
```bash
|
||||
# Required
|
||||
MASTER_API_KEY=sk-your-secure-key
|
||||
|
||||
# WordPress
|
||||
WORDPRESS_SITE1_URL=https://example.com
|
||||
WORDPRESS_SITE1_USERNAME=admin
|
||||
WORDPRESS_SITE1_PASSWORD=app_password
|
||||
|
||||
# Gitea
|
||||
GITEA_SITE1_URL=https://gitea.example.com
|
||||
GITEA_SITE1_TOKEN=your-token
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Testing Endpoints
|
||||
|
||||
### Check Available Endpoints
|
||||
```bash
|
||||
curl http://localhost:8000/endpoints
|
||||
```
|
||||
|
||||
### Check Health
|
||||
```bash
|
||||
curl http://localhost:8000/health
|
||||
```
|
||||
|
||||
### Test WordPress Endpoint
|
||||
```bash
|
||||
curl -X POST http://localhost:8000/mcp/wordpress \
|
||||
-H "Authorization: Bearer cmp_xxx" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"tool": "wordpress_list_posts", "arguments": {"site": "site1"}}'
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### "Endpoint requires master API key"
|
||||
- Use `/mcp` endpoint with Master Key for admin operations
|
||||
- Or use appropriate plugin endpoint with project API key
|
||||
|
||||
### "API key cannot access this endpoint"
|
||||
- API key project_id must match endpoint plugin type
|
||||
- Example: `wordpress_site1` key works on `/mcp/wordpress`, not `/mcp/gitea`
|
||||
|
||||
### "Access denied to tool"
|
||||
- Tool may be blacklisted for this endpoint
|
||||
- Check if admin scope is required
|
||||
|
||||
---
|
||||
|
||||
## Future Improvements
|
||||
|
||||
- [ ] Per-project endpoints (`/mcp/project/{id}`)
|
||||
- [ ] Dynamic endpoint creation
|
||||
- [ ] Custom tool whitelists
|
||||
- [ ] Endpoint-level rate limiting
|
||||
- [ ] Endpoint usage analytics
|
||||
|
||||
---
|
||||
|
||||
**Last Updated**: 2025-11-24
|
||||
1025
docs/OAUTH_GUIDE.md
Normal file
1025
docs/OAUTH_GUIDE.md
Normal file
File diff suppressed because it is too large
Load Diff
1131
docs/appwrite-plugin-design.md
Normal file
1131
docs/appwrite-plugin-design.md
Normal file
File diff suppressed because it is too large
Load Diff
1420
docs/directus-plugin-design.md
Normal file
1420
docs/directus-plugin-design.md
Normal file
File diff suppressed because it is too large
Load Diff
448
docs/getting-started.md
Normal file
448
docs/getting-started.md
Normal file
@@ -0,0 +1,448 @@
|
||||
# 🚀 Getting Started with MCP Hub
|
||||
|
||||
---
|
||||
|
||||
## Table of Contents
|
||||
|
||||
1. [Prerequisites](#prerequisites)
|
||||
2. [Installation](#installation)
|
||||
3. [Configuration](#configuration)
|
||||
4. [Running the Server](#running-the-server)
|
||||
5. [Testing Your Setup](#testing-your-setup)
|
||||
6. [Using MCP Tools](#using-mcp-tools)
|
||||
7. [Docker Deployment](#docker-deployment)
|
||||
8. [Coolify Deployment](#coolify-deployment)
|
||||
9. [Next Steps](#next-steps)
|
||||
|
||||
---
|
||||
|
||||
## Prerequisites
|
||||
|
||||
Before you begin, ensure you have the following installed:
|
||||
|
||||
### Required
|
||||
|
||||
- **Python 3.11+**: [Download Python](https://www.python.org/downloads/)
|
||||
- **Git**: [Download Git](https://git-scm.com/downloads)
|
||||
|
||||
### Optional (for Docker deployment)
|
||||
|
||||
- **Docker**: [Download Docker Desktop](https://www.docker.com/products/docker-desktop)
|
||||
- **Docker Compose**: Included with Docker Desktop
|
||||
|
||||
### WordPress Requirements
|
||||
|
||||
For each WordPress site you want to manage:
|
||||
|
||||
- WordPress 5.0+
|
||||
- **Application Passwords** enabled (WordPress 5.6+)
|
||||
- **WooCommerce** 3.0+ (if using WooCommerce tools)
|
||||
- **Rank Math** or **Yoast SEO** (if using SEO tools)
|
||||
- HTTPS enabled (recommended)
|
||||
|
||||
---
|
||||
|
||||
## Installation
|
||||
|
||||
### Option 1: Automated Setup (Recommended)
|
||||
|
||||
#### Linux/Mac
|
||||
|
||||
```bash
|
||||
# Clone the repository
|
||||
git clone https://github.com/mcphub/mcphub.git
|
||||
cd mcphub
|
||||
|
||||
# Run setup script
|
||||
chmod +x scripts/setup.sh
|
||||
./scripts/setup.sh
|
||||
```
|
||||
|
||||
#### Windows (PowerShell)
|
||||
|
||||
```powershell
|
||||
# Clone the repository
|
||||
git clone https://github.com/mcphub/mcphub.git
|
||||
cd mcphub
|
||||
|
||||
# Run setup script
|
||||
Set-ExecutionPolicy -ExecutionPolicy RemoteSigned -Scope Process
|
||||
.\scripts\setup.ps1
|
||||
```
|
||||
|
||||
### Option 2: Manual Setup
|
||||
|
||||
```bash
|
||||
# 1. Clone repository
|
||||
git clone https://github.com/mcphub/mcphub.git
|
||||
cd mcphub
|
||||
|
||||
# 2. Create virtual environment
|
||||
python3 -m venv venv
|
||||
|
||||
# 3. Activate virtual environment
|
||||
# Linux/Mac:
|
||||
source venv/bin/activate
|
||||
# Windows:
|
||||
.\venv\Scripts\Activate.ps1
|
||||
|
||||
# 4. Install dependencies
|
||||
pip install --upgrade pip
|
||||
pip install -r requirements.txt
|
||||
|
||||
# 5. Copy environment template
|
||||
cp .env.example .env
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Configuration
|
||||
|
||||
### Step 1: Generate WordPress Application Passwords
|
||||
|
||||
For each WordPress site:
|
||||
|
||||
1. Log in to WordPress admin
|
||||
2. Navigate to: **Users → Your Profile**
|
||||
3. Scroll to **Application Passwords** section
|
||||
4. Enter name: `MCP Server`
|
||||
5. Click **Add New Application Password**
|
||||
6. Copy the generated password (format: `xxxx xxxx xxxx xxxx xxxx xxxx`)
|
||||
|
||||
**Important**: Save this password immediately. You cannot retrieve it later.
|
||||
|
||||
### Step 2: Generate WooCommerce API Keys
|
||||
|
||||
If using WooCommerce tools:
|
||||
|
||||
1. Go to: **WooCommerce → Settings → Advanced → REST API**
|
||||
2. Click **Add Key**
|
||||
3. Fill in:
|
||||
- **Description**: `MCP Server`
|
||||
- **User**: Select admin user
|
||||
- **Permissions**: `Read/Write`
|
||||
4. Click **Generate API Key**
|
||||
5. Copy **Consumer Key** and **Consumer Secret**
|
||||
|
||||
### Step 3: Configure Environment Variables
|
||||
|
||||
Edit `.env` file:
|
||||
|
||||
```bash
|
||||
# Basic Configuration
|
||||
MCP_SERVER_NAME=mcphub
|
||||
MCP_SERVER_VERSION=1.0.0
|
||||
|
||||
# Site 1 - Main WordPress Site
|
||||
WORDPRESS_SITE1_URL=https://example.com
|
||||
WORDPRESS_SITE1_USERNAME=admin
|
||||
WORDPRESS_SITE1_APP_PASSWORD=xxxx xxxx xxxx xxxx xxxx xxxx
|
||||
WORDPRESS_SITE1_WC_CONSUMER_KEY=ck_xxxxxxxxxxxxx
|
||||
WORDPRESS_SITE1_WC_CONSUMER_SECRET=cs_xxxxxxxxxxxxx
|
||||
WORDPRESS_SITE1_ALIAS=mainsite
|
||||
|
||||
# Site 2 - E-commerce Site (Optional)
|
||||
WORDPRESS_SITE2_URL=https://shop.example.com
|
||||
WORDPRESS_SITE2_USERNAME=admin
|
||||
WORDPRESS_SITE2_APP_PASSWORD=yyyy yyyy yyyy yyyy yyyy yyyy
|
||||
WORDPRESS_SITE2_WC_CONSUMER_KEY=ck_yyyyyyyyyyyyy
|
||||
WORDPRESS_SITE2_WC_CONSUMER_SECRET=cs_yyyyyyyyyyyyy
|
||||
WORDPRESS_SITE2_ALIAS=shop
|
||||
|
||||
# Site 3 - Blog Site (Optional)
|
||||
WORDPRESS_SITE3_URL=https://blog.example.com
|
||||
WORDPRESS_SITE3_USERNAME=admin
|
||||
WORDPRESS_SITE3_APP_PASSWORD=zzzz zzzz zzzz zzzz zzzz zzzz
|
||||
WORDPRESS_SITE3_ALIAS=blog
|
||||
|
||||
# Rate Limiting (Optional)
|
||||
RATE_LIMIT_PER_MINUTE=60
|
||||
RATE_LIMIT_PER_HOUR=1000
|
||||
RATE_LIMIT_PER_DAY=10000
|
||||
|
||||
# Logging (Optional)
|
||||
LOG_LEVEL=INFO
|
||||
```
|
||||
|
||||
### Configuration Tips
|
||||
|
||||
- **Site Aliases**: Use friendly names like `mainsite`, `shop`, or `blog`
|
||||
- **Minimal WooCommerce**: Only configure WooCommerce keys if you need e-commerce tools
|
||||
- **Testing**: Start with one site, verify it works, then add more
|
||||
- **Security**: Never commit `.env` file to git
|
||||
|
||||
---
|
||||
|
||||
## Running the Server
|
||||
|
||||
### Development Mode
|
||||
|
||||
```bash
|
||||
# Activate virtual environment (if not already active)
|
||||
source venv/bin/activate # Linux/Mac
|
||||
.\venv\Scripts\Activate.ps1 # Windows
|
||||
|
||||
# Run server
|
||||
python src/main.py
|
||||
|
||||
# Or use the dev script
|
||||
./scripts/dev.sh # Linux/Mac
|
||||
```
|
||||
|
||||
### Verify Server is Running
|
||||
|
||||
Check logs for:
|
||||
|
||||
```
|
||||
INFO: MCP Server initialized
|
||||
INFO: Registered 390 tools
|
||||
INFO: Server ready
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Testing Your Setup
|
||||
|
||||
### Quick Health Check
|
||||
|
||||
Run the test script:
|
||||
|
||||
```bash
|
||||
./scripts/test.sh quick
|
||||
```
|
||||
|
||||
### Manual Testing
|
||||
|
||||
```bash
|
||||
# Run all tests
|
||||
pytest
|
||||
|
||||
# Run with coverage
|
||||
pytest --cov
|
||||
|
||||
# Run specific test
|
||||
pytest tests/test_wordpress_plugin.py
|
||||
```
|
||||
|
||||
### Verify Tool Registration
|
||||
|
||||
Check that tools are registered:
|
||||
|
||||
```bash
|
||||
python -c "
|
||||
from src.main import app
|
||||
tools = app.list_tools()
|
||||
print(f'Total tools: {len(tools)}')
|
||||
"
|
||||
```
|
||||
|
||||
Expected output: `Total tools: 390` (for 3 configured sites)
|
||||
|
||||
---
|
||||
|
||||
## Using MCP Tools
|
||||
|
||||
### Tool Naming Convention
|
||||
|
||||
#### Per-Site Tools (Legacy)
|
||||
```
|
||||
wordpress_{site}_action
|
||||
```
|
||||
Examples:
|
||||
- `wordpress_site1_list_posts`
|
||||
- `wordpress_site2_get_product`
|
||||
- `wordpress_site3_create_page`
|
||||
|
||||
#### Unified Tools (Recommended)
|
||||
```
|
||||
wordpress_action(site="site_id", ...)
|
||||
```
|
||||
Examples:
|
||||
- `wordpress_list_posts(site="site1")`
|
||||
- `wordpress_get_product(site="shop", product_id=123)`
|
||||
- `wordpress_create_page(site="blog", title="Hello", content="...")`
|
||||
|
||||
### Using Site Aliases
|
||||
|
||||
If you configured `WORDPRESS_SITE2_ALIAS=shop`:
|
||||
|
||||
```python
|
||||
# Both work the same
|
||||
wordpress_list_products(site="site2")
|
||||
wordpress_list_products(site="shop")
|
||||
```
|
||||
|
||||
### Example: List Posts
|
||||
|
||||
**Using Per-Site Tools**:
|
||||
```python
|
||||
result = wordpress_site1_list_posts(per_page=10, status="publish")
|
||||
```
|
||||
|
||||
**Using Unified Tools**:
|
||||
```python
|
||||
result = wordpress_list_posts(site="mainsite", per_page=10, status="publish")
|
||||
```
|
||||
|
||||
### Example: Create Product
|
||||
|
||||
```python
|
||||
result = wordpress_create_product(
|
||||
site="shop",
|
||||
name="New Product",
|
||||
type="simple",
|
||||
regular_price="29.99",
|
||||
description="Product description",
|
||||
status="publish"
|
||||
)
|
||||
```
|
||||
|
||||
### Example: Update Page
|
||||
|
||||
```python
|
||||
result = wordpress_update_page(
|
||||
site="blog",
|
||||
page_id=42,
|
||||
title="Updated Title",
|
||||
content="<p>Updated content</p>",
|
||||
status="publish"
|
||||
)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Docker Deployment
|
||||
|
||||
### Quick Start
|
||||
|
||||
```bash
|
||||
# Deploy with Docker
|
||||
./scripts/deploy.sh
|
||||
|
||||
# Or manually
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
### Docker Commands
|
||||
|
||||
```bash
|
||||
# View logs
|
||||
docker compose logs -f
|
||||
|
||||
# Check status
|
||||
docker compose ps
|
||||
|
||||
# Restart
|
||||
docker compose restart
|
||||
|
||||
# Stop
|
||||
docker compose down
|
||||
|
||||
# Rebuild
|
||||
docker compose up --build -d
|
||||
```
|
||||
|
||||
### Health Check
|
||||
|
||||
```bash
|
||||
# Check container health
|
||||
docker compose ps
|
||||
|
||||
# Test API endpoint
|
||||
curl http://localhost:8000/health
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Coolify Deployment
|
||||
|
||||
### Prerequisites
|
||||
|
||||
- Coolify instance running
|
||||
- Docker registry access (optional)
|
||||
|
||||
### Step 1: Create New Resource
|
||||
|
||||
1. Log in to Coolify dashboard
|
||||
2. Click **+ New Resource**
|
||||
3. Select **Docker Compose**
|
||||
|
||||
### Step 2: Configure Repository
|
||||
|
||||
1. **Git Repository**: `https://github.com/mcphub/mcphub.git`
|
||||
2. **Branch**: `main`
|
||||
3. **Build Pack**: `Docker Compose`
|
||||
|
||||
### Step 3: Configure Environment Variables
|
||||
|
||||
Add all required environment variables from `.env.example`:
|
||||
|
||||
```
|
||||
WORDPRESS_SITE1_URL=https://example.com
|
||||
WORDPRESS_SITE1_USERNAME=admin
|
||||
WORDPRESS_SITE1_APP_PASSWORD=xxxx xxxx xxxx xxxx xxxx xxxx
|
||||
...
|
||||
```
|
||||
|
||||
### Step 4: Configure Health Check
|
||||
|
||||
- **Path**: `/health`
|
||||
- **Port**: `8000`
|
||||
- **Interval**: `30s`
|
||||
- **Timeout**: `10s`
|
||||
- **Retries**: `3`
|
||||
|
||||
### Step 5: Deploy
|
||||
|
||||
1. Click **Deploy**
|
||||
2. Wait for build to complete
|
||||
3. Check logs for successful startup
|
||||
|
||||
### Coolify-Specific Configuration
|
||||
|
||||
Add to `docker-compose.yml` if needed:
|
||||
|
||||
```yaml
|
||||
services:
|
||||
mcp-server:
|
||||
labels:
|
||||
- "coolify.managed=true"
|
||||
- "coolify.port=8000"
|
||||
- "coolify.health_check=/health"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Next Steps
|
||||
|
||||
### 1. Explore Available Tools
|
||||
|
||||
Check the [README](../README.md) for complete tool listing.
|
||||
|
||||
### 2. Configure Monitoring
|
||||
|
||||
- View health metrics: Use `check_all_projects_health` tool
|
||||
- Check rate limits: Use `get_rate_limit_stats` tool
|
||||
- Review audit logs: `tail -f logs/audit.log`
|
||||
|
||||
### 3. Customize Configuration
|
||||
|
||||
- Adjust rate limits in `.env`
|
||||
- Configure log levels
|
||||
- Add more WordPress sites
|
||||
|
||||
### 4. Read Documentation
|
||||
|
||||
- [Troubleshooting Guide](troubleshooting.md)
|
||||
- [Security Policy](../SECURITY.md)
|
||||
- [Contributing Guide](../CONTRIBUTING.md)
|
||||
|
||||
### 5. Join Community
|
||||
|
||||
- **Repository**: [github.com/mcphub/mcphub](https://github.com/mcphub/mcphub)
|
||||
- **Contact**: hello@mcphub.dev
|
||||
- **Website**: [mcphub.dev](https://mcphub.dev)
|
||||
|
||||
---
|
||||
|
||||
---
|
||||
669
docs/n8n-plugin-design.md
Normal file
669
docs/n8n-plugin-design.md
Normal file
@@ -0,0 +1,669 @@
|
||||
# n8n Automation Plugin Design
|
||||
|
||||
> **Phase I: n8n Automation Plugin**
|
||||
> **Priority**: High (Upgraded from Medium)
|
||||
> **Estimated Tools**: 55-60
|
||||
|
||||
---
|
||||
|
||||
## Overview
|
||||
|
||||
The n8n plugin provides comprehensive automation workflow management through n8n's REST API. This enables AI assistants to create, manage, execute, and monitor automation workflows programmatically.
|
||||
|
||||
### Key Capabilities
|
||||
- Complete workflow lifecycle management (CRUD + activate/deactivate/execute)
|
||||
- Execution monitoring and history
|
||||
- Credential management for third-party integrations
|
||||
- Project and user management (Enterprise/Pro features)
|
||||
- Environment variables management
|
||||
- Security audit and source control integration
|
||||
|
||||
---
|
||||
|
||||
## Architecture
|
||||
|
||||
### Plugin Structure
|
||||
|
||||
```
|
||||
plugins/n8n/
|
||||
├── __init__.py
|
||||
├── plugin.py # N8nPlugin class
|
||||
├── client.py # N8nClient (REST API communication)
|
||||
└── handlers/
|
||||
├── __init__.py
|
||||
├── workflows.py # Workflow management (14 tools)
|
||||
├── executions.py # Execution monitoring (8 tools)
|
||||
├── credentials.py # Credential management (8 tools)
|
||||
├── tags.py # Tag management (6 tools)
|
||||
├── users.py # User management (6 tools)
|
||||
├── projects.py # Project management (8 tools)
|
||||
├── variables.py # Variable management (6 tools)
|
||||
└── system.py # Audit & Source Control (4 tools)
|
||||
```
|
||||
|
||||
### Authentication
|
||||
|
||||
n8n uses API Key authentication via `X-N8N-API-KEY` header.
|
||||
|
||||
```python
|
||||
# Environment Variables
|
||||
N8N_SITE1_URL=https://n8n.example.com
|
||||
N8N_SITE1_API_KEY=your-api-key
|
||||
N8N_SITE1_ALIAS=myautomation # Optional friendly name
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Tool Specifications (55-60 Tools)
|
||||
|
||||
### 1. Workflows Handler (14 tools)
|
||||
|
||||
| Tool | Method | Scope | Description |
|
||||
|------|--------|-------|-------------|
|
||||
| `list_workflows` | GET | read | List all workflows with filters (active, tags, name) |
|
||||
| `get_workflow` | GET | read | Get workflow details by ID |
|
||||
| `create_workflow` | POST | write | Create new workflow from JSON definition |
|
||||
| `update_workflow` | PUT | write | Update existing workflow |
|
||||
| `delete_workflow` | DELETE | admin | Delete a workflow |
|
||||
| `activate_workflow` | POST | write | Activate a workflow |
|
||||
| `deactivate_workflow` | POST | write | Deactivate a workflow |
|
||||
| `execute_workflow` | POST | write | Manually execute a workflow |
|
||||
| `execute_workflow_with_data` | POST | write | Execute with custom input data |
|
||||
| `duplicate_workflow` | POST | write | Duplicate an existing workflow |
|
||||
| `export_workflow` | GET | read | Export workflow as JSON |
|
||||
| `import_workflow` | POST | write | Import workflow from JSON |
|
||||
| `get_workflow_tags` | GET | read | Get tags assigned to a workflow |
|
||||
| `set_workflow_tags` | PUT | write | Assign tags to a workflow |
|
||||
|
||||
#### Tool Specification Example
|
||||
|
||||
```python
|
||||
{
|
||||
"name": "list_workflows",
|
||||
"method_name": "list_workflows",
|
||||
"description": "List all n8n workflows with optional filters. Returns workflow ID, name, active status, and metadata.",
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"active": {
|
||||
"type": "boolean",
|
||||
"description": "Filter by active/inactive status"
|
||||
},
|
||||
"tags": {
|
||||
"anyOf": [{"type": "string"}, {"type": "null"}],
|
||||
"description": "Filter by tag name(s), comma-separated"
|
||||
},
|
||||
"name": {
|
||||
"anyOf": [{"type": "string"}, {"type": "null"}],
|
||||
"description": "Filter by workflow name (partial match)"
|
||||
},
|
||||
"limit": {
|
||||
"type": "integer",
|
||||
"description": "Maximum workflows to return",
|
||||
"default": 50,
|
||||
"minimum": 1,
|
||||
"maximum": 250
|
||||
},
|
||||
"cursor": {
|
||||
"anyOf": [{"type": "string"}, {"type": "null"}],
|
||||
"description": "Pagination cursor for next page"
|
||||
}
|
||||
}
|
||||
},
|
||||
"scope": "read"
|
||||
}
|
||||
```
|
||||
|
||||
```python
|
||||
{
|
||||
"name": "execute_workflow",
|
||||
"method_name": "execute_workflow",
|
||||
"description": "Manually execute a workflow and return execution ID. Use get_execution to check status.",
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"workflow_id": {
|
||||
"type": "string",
|
||||
"description": "Workflow ID to execute",
|
||||
"minLength": 1
|
||||
},
|
||||
"wait": {
|
||||
"type": "boolean",
|
||||
"description": "Wait for execution to complete (max 5 minutes)",
|
||||
"default": False
|
||||
}
|
||||
},
|
||||
"required": ["workflow_id"]
|
||||
},
|
||||
"scope": "write"
|
||||
}
|
||||
```
|
||||
|
||||
```python
|
||||
{
|
||||
"name": "execute_workflow_with_data",
|
||||
"method_name": "execute_workflow_with_data",
|
||||
"description": "Execute workflow with custom input data. Useful for workflows with webhook/manual triggers.",
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"workflow_id": {
|
||||
"type": "string",
|
||||
"description": "Workflow ID to execute",
|
||||
"minLength": 1
|
||||
},
|
||||
"data": {
|
||||
"type": "object",
|
||||
"description": "Input data to pass to workflow trigger node"
|
||||
},
|
||||
"wait": {
|
||||
"type": "boolean",
|
||||
"description": "Wait for execution to complete",
|
||||
"default": False
|
||||
}
|
||||
},
|
||||
"required": ["workflow_id", "data"]
|
||||
},
|
||||
"scope": "write"
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 2. Executions Handler (8 tools)
|
||||
|
||||
| Tool | Method | Scope | Description |
|
||||
|------|--------|-------|-------------|
|
||||
| `list_executions` | GET | read | List workflow executions with filters |
|
||||
| `get_execution` | GET | read | Get execution details and data |
|
||||
| `delete_execution` | DELETE | write | Delete a single execution |
|
||||
| `delete_executions` | DELETE | write | Bulk delete executions |
|
||||
| `stop_execution` | POST | write | Stop a running execution |
|
||||
| `retry_execution` | POST | write | Retry a failed execution |
|
||||
| `get_execution_data` | GET | read | Get full execution data including node outputs |
|
||||
| `wait_for_execution` | GET | read | Poll until execution completes |
|
||||
|
||||
#### Tool Specification Examples
|
||||
|
||||
```python
|
||||
{
|
||||
"name": "list_executions",
|
||||
"method_name": "list_executions",
|
||||
"description": "List workflow executions with filters by status, workflow, date range. Returns execution history.",
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"workflow_id": {
|
||||
"anyOf": [{"type": "string"}, {"type": "null"}],
|
||||
"description": "Filter by workflow ID"
|
||||
},
|
||||
"status": {
|
||||
"anyOf": [{"type": "string", "enum": ["success", "error", "waiting", "running", "new"]}, {"type": "null"}],
|
||||
"description": "Filter by execution status"
|
||||
},
|
||||
"include_data": {
|
||||
"type": "boolean",
|
||||
"description": "Include full execution data",
|
||||
"default": False
|
||||
},
|
||||
"limit": {
|
||||
"type": "integer",
|
||||
"description": "Maximum results",
|
||||
"default": 20,
|
||||
"minimum": 1,
|
||||
"maximum": 250
|
||||
},
|
||||
"cursor": {
|
||||
"anyOf": [{"type": "string"}, {"type": "null"}],
|
||||
"description": "Pagination cursor"
|
||||
}
|
||||
}
|
||||
},
|
||||
"scope": "read"
|
||||
}
|
||||
```
|
||||
|
||||
```python
|
||||
{
|
||||
"name": "get_execution",
|
||||
"method_name": "get_execution",
|
||||
"description": "Get detailed information about a specific execution including status, timing, and optionally full data.",
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"execution_id": {
|
||||
"type": "string",
|
||||
"description": "Execution ID",
|
||||
"minLength": 1
|
||||
},
|
||||
"include_data": {
|
||||
"type": "boolean",
|
||||
"description": "Include full node execution data",
|
||||
"default": True
|
||||
}
|
||||
},
|
||||
"required": ["execution_id"]
|
||||
},
|
||||
"scope": "read"
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 3. Credentials Handler (8 tools)
|
||||
|
||||
| Tool | Method | Scope | Description |
|
||||
|------|--------|-------|-------------|
|
||||
| `list_credentials` | GET | read | List all credentials (without sensitive data) |
|
||||
| `get_credential` | GET | read | Get credential metadata |
|
||||
| `create_credential` | POST | admin | Create new credential |
|
||||
| `update_credential` | PUT | admin | Update credential |
|
||||
| `delete_credential` | DELETE | admin | Delete a credential |
|
||||
| `get_credential_schema` | GET | read | Get schema for credential type |
|
||||
| `list_credential_types` | GET | read | List available credential types |
|
||||
| `transfer_credential` | POST | admin | Transfer credential to another project |
|
||||
|
||||
#### Tool Specification Examples
|
||||
|
||||
```python
|
||||
{
|
||||
"name": "list_credentials",
|
||||
"method_name": "list_credentials",
|
||||
"description": "List all stored credentials. Returns metadata only (no sensitive values).",
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"limit": {
|
||||
"type": "integer",
|
||||
"description": "Maximum results",
|
||||
"default": 100,
|
||||
"minimum": 1,
|
||||
"maximum": 250
|
||||
},
|
||||
"cursor": {
|
||||
"anyOf": [{"type": "string"}, {"type": "null"}],
|
||||
"description": "Pagination cursor"
|
||||
}
|
||||
}
|
||||
},
|
||||
"scope": "read"
|
||||
}
|
||||
```
|
||||
|
||||
```python
|
||||
{
|
||||
"name": "create_credential",
|
||||
"method_name": "create_credential",
|
||||
"description": "Create a new credential for use in workflows. Use get_credential_schema to see required fields.",
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"name": {
|
||||
"type": "string",
|
||||
"description": "Credential display name",
|
||||
"minLength": 1
|
||||
},
|
||||
"type": {
|
||||
"type": "string",
|
||||
"description": "Credential type (e.g., 'githubApi', 'slackApi')",
|
||||
"minLength": 1
|
||||
},
|
||||
"data": {
|
||||
"type": "object",
|
||||
"description": "Credential data matching the schema for this type"
|
||||
}
|
||||
},
|
||||
"required": ["name", "type", "data"]
|
||||
},
|
||||
"scope": "admin"
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 4. Tags Handler (6 tools)
|
||||
|
||||
| Tool | Method | Scope | Description |
|
||||
|------|--------|-------|-------------|
|
||||
| `list_tags` | GET | read | List all tags |
|
||||
| `get_tag` | GET | read | Get tag details |
|
||||
| `create_tag` | POST | write | Create a new tag |
|
||||
| `update_tag` | PUT | write | Update tag name |
|
||||
| `delete_tag` | DELETE | write | Delete a tag |
|
||||
| `delete_tags` | DELETE | write | Bulk delete tags |
|
||||
|
||||
---
|
||||
|
||||
### 5. Users Handler (6 tools)
|
||||
|
||||
| Tool | Method | Scope | Description |
|
||||
|------|--------|-------|-------------|
|
||||
| `list_users` | GET | admin | List all users |
|
||||
| `get_user` | GET | admin | Get user details |
|
||||
| `create_user` | POST | admin | Invite/create new user |
|
||||
| `delete_user` | DELETE | admin | Delete a user |
|
||||
| `change_user_role` | PUT | admin | Change user's global role |
|
||||
| `get_current_user` | GET | read | Get current authenticated user |
|
||||
|
||||
---
|
||||
|
||||
### 6. Projects Handler (8 tools) - Enterprise/Pro
|
||||
|
||||
| Tool | Method | Scope | Description |
|
||||
|------|--------|-------|-------------|
|
||||
| `list_projects` | GET | read | List all projects |
|
||||
| `get_project` | GET | read | Get project details |
|
||||
| `create_project` | POST | admin | Create a new project |
|
||||
| `update_project` | PUT | admin | Update project metadata |
|
||||
| `delete_project` | DELETE | admin | Delete a project |
|
||||
| `add_project_users` | POST | admin | Add users to project with roles |
|
||||
| `change_project_user_role` | PUT | admin | Change user's role in project |
|
||||
| `remove_project_user` | DELETE | admin | Remove user from project |
|
||||
|
||||
---
|
||||
|
||||
### 7. Variables Handler (6 tools)
|
||||
|
||||
| Tool | Method | Scope | Description |
|
||||
|------|--------|-------|-------------|
|
||||
| `list_variables` | GET | read | List all environment variables |
|
||||
| `get_variable` | GET | read | Get variable value by key |
|
||||
| `create_variable` | POST | admin | Create new variable |
|
||||
| `update_variable` | PUT | admin | Update variable value |
|
||||
| `delete_variable` | DELETE | admin | Delete a variable |
|
||||
| `set_variables` | POST | admin | Bulk set multiple variables |
|
||||
|
||||
---
|
||||
|
||||
### 8. System Handler (4 tools)
|
||||
|
||||
| Tool | Method | Scope | Description |
|
||||
|------|--------|-------|-------------|
|
||||
| `run_security_audit` | POST | admin | Run security audit on instance |
|
||||
| `source_control_pull` | POST | admin | Pull workflows from Git repository |
|
||||
| `get_instance_info` | GET | read | Get n8n instance version and status |
|
||||
| `health_check` | GET | read | Check n8n instance health |
|
||||
|
||||
---
|
||||
|
||||
## API Client Design
|
||||
|
||||
### N8nClient Class
|
||||
|
||||
```python
|
||||
class N8nClient:
|
||||
"""
|
||||
n8n REST API client for HTTP communication.
|
||||
|
||||
Handles authentication, request formatting, and error handling
|
||||
for all n8n API endpoints.
|
||||
"""
|
||||
|
||||
def __init__(self, site_url: str, api_key: str):
|
||||
"""
|
||||
Initialize n8n API client.
|
||||
|
||||
Args:
|
||||
site_url: n8n instance URL (e.g., https://n8n.example.com)
|
||||
api_key: n8n API key for authentication
|
||||
"""
|
||||
self.site_url = site_url.rstrip('/')
|
||||
self.api_base = f"{self.site_url}/api/v1"
|
||||
self.api_key = api_key
|
||||
|
||||
def _get_headers(self) -> Dict[str, str]:
|
||||
"""Get request headers with API key authentication."""
|
||||
return {
|
||||
"Content-Type": "application/json",
|
||||
"Accept": "application/json",
|
||||
"X-N8N-API-KEY": self.api_key
|
||||
}
|
||||
|
||||
async def request(
|
||||
self,
|
||||
method: str,
|
||||
endpoint: str,
|
||||
params: Optional[Dict] = None,
|
||||
json_data: Optional[Dict] = None
|
||||
) -> Any:
|
||||
"""Make authenticated request to n8n REST API."""
|
||||
# Implementation similar to GiteaClient
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Endpoint Reference
|
||||
|
||||
### Base URL
|
||||
```
|
||||
{N8N_URL}/api/v1
|
||||
```
|
||||
|
||||
### Workflows
|
||||
```
|
||||
GET /workflows - List workflows
|
||||
POST /workflows - Create workflow
|
||||
GET /workflows/{id} - Get workflow
|
||||
PUT /workflows/{id} - Update workflow
|
||||
DELETE /workflows/{id} - Delete workflow
|
||||
POST /workflows/{id}/activate - Activate workflow
|
||||
POST /workflows/{id}/deactivate - Deactivate workflow
|
||||
POST /workflows/{id}/run - Execute workflow
|
||||
```
|
||||
|
||||
### Executions
|
||||
```
|
||||
GET /executions - List executions
|
||||
GET /executions/{id} - Get execution
|
||||
DELETE /executions/{id} - Delete execution
|
||||
```
|
||||
|
||||
### Credentials
|
||||
```
|
||||
GET /credentials - List credentials
|
||||
POST /credentials - Create credential
|
||||
GET /credentials/{id} - Get credential
|
||||
DELETE /credentials/{id} - Delete credential
|
||||
GET /credentials/schema/{type} - Get credential schema
|
||||
POST /credentials/{id}/transfer - Transfer credential
|
||||
```
|
||||
|
||||
### Tags
|
||||
```
|
||||
GET /tags - List tags
|
||||
POST /tags - Create tag
|
||||
GET /tags/{id} - Get tag
|
||||
PUT /tags/{id} - Update tag
|
||||
DELETE /tags/{id} - Delete tag
|
||||
```
|
||||
|
||||
### Users
|
||||
```
|
||||
GET /users - List users
|
||||
POST /users - Create/invite user
|
||||
GET /users/{id} - Get user
|
||||
DELETE /users/{id} - Delete user
|
||||
PATCH /users/{id}/role - Change user role
|
||||
```
|
||||
|
||||
### Projects (Enterprise/Pro)
|
||||
```
|
||||
GET /projects - List projects
|
||||
POST /projects - Create project
|
||||
GET /projects/{id} - Get project
|
||||
PUT /projects/{id} - Update project
|
||||
DELETE /projects/{id} - Delete project
|
||||
POST /projects/{id}/users - Add users to project
|
||||
```
|
||||
|
||||
### Variables
|
||||
```
|
||||
GET /variables - List variables
|
||||
POST /variables - Create variable
|
||||
GET /variables/{key} - Get variable
|
||||
PUT /variables/{key} - Update variable
|
||||
DELETE /variables/{key} - Delete variable
|
||||
```
|
||||
|
||||
### System
|
||||
```
|
||||
POST /audit - Run security audit
|
||||
POST /source-control/pull - Pull from Git
|
||||
GET /health - Health check
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Multi-Endpoint Integration
|
||||
|
||||
### Endpoint Configuration
|
||||
|
||||
```python
|
||||
# core/endpoints/config.py
|
||||
ENDPOINT_CONFIGS = {
|
||||
# ... existing configs ...
|
||||
|
||||
"n8n": EndpointConfig(
|
||||
path="/n8n/mcp",
|
||||
name="n8n Automation",
|
||||
plugin_types=["n8n"],
|
||||
description="Workflow automation management",
|
||||
tools_count=55
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
### Per-Project Endpoint
|
||||
|
||||
```
|
||||
/project/{alias}/mcp - Site-specific n8n endpoint
|
||||
/project/myautomation/mcp
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Environment Configuration
|
||||
|
||||
```bash
|
||||
# Single n8n instance
|
||||
N8N_SITE1_URL=https://n8n.example.com
|
||||
N8N_SITE1_API_KEY=your-api-key-here
|
||||
N8N_SITE1_ALIAS=automation
|
||||
|
||||
# Multiple instances
|
||||
N8N_SITE2_URL=https://n8n-staging.example.com
|
||||
N8N_SITE2_API_KEY=staging-api-key
|
||||
N8N_SITE2_ALIAS=automation-staging
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Use Cases
|
||||
|
||||
### 1. Workflow Management
|
||||
```
|
||||
User: "Create a new workflow that sends Slack notifications when a GitHub issue is created"
|
||||
|
||||
AI Assistant:
|
||||
1. create_workflow with workflow JSON definition
|
||||
2. activate_workflow to enable it
|
||||
3. list_credentials to verify Slack/GitHub credentials exist
|
||||
```
|
||||
|
||||
### 2. Execution Monitoring
|
||||
```
|
||||
User: "Show me failed executions from the last 24 hours"
|
||||
|
||||
AI Assistant:
|
||||
1. list_executions with status="error" filter
|
||||
2. get_execution for each to see error details
|
||||
3. Provide summary and suggestions
|
||||
```
|
||||
|
||||
### 3. Credential Management
|
||||
```
|
||||
User: "Set up new API credentials for OpenAI"
|
||||
|
||||
AI Assistant:
|
||||
1. get_credential_schema for "openAiApi" type
|
||||
2. create_credential with required fields
|
||||
3. Confirm credential is ready for use
|
||||
```
|
||||
|
||||
### 4. Security Audit
|
||||
```
|
||||
User: "Run a security audit on our n8n instance"
|
||||
|
||||
AI Assistant:
|
||||
1. run_security_audit
|
||||
2. Parse and summarize findings
|
||||
3. Provide recommendations
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Implementation Priority
|
||||
|
||||
### Phase 1 (Core - Must Have)
|
||||
1. Workflows handler (14 tools)
|
||||
2. Executions handler (8 tools)
|
||||
3. Client implementation
|
||||
4. Plugin class
|
||||
|
||||
### Phase 2 (Extended - Should Have)
|
||||
1. Credentials handler (8 tools)
|
||||
2. Tags handler (6 tools)
|
||||
3. Variables handler (6 tools)
|
||||
|
||||
### Phase 3 (Advanced - Nice to Have)
|
||||
1. Users handler (6 tools)
|
||||
2. Projects handler (8 tools)
|
||||
3. System handler (4 tools)
|
||||
|
||||
---
|
||||
|
||||
## Security Considerations
|
||||
|
||||
1. **API Key Protection**: Store API keys securely, never log them
|
||||
2. **Credential Handling**: Never expose credential values in responses
|
||||
3. **Scope Enforcement**: Admin-level tools require admin scope
|
||||
4. **Rate Limiting**: Respect n8n API rate limits
|
||||
5. **Audit Logging**: Log all write/admin operations
|
||||
|
||||
---
|
||||
|
||||
## Testing Strategy
|
||||
|
||||
1. **Unit Tests**: Test each handler function independently
|
||||
2. **Integration Tests**: Test against a local n8n instance
|
||||
3. **Mock Tests**: Use mocked API responses for CI/CD
|
||||
|
||||
---
|
||||
|
||||
## Documentation Links
|
||||
|
||||
- [n8n Public REST API](https://docs.n8n.io/api/)
|
||||
- [n8n API Reference](https://docs.n8n.io/api/api-reference/)
|
||||
- [n8n Authentication](https://docs.n8n.io/api/authentication/)
|
||||
|
||||
---
|
||||
|
||||
## Summary
|
||||
|
||||
| Category | Tools | Priority |
|
||||
|----------|-------|----------|
|
||||
| Workflows | 14 | Phase 1 |
|
||||
| Executions | 8 | Phase 1 |
|
||||
| Credentials | 8 | Phase 2 |
|
||||
| Tags | 6 | Phase 2 |
|
||||
| Variables | 6 | Phase 2 |
|
||||
| Users | 6 | Phase 3 |
|
||||
| Projects | 8 | Phase 3 |
|
||||
| System | 4 | Phase 3 |
|
||||
| **Total** | **60** | - |
|
||||
|
||||
---
|
||||
|
||||
**Created**: 2025-11-27
|
||||
**Author**: AI Assistant
|
||||
**Status**: Design Document - Awaiting Implementation
|
||||
922
docs/openpanel-plugin-design.md
Normal file
922
docs/openpanel-plugin-design.md
Normal file
@@ -0,0 +1,922 @@
|
||||
# OpenPanel Plugin Design - Phase H
|
||||
|
||||
> **MCP Plugin for OpenPanel Analytics Management (Self-Hosted)**
|
||||
|
||||
**Version**: v1.0.0 (Design)
|
||||
**Priority**: Highest
|
||||
**Estimated Tools**: 72-78
|
||||
|
||||
---
|
||||
|
||||
## Why OpenPanel over Plausible?
|
||||
|
||||
### Comparison Summary
|
||||
|
||||
| Feature | Plausible | OpenPanel | Winner |
|
||||
|---------|-----------|-----------|--------|
|
||||
| Web Analytics | Yes | Yes | Tie |
|
||||
| Product Analytics | No | Yes (Funnels, Cohorts) | OpenPanel |
|
||||
| User Profiles | No | Yes | OpenPanel |
|
||||
| A/B Testing | No | Yes | OpenPanel |
|
||||
| Retention Analysis | No | Yes | OpenPanel |
|
||||
| Session Recording | No | Yes | OpenPanel |
|
||||
| Multi-platform | Web only | Web, Mobile, Server | OpenPanel |
|
||||
| API Completeness | Stats API only | Track + Export + Management | OpenPanel |
|
||||
| Custom Dashboards | Limited | Full flexibility | OpenPanel |
|
||||
| Self-Hosted | Yes | Yes | Tie |
|
||||
| Privacy (GDPR) | Yes | Yes | Tie |
|
||||
|
||||
**Decision**: **OpenPanel** - More comprehensive analytics with Product Analytics features that Plausible lacks.
|
||||
|
||||
### Key OpenPanel Advantages
|
||||
|
||||
1. **Product Analytics** - Funnels, cohorts, user profiles, retention
|
||||
2. **A/B Testing** - Built-in variant testing
|
||||
3. **Multi-platform** - Web, iOS, Android, Server-side SDKs
|
||||
4. **Comprehensive API** - Track, Export, and Management APIs
|
||||
5. **Custom Dashboards** - Flexible chart creation
|
||||
6. **Self-Hosted on Coolify** - Full data control
|
||||
|
||||
Sources:
|
||||
- [OpenPanel](https://openpanel.dev/)
|
||||
- [OpenPanel GitHub](https://github.com/Openpanel-dev/openpanel)
|
||||
- [Coolify OpenPanel Docs](https://coolify.io/docs/services/openpanel)
|
||||
|
||||
---
|
||||
|
||||
## Overview
|
||||
|
||||
Plugin for managing **OpenPanel Self-Hosted** instances deployed on Coolify.
|
||||
|
||||
### Architecture Overview
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────────┐
|
||||
│ OpenPanel Self-Hosted Architecture │
|
||||
├─────────────────────────────────────────────────────────────────┤
|
||||
│ │
|
||||
│ ┌─────────────────────────────────────────────────────────┐ │
|
||||
│ │ OpenPanel Stack │ │
|
||||
│ ├─────────────────────────────────────────────────────────┤ │
|
||||
│ │ │ │
|
||||
│ │ Next.js Dashboard (:3000) │ │
|
||||
│ │ └── tRPC API (internal management) │ │
|
||||
│ │ │ │
|
||||
│ │ Fastify Event API (:3333) │ │
|
||||
│ │ └── /track - Event ingestion │ │
|
||||
│ │ └── /export - Data export │ │
|
||||
│ │ │ │
|
||||
│ │ PostgreSQL - Metadata & config │ │
|
||||
│ │ ClickHouse - Event storage (high-volume) │ │
|
||||
│ │ Redis - Cache, pub/sub, queues │ │
|
||||
│ │ BullMQ - Job processing │ │
|
||||
│ │ │ │
|
||||
│ └─────────────────────────────────────────────────────────┘ │
|
||||
│ │
|
||||
└─────────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Authentication
|
||||
|
||||
### API Authentication
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────────┐
|
||||
│ OpenPanel Authentication │
|
||||
├─────────────────────────────────────────────────────────────────┤
|
||||
│ │
|
||||
│ Required Headers: │
|
||||
│ ├── openpanel-client-id: YOUR_CLIENT_ID │
|
||||
│ └── openpanel-client-secret: YOUR_CLIENT_SECRET │
|
||||
│ │
|
||||
│ Client Modes: │
|
||||
│ ├── write - Can send events (tracking) │
|
||||
│ ├── read - Can read data (export) │
|
||||
│ └── root - Full access (management + read + write) │
|
||||
│ │
|
||||
│ For Self-Hosted: Create clients via Dashboard or API │
|
||||
│ │
|
||||
└─────────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### Environment Variables
|
||||
|
||||
```bash
|
||||
# OpenPanel Self-Hosted Instance (Required)
|
||||
OPENPANEL_SITE1_URL=https://analytics.example.com
|
||||
OPENPANEL_SITE1_CLIENT_ID=your-client-id
|
||||
OPENPANEL_SITE1_CLIENT_SECRET=your-client-secret
|
||||
OPENPANEL_SITE1_PROJECT_ID=your-project-id # Required for Export/Read APIs
|
||||
OPENPANEL_SITE1_ALIAS=myanalytics
|
||||
|
||||
# Optional: Multiple Instances
|
||||
OPENPANEL_SITE2_URL=https://analytics-staging.example.com
|
||||
OPENPANEL_SITE2_CLIENT_ID=client-id-staging
|
||||
OPENPANEL_SITE2_CLIENT_SECRET=client-secret-staging
|
||||
OPENPANEL_SITE2_PROJECT_ID=staging-project-id
|
||||
OPENPANEL_SITE2_ALIAS=staging
|
||||
```
|
||||
|
||||
**Finding Your Project ID:**
|
||||
1. Log in to your OpenPanel Dashboard
|
||||
2. Go to Project Settings
|
||||
3. Copy the Project ID
|
||||
|
||||
**Note:**
|
||||
- `CLIENT_ID` and `CLIENT_SECRET` are used for authentication
|
||||
- `PROJECT_ID` is required for Export/Read APIs (get_event_count, export_events, etc.)
|
||||
- Track APIs (identify_user, track_event) work without PROJECT_ID
|
||||
|
||||
---
|
||||
|
||||
## API Endpoints
|
||||
|
||||
### Track API (Fastify - /api)
|
||||
|
||||
Primary endpoint for event ingestion:
|
||||
|
||||
```
|
||||
POST /track
|
||||
Headers:
|
||||
openpanel-client-id: YOUR_CLIENT_ID
|
||||
openpanel-client-secret: YOUR_CLIENT_SECRET
|
||||
x-client-ip: CLIENT_IP (optional, for geo)
|
||||
user-agent: USER_AGENT (optional, for device info)
|
||||
```
|
||||
|
||||
**Operation Types:**
|
||||
|
||||
| Type | Description | Use Case |
|
||||
|------|-------------|----------|
|
||||
| `track` | Track custom event | Page view, button click, purchase |
|
||||
| `identify` | Identify user | Set user profile properties |
|
||||
| `increment` | Increment property | Visit count, purchase count |
|
||||
| `decrement` | Decrement property | Credits used, inventory |
|
||||
| `alias` | Alias profile ID | Link anonymous to authenticated |
|
||||
|
||||
### Export API (Fastify - /export)
|
||||
|
||||
```
|
||||
GET /export/events
|
||||
- Retrieve raw event data
|
||||
- Filters: projectId, profileId, event, start, end
|
||||
- Pagination: page, limit
|
||||
- Includes: profile, meta
|
||||
|
||||
GET /export/charts
|
||||
- Retrieve aggregated chart data
|
||||
- Events with breakdowns
|
||||
- Intervals: minute, hour, day, week, month
|
||||
- Ranges: 30min, today, 7d, 30d, 6m, 12m, etc.
|
||||
```
|
||||
|
||||
### Dashboard tRPC API (Next.js - /api/trpc)
|
||||
|
||||
Internal API for dashboard management:
|
||||
|
||||
```
|
||||
Projects:
|
||||
- project.list, project.get, project.create, project.update, project.delete
|
||||
|
||||
Dashboards:
|
||||
- dashboard.list, dashboard.get, dashboard.create, dashboard.update, dashboard.delete
|
||||
|
||||
Charts:
|
||||
- chart.create, chart.update, chart.delete
|
||||
|
||||
Clients:
|
||||
- client.list, client.create, client.delete, client.regenerate
|
||||
|
||||
Funnels:
|
||||
- funnel.list, funnel.get, funnel.create, funnel.update, funnel.delete
|
||||
|
||||
Reports:
|
||||
- report.overview, report.retention, report.paths
|
||||
|
||||
Users/Profiles:
|
||||
- profile.list, profile.get, profile.events
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Plugin Architecture
|
||||
|
||||
### Project Structure
|
||||
|
||||
```
|
||||
plugins/openpanel/
|
||||
├── __init__.py # Export: OpenPanelPlugin, OpenPanelClient
|
||||
├── plugin.py # Main OpenPanelPlugin class
|
||||
├── client.py # OpenPanelClient (unified client)
|
||||
└── handlers/
|
||||
├── __init__.py
|
||||
├── events.py # Event tracking (10 tools)
|
||||
├── export.py # Data export (10 tools)
|
||||
├── projects.py # Project management (8 tools)
|
||||
├── dashboards.py # Dashboard management (10 tools)
|
||||
├── funnels.py # Funnel analytics (8 tools)
|
||||
├── profiles.py # User profiles (8 tools)
|
||||
├── clients.py # API client management (6 tools)
|
||||
├── reports.py # Analytics reports (8 tools)
|
||||
└── system.py # Health & stats (6 tools)
|
||||
```
|
||||
|
||||
### Client Architecture
|
||||
|
||||
```python
|
||||
class OpenPanelClient:
|
||||
"""
|
||||
Unified client for OpenPanel Self-Hosted APIs
|
||||
|
||||
Handles both Track/Export API (Fastify) and
|
||||
Dashboard tRPC API (Next.js) where available.
|
||||
"""
|
||||
def __init__(
|
||||
self,
|
||||
base_url: str, # e.g., https://analytics.example.com
|
||||
client_id: str, # Client ID for authentication
|
||||
client_secret: str, # Client Secret for authentication
|
||||
):
|
||||
self.base_url = base_url.rstrip('/')
|
||||
self.api_url = f"{self.base_url}/api"
|
||||
self.client_id = client_id
|
||||
self.client_secret = client_secret
|
||||
|
||||
def _get_headers(self, include_ip: str = None) -> Dict[str, str]:
|
||||
"""Get authentication headers"""
|
||||
headers = {
|
||||
"Content-Type": "application/json",
|
||||
"openpanel-client-id": self.client_id,
|
||||
"openpanel-client-secret": self.client_secret
|
||||
}
|
||||
if include_ip:
|
||||
headers["x-client-ip"] = include_ip
|
||||
return headers
|
||||
|
||||
async def track(
|
||||
self,
|
||||
event_type: str,
|
||||
payload: Dict[str, Any],
|
||||
client_ip: str = None
|
||||
) -> Dict[str, Any]:
|
||||
"""Send tracking request to /track endpoint"""
|
||||
...
|
||||
|
||||
async def export_events(
|
||||
self,
|
||||
project_id: str,
|
||||
**filters
|
||||
) -> Dict[str, Any]:
|
||||
"""Export events from /export/events"""
|
||||
...
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Tool Categories
|
||||
|
||||
### 1. Events Handler (10 tools)
|
||||
|
||||
Event tracking and ingestion operations.
|
||||
|
||||
| Tool | Type | Scope | Description |
|
||||
|------|------|-------|-------------|
|
||||
| `track_event` | track | write | Track custom event with properties |
|
||||
| `track_page_view` | track | write | Track page view event |
|
||||
| `track_screen_view` | track | write | Track screen view (mobile) |
|
||||
| `identify_user` | identify | write | Identify user with profile data |
|
||||
| `set_user_properties` | identify | write | Update user properties |
|
||||
| `increment_property` | increment | write | Increment numeric property |
|
||||
| `decrement_property` | decrement | write | Decrement numeric property |
|
||||
| `alias_user` | alias | write | Link two profile IDs |
|
||||
| `track_revenue` | track | write | Track revenue/purchase event |
|
||||
| `track_batch` | track | write | Track multiple events in batch |
|
||||
|
||||
```python
|
||||
# Example: track_event
|
||||
{
|
||||
"name": "track_event",
|
||||
"method_name": "track_event",
|
||||
"description": "Track a custom event with properties. Events can have any custom properties.",
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"name": {
|
||||
"type": "string",
|
||||
"description": "Event name (e.g., 'button_clicked', 'purchase_completed')"
|
||||
},
|
||||
"properties": {
|
||||
"type": "object",
|
||||
"description": "Custom properties for the event"
|
||||
},
|
||||
"profile_id": {
|
||||
"type": "string",
|
||||
"description": "User/profile ID to associate with event"
|
||||
},
|
||||
"timestamp": {
|
||||
"type": "string",
|
||||
"format": "date-time",
|
||||
"description": "Event timestamp (ISO 8601, defaults to now)"
|
||||
}
|
||||
},
|
||||
"required": ["name"]
|
||||
},
|
||||
"scope": "write"
|
||||
}
|
||||
```
|
||||
|
||||
### 2. Export Handler (10 tools)
|
||||
|
||||
Data export and retrieval operations.
|
||||
|
||||
| Tool | Type | Scope | Description |
|
||||
|------|------|-------|-------------|
|
||||
| `export_events` | GET | read | Export raw event data |
|
||||
| `export_events_csv` | GET | read | Export events as CSV |
|
||||
| `export_chart_data` | GET | read | Export aggregated chart data |
|
||||
| `get_event_count` | GET | read | Get event counts with filters |
|
||||
| `get_unique_users` | GET | read | Get unique user count |
|
||||
| `get_page_views` | GET | read | Get page view statistics |
|
||||
| `get_top_pages` | GET | read | Get top pages by views |
|
||||
| `get_top_referrers` | GET | read | Get top traffic sources |
|
||||
| `get_geo_data` | GET | read | Get geographic distribution |
|
||||
| `get_device_data` | GET | read | Get device/browser breakdown |
|
||||
|
||||
```python
|
||||
# Example: export_events
|
||||
{
|
||||
"name": "export_events",
|
||||
"method_name": "export_events",
|
||||
"description": "Export raw event data with filters and pagination",
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"project_id": {
|
||||
"type": "string",
|
||||
"description": "Project ID to export from"
|
||||
},
|
||||
"event": {
|
||||
"anyOf": [{"type": "string"}, {"type": "array", "items": {"type": "string"}}],
|
||||
"description": "Event name(s) to filter"
|
||||
},
|
||||
"profile_id": {
|
||||
"type": "string",
|
||||
"description": "Filter by user/profile ID"
|
||||
},
|
||||
"start": {
|
||||
"type": "string",
|
||||
"format": "date",
|
||||
"description": "Start date (YYYY-MM-DD)"
|
||||
},
|
||||
"end": {
|
||||
"type": "string",
|
||||
"format": "date",
|
||||
"description": "End date (YYYY-MM-DD)"
|
||||
},
|
||||
"limit": {
|
||||
"type": "integer",
|
||||
"default": 50,
|
||||
"maximum": 1000
|
||||
},
|
||||
"page": {
|
||||
"type": "integer",
|
||||
"default": 1
|
||||
},
|
||||
"includes": {
|
||||
"type": "array",
|
||||
"items": {"type": "string", "enum": ["profile", "meta"]},
|
||||
"description": "Additional data to include"
|
||||
}
|
||||
},
|
||||
"required": ["project_id"]
|
||||
},
|
||||
"scope": "read"
|
||||
}
|
||||
```
|
||||
|
||||
### 3. Projects Handler (8 tools)
|
||||
|
||||
Project management operations.
|
||||
|
||||
| Tool | Method | Scope | Description |
|
||||
|------|--------|-------|-------------|
|
||||
| `list_projects` | GET | read | List all projects |
|
||||
| `get_project` | GET | read | Get project details |
|
||||
| `create_project` | POST | admin | Create new project |
|
||||
| `update_project` | PUT | admin | Update project settings |
|
||||
| `delete_project` | DELETE | admin | Delete project |
|
||||
| `get_project_stats` | GET | read | Get project statistics |
|
||||
| `get_project_settings` | GET | read | Get project configuration |
|
||||
| `update_project_settings` | PUT | admin | Update project configuration |
|
||||
|
||||
### 4. Dashboards Handler (10 tools)
|
||||
|
||||
Dashboard and chart management.
|
||||
|
||||
| Tool | Method | Scope | Description |
|
||||
|------|--------|-------|-------------|
|
||||
| `list_dashboards` | GET | read | List all dashboards |
|
||||
| `get_dashboard` | GET | read | Get dashboard with charts |
|
||||
| `create_dashboard` | POST | write | Create new dashboard |
|
||||
| `update_dashboard` | PUT | write | Update dashboard |
|
||||
| `delete_dashboard` | DELETE | write | Delete dashboard |
|
||||
| `add_chart` | POST | write | Add chart to dashboard |
|
||||
| `update_chart` | PUT | write | Update chart configuration |
|
||||
| `delete_chart` | DELETE | write | Remove chart from dashboard |
|
||||
| `duplicate_dashboard` | POST | write | Clone existing dashboard |
|
||||
| `share_dashboard` | POST | write | Generate shareable link |
|
||||
|
||||
### 5. Funnels Handler (8 tools)
|
||||
|
||||
Funnel analysis operations.
|
||||
|
||||
| Tool | Method | Scope | Description |
|
||||
|------|--------|-------|-------------|
|
||||
| `list_funnels` | GET | read | List all funnels |
|
||||
| `get_funnel` | GET | read | Get funnel with conversion data |
|
||||
| `create_funnel` | POST | write | Create new funnel |
|
||||
| `update_funnel` | PUT | write | Update funnel steps |
|
||||
| `delete_funnel` | DELETE | write | Delete funnel |
|
||||
| `get_funnel_conversion` | GET | read | Get conversion rates |
|
||||
| `get_funnel_breakdown` | GET | read | Get breakdown by segment |
|
||||
| `compare_funnels` | GET | read | Compare multiple funnels |
|
||||
|
||||
```python
|
||||
# Example: create_funnel
|
||||
{
|
||||
"name": "create_funnel",
|
||||
"method_name": "create_funnel",
|
||||
"description": "Create a funnel to track user journey through steps",
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"project_id": {
|
||||
"type": "string",
|
||||
"description": "Project ID"
|
||||
},
|
||||
"name": {
|
||||
"type": "string",
|
||||
"description": "Funnel name"
|
||||
},
|
||||
"steps": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"name": {"type": "string"},
|
||||
"event": {"type": "string"},
|
||||
"filters": {"type": "array"}
|
||||
},
|
||||
"required": ["name", "event"]
|
||||
},
|
||||
"description": "Funnel steps (events in sequence)",
|
||||
"minItems": 2
|
||||
},
|
||||
"window_days": {
|
||||
"type": "integer",
|
||||
"default": 14,
|
||||
"description": "Conversion window in days"
|
||||
}
|
||||
},
|
||||
"required": ["project_id", "name", "steps"]
|
||||
},
|
||||
"scope": "write"
|
||||
}
|
||||
```
|
||||
|
||||
### 6. Profiles Handler (8 tools)
|
||||
|
||||
User profile management.
|
||||
|
||||
| Tool | Method | Scope | Description |
|
||||
|------|--------|-------|-------------|
|
||||
| `list_profiles` | GET | read | List user profiles |
|
||||
| `get_profile` | GET | read | Get profile details |
|
||||
| `search_profiles` | GET | read | Search profiles by property |
|
||||
| `get_profile_events` | GET | read | Get events for profile |
|
||||
| `get_profile_sessions` | GET | read | Get sessions for profile |
|
||||
| `delete_profile` | DELETE | admin | Delete profile data (GDPR) |
|
||||
| `merge_profiles` | POST | admin | Merge duplicate profiles |
|
||||
| `export_profile_data` | GET | read | Export all profile data (GDPR) |
|
||||
|
||||
### 7. Clients Handler (6 tools)
|
||||
|
||||
API client/key management.
|
||||
|
||||
| Tool | Method | Scope | Description |
|
||||
|------|--------|-------|-------------|
|
||||
| `list_clients` | GET | read | List API clients |
|
||||
| `get_client` | GET | read | Get client details |
|
||||
| `create_client` | POST | admin | Create new API client |
|
||||
| `delete_client` | DELETE | admin | Delete API client |
|
||||
| `regenerate_secret` | POST | admin | Regenerate client secret |
|
||||
| `update_client_mode` | PUT | admin | Update client permissions |
|
||||
|
||||
### 8. Reports Handler (8 tools)
|
||||
|
||||
Analytics and reporting.
|
||||
|
||||
| Tool | Method | Scope | Description |
|
||||
|------|--------|-------|-------------|
|
||||
| `get_overview_report` | GET | read | Get overview statistics |
|
||||
| `get_retention_report` | GET | read | Get retention analysis |
|
||||
| `get_cohort_report` | GET | read | Get cohort analysis |
|
||||
| `get_paths_report` | GET | read | Get user flow paths |
|
||||
| `get_realtime_stats` | GET | read | Get real-time visitors |
|
||||
| `get_ab_test_results` | GET | read | Get A/B test results |
|
||||
| `create_report` | POST | write | Create scheduled report |
|
||||
| `export_report` | GET | read | Export report as PDF/CSV |
|
||||
|
||||
### 9. System Handler (6 tools)
|
||||
|
||||
System health and management.
|
||||
|
||||
| Tool | Method | Scope | Description |
|
||||
|------|--------|-------|-------------|
|
||||
| `health_check` | GET | read | Check service health |
|
||||
| `get_instance_info` | GET | read | Get instance information |
|
||||
| `get_usage_stats` | GET | read | Get usage statistics |
|
||||
| `get_storage_stats` | GET | read | Get storage usage (ClickHouse) |
|
||||
| `test_connection` | GET | read | Test API connection |
|
||||
| `get_rate_limit_status` | GET | read | Check rate limit status |
|
||||
|
||||
---
|
||||
|
||||
## Tool Summary
|
||||
|
||||
| Handler | Tools | Description |
|
||||
|---------|-------|-------------|
|
||||
| Events | 10 | Event tracking & ingestion |
|
||||
| Export | 10 | Data export & retrieval |
|
||||
| Projects | 8 | Project management |
|
||||
| Dashboards | 10 | Dashboard & chart management |
|
||||
| Funnels | 8 | Funnel analysis |
|
||||
| Profiles | 8 | User profile management |
|
||||
| Clients | 6 | API client management |
|
||||
| Reports | 8 | Analytics & reporting |
|
||||
| System | 6 | Health & instance info |
|
||||
| **Total** | **74** | |
|
||||
|
||||
---
|
||||
|
||||
## Implementation Phases
|
||||
|
||||
### Phase H.1: Core (Required)
|
||||
|
||||
**Goal**: Basic event tracking and data export
|
||||
|
||||
1. **OpenPanelPlugin** class
|
||||
2. **OpenPanelClient** (unified)
|
||||
3. **Events Handler** (10 tools)
|
||||
4. **Export Handler** (10 tools)
|
||||
5. **System Handler** (6 tools)
|
||||
|
||||
**Tools**: 26
|
||||
|
||||
### Phase H.2: Analytics (Recommended)
|
||||
|
||||
**Goal**: Advanced analytics features
|
||||
|
||||
1. **Reports Handler** (8 tools)
|
||||
2. **Funnels Handler** (8 tools)
|
||||
3. **Profiles Handler** (8 tools)
|
||||
|
||||
**Tools**: 24 (Total: 50)
|
||||
|
||||
### Phase H.3: Management (Complete)
|
||||
|
||||
**Goal**: Full dashboard and project management
|
||||
|
||||
1. **Projects Handler** (8 tools)
|
||||
2. **Dashboards Handler** (10 tools)
|
||||
3. **Clients Handler** (6 tools)
|
||||
|
||||
**Tools**: 24 (Total: 74)
|
||||
|
||||
---
|
||||
|
||||
## Export API Reference
|
||||
|
||||
### Event Segmentation Types
|
||||
|
||||
| Segment | Description |
|
||||
|---------|-------------|
|
||||
| `event` | Count total events |
|
||||
| `user` | Count unique users |
|
||||
| `session` | Count unique sessions |
|
||||
| `user_average` | Average per user |
|
||||
| `one_event_per_user` | First event per user |
|
||||
| `property_sum` | Sum of property values |
|
||||
| `property_average` | Average of property values |
|
||||
| `property_min` | Minimum property value |
|
||||
| `property_max` | Maximum property value |
|
||||
|
||||
### Filter Operators
|
||||
|
||||
| Operator | Description |
|
||||
|----------|-------------|
|
||||
| `is` | Exact match |
|
||||
| `isNot` | Not equal |
|
||||
| `contains` | Contains substring |
|
||||
| `doesNotContain` | Does not contain |
|
||||
| `startsWith` | Starts with |
|
||||
| `endsWith` | Ends with |
|
||||
| `regex` | Regular expression |
|
||||
| `isNull` | Is null/undefined |
|
||||
| `isNotNull` | Is not null |
|
||||
|
||||
### Breakdown Dimensions
|
||||
|
||||
| Dimension | Description |
|
||||
|-----------|-------------|
|
||||
| `country` | Country |
|
||||
| `region` | Region/State |
|
||||
| `city` | City |
|
||||
| `device` | Device type |
|
||||
| `browser` | Browser name |
|
||||
| `os` | Operating system |
|
||||
| `referrer` | Traffic source |
|
||||
| `path` | Page path |
|
||||
|
||||
### Date Ranges
|
||||
|
||||
```
|
||||
30min, lastHour, today, yesterday
|
||||
7d, 30d, 6m, 12m
|
||||
monthToDate, lastMonth
|
||||
yearToDate, lastYear
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Error Handling
|
||||
|
||||
### HTTP Status Codes
|
||||
|
||||
| Code | Description | Action |
|
||||
|------|-------------|--------|
|
||||
| 200 | Success | Return data |
|
||||
| 400 | Bad Request | Validate input |
|
||||
| 401 | Unauthorized | Check credentials |
|
||||
| 403 | Forbidden | Check client mode |
|
||||
| 404 | Not Found | Resource doesn't exist |
|
||||
| 429 | Rate Limited | Implement backoff |
|
||||
| 500 | Server Error | Retry with backoff |
|
||||
|
||||
### Rate Limiting
|
||||
|
||||
```
|
||||
Limit: 100 requests per 10 seconds per client
|
||||
Backoff: Exponential (1s, 2s, 4s, 8s...)
|
||||
|
||||
Headers:
|
||||
X-RateLimit-Limit: 100
|
||||
X-RateLimit-Remaining: 95
|
||||
X-RateLimit-Reset: 1699999999
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Security Considerations
|
||||
|
||||
### Client Secret Protection
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────────┐
|
||||
│ Client Security │
|
||||
├─────────────────────────────────────────────────────────────────┤
|
||||
│ │
|
||||
│ write mode client: │
|
||||
│ ├── Can send events only │
|
||||
│ ├── Cannot read data │
|
||||
│ └── Safe for client-side (with domain restrictions) │
|
||||
│ │
|
||||
│ read mode client: │
|
||||
│ ├── Can export data │
|
||||
│ ├── Cannot send events │
|
||||
│ └── Server-side only │
|
||||
│ │
|
||||
│ root mode client: │
|
||||
│ ├── Full access │
|
||||
│ ├── Can manage projects, clients │
|
||||
│ └── ⚠️ Server-side only - Never expose! │
|
||||
│ │
|
||||
└─────────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### Best Practices
|
||||
|
||||
1. **Use appropriate client mode** - write for tracking, read for export
|
||||
2. **Never expose root clients** - Server-side only
|
||||
3. **Implement rate limiting** - Respect API limits
|
||||
4. **GDPR compliance** - Use profile deletion tools
|
||||
5. **Audit data exports** - Log who exports what
|
||||
|
||||
---
|
||||
|
||||
## Example Usage
|
||||
|
||||
### Track Event
|
||||
|
||||
```json
|
||||
{
|
||||
"tool": "track_event",
|
||||
"args": {
|
||||
"site": "myanalytics",
|
||||
"name": "purchase_completed",
|
||||
"properties": {
|
||||
"product_id": "prod_123",
|
||||
"amount": 99.99,
|
||||
"currency": "USD",
|
||||
"category": "electronics"
|
||||
},
|
||||
"profile_id": "user_456"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Identify User
|
||||
|
||||
```json
|
||||
{
|
||||
"tool": "identify_user",
|
||||
"args": {
|
||||
"site": "myanalytics",
|
||||
"profile_id": "user_456",
|
||||
"properties": {
|
||||
"firstName": "John",
|
||||
"lastName": "Doe",
|
||||
"email": "john@example.com",
|
||||
"plan": "premium",
|
||||
"company": "Acme Inc"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Export Events
|
||||
|
||||
```json
|
||||
{
|
||||
"tool": "export_events",
|
||||
"args": {
|
||||
"site": "myanalytics",
|
||||
"project_id": "proj_abc",
|
||||
"event": "purchase_completed",
|
||||
"start": "2025-11-01",
|
||||
"end": "2025-11-30",
|
||||
"limit": 100,
|
||||
"includes": ["profile"]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Create Funnel
|
||||
|
||||
```json
|
||||
{
|
||||
"tool": "create_funnel",
|
||||
"args": {
|
||||
"site": "myanalytics",
|
||||
"project_id": "proj_abc",
|
||||
"name": "Checkout Flow",
|
||||
"steps": [
|
||||
{"name": "View Product", "event": "product_viewed"},
|
||||
{"name": "Add to Cart", "event": "cart_added"},
|
||||
{"name": "Start Checkout", "event": "checkout_started"},
|
||||
{"name": "Complete Purchase", "event": "purchase_completed"}
|
||||
],
|
||||
"window_days": 7
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Get Retention Report
|
||||
|
||||
```json
|
||||
{
|
||||
"tool": "get_retention_report",
|
||||
"args": {
|
||||
"site": "myanalytics",
|
||||
"project_id": "proj_abc",
|
||||
"start_event": "signup_completed",
|
||||
"return_event": "app_opened",
|
||||
"period": "week",
|
||||
"cohorts": 12
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Coolify Deployment Notes
|
||||
|
||||
### OpenPanel Services
|
||||
|
||||
```yaml
|
||||
# OpenPanel services in Coolify
|
||||
services:
|
||||
dashboard: # Next.js - port 3000
|
||||
api: # Fastify Event API - port 3333
|
||||
worker: # BullMQ worker
|
||||
postgres: # PostgreSQL - metadata
|
||||
clickhouse: # ClickHouse - events
|
||||
redis: # Redis - cache/queue
|
||||
```
|
||||
|
||||
### Environment Variables (Coolify)
|
||||
|
||||
```bash
|
||||
# Required
|
||||
NEXT_PUBLIC_DASHBOARD_URL=https://analytics.example.com
|
||||
DATABASE_URL=postgresql://...
|
||||
CLICKHOUSE_URL=http://clickhouse:8123
|
||||
REDIS_URL=redis://redis:6379
|
||||
|
||||
# Optional
|
||||
RESEND_API_KEY=re_...
|
||||
OPENAI_API_KEY=sk-... # For AI features
|
||||
ANTHROPIC_API_KEY=sk-...
|
||||
```
|
||||
|
||||
### Finding Credentials
|
||||
|
||||
1. **URL**: Coolify Dashboard → Project → OpenPanel → Domain
|
||||
2. **Client ID/Secret**: OpenPanel Dashboard → Settings → Clients
|
||||
|
||||
---
|
||||
|
||||
## Endpoint Registration
|
||||
|
||||
### Endpoint Config
|
||||
|
||||
```python
|
||||
# core/endpoints/config.py
|
||||
|
||||
EndpointType.OPENPANEL: EndpointConfig(
|
||||
path="/openpanel",
|
||||
name="OpenPanel Analytics",
|
||||
description="OpenPanel product analytics management (events, funnels, dashboards)",
|
||||
endpoint_type=EndpointType.OPENPANEL,
|
||||
plugin_types=["openpanel"],
|
||||
require_master_key=False,
|
||||
allowed_scopes={"read", "write", "admin"},
|
||||
tool_blacklist={
|
||||
"manage_api_keys_create",
|
||||
"manage_api_keys_delete",
|
||||
"oauth_register_client",
|
||||
"oauth_revoke_client",
|
||||
},
|
||||
max_tools=80,
|
||||
),
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Testing Checklist
|
||||
|
||||
### Unit Tests
|
||||
|
||||
- [ ] OpenPanelClient authentication
|
||||
- [ ] Track API operations (track, identify, increment)
|
||||
- [ ] Export API operations (events, charts)
|
||||
- [ ] Error handling for all endpoints
|
||||
- [ ] Rate limiting behavior
|
||||
|
||||
### Integration Tests
|
||||
|
||||
- [ ] Track event and verify in export
|
||||
- [ ] Create funnel and get conversion data
|
||||
- [ ] Create dashboard with charts
|
||||
- [ ] Profile identification and merging
|
||||
- [ ] GDPR data export and deletion
|
||||
|
||||
---
|
||||
|
||||
## Comparison with Other Plugins
|
||||
|
||||
| Aspect | Supabase | n8n | OpenPanel |
|
||||
|--------|----------|-----|-----------|
|
||||
| Primary Focus | Database/Backend | Automation | Analytics |
|
||||
| Auth Method | JWT Keys | API Key | Client ID/Secret |
|
||||
| Main APIs | PostgREST, GoTrue | REST API | Track, Export |
|
||||
| Tools | 70 | 56 | 74 |
|
||||
| Phases | 3 | 1 | 3 |
|
||||
|
||||
---
|
||||
|
||||
## References
|
||||
|
||||
- [OpenPanel Documentation](https://openpanel.dev/docs)
|
||||
- [OpenPanel GitHub](https://github.com/Openpanel-dev/openpanel)
|
||||
- [Track API Reference](https://openpanel.dev/docs/api/track)
|
||||
- [Export API Reference](https://openpanel.dev/docs/api/export)
|
||||
- [Coolify OpenPanel](https://coolify.io/docs/services/openpanel)
|
||||
|
||||
---
|
||||
|
||||
**Created**: 2025-11-30
|
||||
**Author**: Claude AI Assistant
|
||||
**Status**: Design Phase
|
||||
721
docs/supabase-plugin-design.md
Normal file
721
docs/supabase-plugin-design.md
Normal file
@@ -0,0 +1,721 @@
|
||||
# Supabase Plugin Design - Phase G
|
||||
|
||||
> **MCP Plugin برای مدیریت Supabase Self-Hosted روی Coolify**
|
||||
|
||||
**Version**: v1.1.0 (طراحی - Self-Hosted)
|
||||
**Priority**: High
|
||||
**Estimated Tools**: 70-75
|
||||
|
||||
---
|
||||
|
||||
## Overview
|
||||
|
||||
پلاگین Supabase برای مدیریت **Supabase Self-Hosted** روی Coolify طراحی شده است.
|
||||
|
||||
### تفاوت Self-Hosted با Cloud
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────────┐
|
||||
│ Supabase Self-Hosted vs Cloud │
|
||||
├─────────────────────────────────────────────────────────────────┤
|
||||
│ │
|
||||
│ ✅ موجود در Self-Hosted: │
|
||||
│ ├── Database API (PostgREST) - /rest/v1/ │
|
||||
│ ├── Auth API (GoTrue) - /auth/v1/ │
|
||||
│ ├── Storage API - /storage/v1/ │
|
||||
│ ├── Edge Functions - /functions/v1/ │
|
||||
│ ├── postgres-meta (DB Admin) - /pg/ │
|
||||
│ └── Realtime - /realtime/v1/ (WebSocket) │
|
||||
│ │
|
||||
│ ❌ فقط در Cloud: │
|
||||
│ ├── Management API (api.supabase.com) │
|
||||
│ ├── Projects & Organizations │
|
||||
│ ├── Database Branches │
|
||||
│ ├── Platform Analytics │
|
||||
│ └── Secrets Management (platform-level) │
|
||||
│ │
|
||||
└─────────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
**نکته مهم**: در Self-Hosted، هر instance یک پروژه است. Management API وجود ندارد.
|
||||
|
||||
---
|
||||
|
||||
## Authentication
|
||||
|
||||
### سطح واحد احراز هویت
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────────┐
|
||||
│ Supabase Self-Hosted Auth │
|
||||
├─────────────────────────────────────────────────────────────────┤
|
||||
│ │
|
||||
│ API Keys (JWT-based): │
|
||||
│ ├── anon_key │
|
||||
│ │ ├── Public/client-safe │
|
||||
│ │ ├── Protected by RLS policies │
|
||||
│ │ └── role: "anon" in JWT │
|
||||
│ │ │
|
||||
│ └── service_role_key │
|
||||
│ ├── ⚠️ SERVER-ONLY - Never expose! │
|
||||
│ ├── Bypasses ALL RLS policies │
|
||||
│ └── role: "service_role" in JWT │
|
||||
│ │
|
||||
│ All keys signed with JWT_SECRET from .env │
|
||||
│ │
|
||||
└─────────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### Environment Variables
|
||||
|
||||
```bash
|
||||
# Supabase Self-Hosted Instance (Required)
|
||||
SUPABASE_SITE1_URL=https://supabase.example.com
|
||||
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
|
||||
|
||||
# Multiple Instances
|
||||
SUPABASE_SITE2_URL=https://supabase-staging.example.com
|
||||
SUPABASE_SITE2_ANON_KEY=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...
|
||||
SUPABASE_SITE2_SERVICE_ROLE_KEY=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...
|
||||
SUPABASE_SITE2_ALIAS=staging
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## API Endpoints (Kong Gateway)
|
||||
|
||||
تمام API ها از طریق Kong API Gateway (پورت 8000) در دسترس هستند:
|
||||
|
||||
```
|
||||
Base URL: https://supabase.example.com (یا http://localhost:8000)
|
||||
|
||||
Database (PostgREST):
|
||||
GET/POST/PATCH/DELETE /rest/v1/{table}
|
||||
POST /rest/v1/rpc/{function}
|
||||
|
||||
Auth (GoTrue):
|
||||
POST /auth/v1/signup
|
||||
POST /auth/v1/token
|
||||
GET /auth/v1/user
|
||||
POST /auth/v1/admin/users
|
||||
...
|
||||
|
||||
Storage:
|
||||
GET/POST/DELETE /storage/v1/bucket
|
||||
GET/POST/DELETE /storage/v1/object/{bucket}/{path}
|
||||
...
|
||||
|
||||
Edge Functions:
|
||||
POST /functions/v1/{function_name}
|
||||
|
||||
postgres-meta (Admin):
|
||||
GET /pg/tables
|
||||
GET /pg/columns
|
||||
GET /pg/policies
|
||||
...
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Architecture
|
||||
|
||||
### Project Structure
|
||||
|
||||
```
|
||||
plugins/supabase/
|
||||
├── __init__.py # Export: SupabasePlugin, SupabaseClient
|
||||
├── plugin.py # کلاس اصلی SupabasePlugin
|
||||
├── client.py # SupabaseClient (unified client)
|
||||
└── handlers/
|
||||
├── __init__.py
|
||||
├── database.py # Database operations (18 tools)
|
||||
├── auth.py # User management (14 tools)
|
||||
├── storage.py # File management (12 tools)
|
||||
├── functions.py # Edge Functions (8 tools)
|
||||
├── admin.py # postgres-meta admin (12 tools)
|
||||
└── system.py # Health & info (6 tools)
|
||||
```
|
||||
|
||||
### Client Architecture
|
||||
|
||||
```python
|
||||
class SupabaseClient:
|
||||
"""
|
||||
Unified client for Supabase Self-Hosted APIs
|
||||
|
||||
All requests go through Kong gateway on single base URL.
|
||||
"""
|
||||
def __init__(
|
||||
self,
|
||||
base_url: str, # e.g., https://supabase.example.com
|
||||
anon_key: str, # For public/RLS-protected operations
|
||||
service_role_key: str, # For admin operations (bypasses RLS)
|
||||
):
|
||||
self.base_url = base_url.rstrip('/')
|
||||
self.anon_key = anon_key
|
||||
self.service_role_key = service_role_key
|
||||
|
||||
async def request(
|
||||
self,
|
||||
method: str,
|
||||
endpoint: str,
|
||||
use_service_role: bool = False,
|
||||
**kwargs
|
||||
) -> Any:
|
||||
"""Make authenticated request to Supabase API"""
|
||||
key = self.service_role_key if use_service_role else self.anon_key
|
||||
headers = {
|
||||
"apikey": key,
|
||||
"Authorization": f"Bearer {key}",
|
||||
"Content-Type": "application/json"
|
||||
}
|
||||
url = f"{self.base_url}{endpoint}"
|
||||
# ... request logic
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Tool Categories
|
||||
|
||||
### 1. Database Handler (18 tools)
|
||||
|
||||
عملیات CRUD روی دیتابیس PostgreSQL از طریق PostgREST
|
||||
|
||||
| Tool | Method | Scope | Description |
|
||||
|------|--------|-------|-------------|
|
||||
| `list_tables` | GET | read | لیست جداول (via postgres-meta) |
|
||||
| `get_table_schema` | GET | read | ساختار جدول |
|
||||
| `query_table` | GET | read | کوئری SELECT با فیلتر |
|
||||
| `insert_rows` | POST | write | درج رکوردها |
|
||||
| `update_rows` | PATCH | write | بهروزرسانی رکوردها |
|
||||
| `upsert_rows` | POST | write | درج/بهروزرسانی |
|
||||
| `delete_rows` | DELETE | write | حذف رکوردها |
|
||||
| `execute_rpc` | POST | write | اجرای stored procedure |
|
||||
| `count_rows` | GET | read | شمارش رکوردها |
|
||||
| `get_row_by_id` | GET | read | دریافت یک رکورد با ID |
|
||||
| `bulk_insert` | POST | write | درج دستهای |
|
||||
| `bulk_update` | PATCH | write | بهروزرسانی دستهای |
|
||||
| `bulk_delete` | DELETE | write | حذف دستهای |
|
||||
| `search_text` | GET | read | جستجوی Full-text |
|
||||
| `get_foreign_tables` | GET | read | جداول مرتبط |
|
||||
| `execute_sql` | POST | admin | اجرای SQL مستقیم (via postgres-meta) |
|
||||
| `create_table` | POST | admin | ایجاد جدول جدید |
|
||||
| `drop_table` | DELETE | admin | حذف جدول |
|
||||
|
||||
```python
|
||||
# Query Table Example
|
||||
{
|
||||
"name": "supabase_query_table",
|
||||
"description": "Query data from a Supabase table with filters and pagination",
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"site": {
|
||||
"type": "string",
|
||||
"description": "Supabase instance alias or site ID"
|
||||
},
|
||||
"table": {
|
||||
"type": "string",
|
||||
"description": "Table name"
|
||||
},
|
||||
"select": {
|
||||
"type": "string",
|
||||
"description": "Columns to select (e.g., 'id,name,email' or '*')",
|
||||
"default": "*"
|
||||
},
|
||||
"filters": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"column": {"type": "string"},
|
||||
"operator": {
|
||||
"type": "string",
|
||||
"enum": ["eq", "neq", "gt", "gte", "lt", "lte",
|
||||
"like", "ilike", "in", "is", "cs", "cd"]
|
||||
},
|
||||
"value": {}
|
||||
}
|
||||
},
|
||||
"description": "PostgREST filter conditions"
|
||||
},
|
||||
"order": {
|
||||
"type": "string",
|
||||
"description": "Order by (e.g., 'created_at.desc')"
|
||||
},
|
||||
"limit": {
|
||||
"type": "integer",
|
||||
"default": 100,
|
||||
"maximum": 1000
|
||||
},
|
||||
"offset": {
|
||||
"type": "integer",
|
||||
"default": 0
|
||||
}
|
||||
},
|
||||
"required": ["table"]
|
||||
},
|
||||
"scope": "read"
|
||||
}
|
||||
```
|
||||
|
||||
### 2. Auth Handler (14 tools)
|
||||
|
||||
مدیریت کاربران از طریق GoTrue Admin API
|
||||
|
||||
| Tool | Method | Scope | Description |
|
||||
|------|--------|-------|-------------|
|
||||
| `list_users` | GET | admin | لیست کاربران |
|
||||
| `get_user` | GET | read | جزئیات کاربر |
|
||||
| `create_user` | POST | admin | ایجاد کاربر |
|
||||
| `update_user` | PUT | admin | بهروزرسانی کاربر |
|
||||
| `delete_user` | DELETE | admin | حذف کاربر |
|
||||
| `invite_user` | POST | admin | ارسال دعوتنامه |
|
||||
| `generate_link` | POST | admin | تولید magic/recovery link |
|
||||
| `ban_user` | PUT | admin | مسدود کردن کاربر |
|
||||
| `unban_user` | PUT | admin | رفع مسدودیت |
|
||||
| `list_factors` | GET | read | لیست MFA factors |
|
||||
| `delete_factor` | DELETE | admin | حذف MFA factor |
|
||||
| `get_audit_logs` | GET | read | لاگهای auth |
|
||||
| `verify_otp` | POST | write | تایید OTP |
|
||||
| `resend_otp` | POST | write | ارسال مجدد OTP |
|
||||
|
||||
```python
|
||||
# Create User Example
|
||||
{
|
||||
"name": "supabase_create_user",
|
||||
"description": "Create a new user in Supabase Auth",
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"site": {"type": "string"},
|
||||
"email": {
|
||||
"type": "string",
|
||||
"format": "email"
|
||||
},
|
||||
"password": {
|
||||
"type": "string",
|
||||
"minLength": 6
|
||||
},
|
||||
"phone": {
|
||||
"type": "string",
|
||||
"description": "Phone number (E.164 format)"
|
||||
},
|
||||
"email_confirm": {
|
||||
"type": "boolean",
|
||||
"default": false,
|
||||
"description": "Auto-confirm email"
|
||||
},
|
||||
"user_metadata": {
|
||||
"type": "object",
|
||||
"description": "Custom user metadata"
|
||||
},
|
||||
"app_metadata": {
|
||||
"type": "object",
|
||||
"description": "App-specific metadata"
|
||||
}
|
||||
},
|
||||
"required": ["email", "password"]
|
||||
},
|
||||
"scope": "admin"
|
||||
}
|
||||
```
|
||||
|
||||
### 3. Storage Handler (12 tools)
|
||||
|
||||
مدیریت فایلها و bucket ها
|
||||
|
||||
| Tool | Method | Scope | Description |
|
||||
|------|--------|-------|-------------|
|
||||
| `list_buckets` | GET | read | لیست bucket ها |
|
||||
| `get_bucket` | GET | read | جزئیات bucket |
|
||||
| `create_bucket` | POST | admin | ایجاد bucket |
|
||||
| `update_bucket` | PUT | admin | بهروزرسانی bucket |
|
||||
| `delete_bucket` | DELETE | admin | حذف bucket |
|
||||
| `empty_bucket` | POST | admin | خالی کردن bucket |
|
||||
| `list_files` | GET | read | لیست فایلها در مسیر |
|
||||
| `upload_file` | POST | write | آپلود فایل |
|
||||
| `download_file` | GET | read | دانلود فایل (base64) |
|
||||
| `delete_files` | DELETE | write | حذف فایلها |
|
||||
| `move_file` | POST | write | انتقال/تغییر نام فایل |
|
||||
| `get_public_url` | GET | read | دریافت URL عمومی |
|
||||
|
||||
### 4. Functions Handler (8 tools)
|
||||
|
||||
مدیریت و اجرای Edge Functions
|
||||
|
||||
| Tool | Method | Scope | Description |
|
||||
|------|--------|-------|-------------|
|
||||
| `list_functions` | GET | read | لیست functions موجود |
|
||||
| `invoke_function` | POST | write | اجرای function |
|
||||
| `invoke_function_get` | GET | read | اجرای function با GET |
|
||||
| `get_function_body` | GET | read | دریافت کد function |
|
||||
| `deploy_function` | POST | admin | deploy function جدید |
|
||||
| `delete_function` | DELETE | admin | حذف function |
|
||||
| `get_function_logs` | GET | read | لاگهای اخیر |
|
||||
| `update_function_secrets` | PATCH | admin | تنظیم secrets |
|
||||
|
||||
**نکته**: در Self-Hosted، functions در `volumes/functions/` ذخیره میشوند.
|
||||
|
||||
### 5. Admin Handler (12 tools)
|
||||
|
||||
عملیات مدیریتی دیتابیس از طریق postgres-meta
|
||||
|
||||
| Tool | Method | Scope | Description |
|
||||
|------|--------|-------|-------------|
|
||||
| `list_schemas` | GET | read | لیست schemas |
|
||||
| `list_extensions` | GET | read | لیست extensions |
|
||||
| `enable_extension` | POST | admin | فعالسازی extension |
|
||||
| `disable_extension` | DELETE | admin | غیرفعالسازی extension |
|
||||
| `list_policies` | GET | read | لیست RLS policies |
|
||||
| `create_policy` | POST | admin | ایجاد RLS policy |
|
||||
| `update_policy` | PATCH | admin | بهروزرسانی policy |
|
||||
| `delete_policy` | DELETE | admin | حذف policy |
|
||||
| `list_roles` | GET | read | لیست database roles |
|
||||
| `list_triggers` | GET | read | لیست triggers |
|
||||
| `list_functions_db` | GET | read | لیست DB functions |
|
||||
| `get_database_size` | GET | read | سایز دیتابیس |
|
||||
|
||||
### 6. System Handler (6 tools)
|
||||
|
||||
سلامت و اطلاعات سیستم
|
||||
|
||||
| Tool | Method | Scope | Description |
|
||||
|------|--------|-------|-------------|
|
||||
| `health_check` | GET | read | بررسی سلامت |
|
||||
| `get_config` | GET | read | تنظیمات فعلی |
|
||||
| `get_version` | GET | read | نسخه سرویسها |
|
||||
| `test_connection` | GET | read | تست اتصال |
|
||||
| `get_stats` | GET | read | آمار سرویسها |
|
||||
| `ping` | GET | read | Ping ساده |
|
||||
|
||||
---
|
||||
|
||||
## Tool Summary
|
||||
|
||||
| Handler | Tools | Description |
|
||||
|---------|-------|-------------|
|
||||
| Database | 18 | PostgREST CRUD + SQL |
|
||||
| Auth | 14 | GoTrue user management |
|
||||
| Storage | 12 | File & bucket management |
|
||||
| Functions | 8 | Edge Functions |
|
||||
| Admin | 12 | postgres-meta DB admin |
|
||||
| System | 6 | Health & info |
|
||||
| **Total** | **70** | |
|
||||
|
||||
---
|
||||
|
||||
## Implementation Phases
|
||||
|
||||
### Phase G.1: Core (Required)
|
||||
|
||||
**هدف**: دسترسی اولیه به Supabase Self-Hosted
|
||||
|
||||
1. **SupabasePlugin** class
|
||||
2. **SupabaseClient** (unified)
|
||||
3. **Database Handler** (18 tools)
|
||||
4. **System Handler** (6 tools)
|
||||
|
||||
**ابزارها**: 24
|
||||
|
||||
### Phase G.2: Auth & Storage (Recommended)
|
||||
|
||||
**هدف**: مدیریت کاربران و فایلها
|
||||
|
||||
1. **Auth Handler** (14 tools)
|
||||
2. **Storage Handler** (12 tools)
|
||||
|
||||
**ابزارها**: 26 (جمع: 50)
|
||||
|
||||
### Phase G.3: Advanced (Complete)
|
||||
|
||||
**هدف**: قابلیتهای پیشرفته
|
||||
|
||||
1. **Functions Handler** (8 tools)
|
||||
2. **Admin Handler** (12 tools)
|
||||
|
||||
**ابزارها**: 20 (جمع: 70)
|
||||
|
||||
---
|
||||
|
||||
## PostgREST Operators Reference
|
||||
|
||||
| Operator | Description | Example |
|
||||
|----------|-------------|---------|
|
||||
| `eq` | Equal | `?column=eq.value` |
|
||||
| `neq` | Not equal | `?column=neq.value` |
|
||||
| `gt` | Greater than | `?column=gt.5` |
|
||||
| `gte` | Greater or equal | `?column=gte.5` |
|
||||
| `lt` | Less than | `?column=lt.10` |
|
||||
| `lte` | Less or equal | `?column=lte.10` |
|
||||
| `like` | LIKE (case-sensitive) | `?column=like.*pattern*` |
|
||||
| `ilike` | LIKE (case-insensitive) | `?column=ilike.*pattern*` |
|
||||
| `in` | IN array | `?column=in.(a,b,c)` |
|
||||
| `is` | IS (null, true, false) | `?column=is.null` |
|
||||
| `cs` | Contains (arrays) | `?column=cs.{a,b}` |
|
||||
| `cd` | Contained by | `?column=cd.{a,b,c}` |
|
||||
| `fts` | Full-text search | `?column=fts.query` |
|
||||
|
||||
---
|
||||
|
||||
## Error Handling
|
||||
|
||||
### PostgREST Errors
|
||||
|
||||
```python
|
||||
async def handle_postgrest_error(response):
|
||||
data = await response.json()
|
||||
|
||||
if response.status == 400:
|
||||
# Validation error
|
||||
raise ValidationError(data.get("message", "Invalid request"))
|
||||
elif response.status == 401:
|
||||
raise AuthError("Invalid API key")
|
||||
elif response.status == 403:
|
||||
# RLS policy violation
|
||||
raise RLSError(f"Row Level Security: {data.get('message')}")
|
||||
elif response.status == 404:
|
||||
raise NotFoundError("Resource not found")
|
||||
elif response.status == 409:
|
||||
raise ConflictError("Unique constraint violation")
|
||||
elif response.status == 406:
|
||||
raise NotAcceptableError("Invalid Accept header")
|
||||
```
|
||||
|
||||
### GoTrue Errors
|
||||
|
||||
```python
|
||||
async def handle_gotrue_error(response):
|
||||
data = await response.json()
|
||||
|
||||
error_code = data.get("error_code") or data.get("code")
|
||||
message = data.get("msg") or data.get("message") or data.get("error_description")
|
||||
|
||||
if error_code == "user_not_found":
|
||||
raise UserNotFoundError(message)
|
||||
elif error_code == "email_exists":
|
||||
raise DuplicateError("Email already registered")
|
||||
# ... more error codes
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Security Considerations
|
||||
|
||||
### Key Security
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────────┐
|
||||
│ API Key Security │
|
||||
├─────────────────────────────────────────────────────────────────┤
|
||||
│ │
|
||||
│ anon_key: │
|
||||
│ ├── ✅ Safe for client-side │
|
||||
│ ├── ✅ Protected by RLS policies │
|
||||
│ ├── ⚠️ Always enable RLS on tables │
|
||||
│ └── Used for: read operations, authenticated user actions │
|
||||
│ │
|
||||
│ service_role_key: │
|
||||
│ ├── ❌ NEVER expose to client │
|
||||
│ ├── ❌ Bypasses ALL RLS policies │
|
||||
│ ├── ✅ Server-side only │
|
||||
│ └── Used for: admin operations, background jobs │
|
||||
│ │
|
||||
└─────────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### Best Practices
|
||||
|
||||
1. **Always use RLS** - حتی با service_role_key، RLS را فعال نگه دارید
|
||||
2. **Minimal service_role usage** - فقط وقتی واقعاً نیاز است
|
||||
3. **Never log keys** - حتی در error messages
|
||||
4. **Validate input** - قبل از اجرای SQL
|
||||
5. **Audit admin operations** - تمام عملیات admin لاگ شود
|
||||
|
||||
---
|
||||
|
||||
## Example Usage
|
||||
|
||||
### Query Database
|
||||
|
||||
```json
|
||||
{
|
||||
"tool": "supabase_query_table",
|
||||
"args": {
|
||||
"site": "mysupabase",
|
||||
"table": "users",
|
||||
"select": "id,email,created_at",
|
||||
"filters": [
|
||||
{"column": "role", "operator": "eq", "value": "admin"}
|
||||
],
|
||||
"order": "created_at.desc",
|
||||
"limit": 50
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Create User
|
||||
|
||||
```json
|
||||
{
|
||||
"tool": "supabase_create_user",
|
||||
"args": {
|
||||
"site": "mysupabase",
|
||||
"email": "user@example.com",
|
||||
"password": "secure-password",
|
||||
"email_confirm": true,
|
||||
"user_metadata": {
|
||||
"name": "John Doe",
|
||||
"role": "editor"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Upload File
|
||||
|
||||
```json
|
||||
{
|
||||
"tool": "supabase_upload_file",
|
||||
"args": {
|
||||
"site": "mysupabase",
|
||||
"bucket": "avatars",
|
||||
"path": "users/123/profile.png",
|
||||
"content_base64": "iVBORw0KGgoAAAANSUhEUgAA...",
|
||||
"content_type": "image/png",
|
||||
"upsert": true
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Invoke Edge Function
|
||||
|
||||
```json
|
||||
{
|
||||
"tool": "supabase_invoke_function",
|
||||
"args": {
|
||||
"site": "mysupabase",
|
||||
"function_name": "send-notification",
|
||||
"body": {
|
||||
"user_id": "123",
|
||||
"message": "Hello from AI!"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Coolify Deployment Notes
|
||||
|
||||
### Finding Supabase Credentials
|
||||
|
||||
در Coolify، بعد از deploy کردن Supabase:
|
||||
|
||||
1. **URL**: از Coolify dashboard → Project → Supabase → Domain
|
||||
2. **Keys**: در Environment Variables:
|
||||
- `ANON_KEY` - کلید عمومی
|
||||
- `SERVICE_ROLE_KEY` - کلید ادمین
|
||||
- `JWT_SECRET` - برای verify کردن tokens
|
||||
|
||||
### Kong Gateway Port
|
||||
|
||||
```
|
||||
Default: 8000
|
||||
HTTPS: 8443 (اگر SSL فعال باشد)
|
||||
|
||||
Coolify معمولاً reverse proxy میکند:
|
||||
https://supabase.example.com → Kong:8000
|
||||
```
|
||||
|
||||
### Docker Compose Services
|
||||
|
||||
```yaml
|
||||
# Supabase services on Coolify
|
||||
services:
|
||||
kong: # API Gateway - port 8000
|
||||
auth: # GoTrue - /auth/v1/
|
||||
rest: # PostgREST - /rest/v1/
|
||||
storage: # Storage API - /storage/v1/
|
||||
meta: # postgres-meta - /pg/
|
||||
functions: # Edge Functions - /functions/v1/
|
||||
db: # PostgreSQL
|
||||
realtime: # Realtime WebSocket
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Endpoint Registration
|
||||
|
||||
### Endpoint Config
|
||||
|
||||
```python
|
||||
# core/endpoints/config.py
|
||||
|
||||
EndpointType.SUPABASE: EndpointConfig(
|
||||
path="/supabase",
|
||||
name="Supabase Manager",
|
||||
description="Supabase Self-Hosted management (database, auth, storage, functions)",
|
||||
endpoint_type=EndpointType.SUPABASE,
|
||||
plugin_types=["supabase"],
|
||||
require_master_key=False,
|
||||
allowed_scopes={"read", "write", "admin"},
|
||||
tool_blacklist={
|
||||
"manage_api_keys_create",
|
||||
"manage_api_keys_delete",
|
||||
"manage_api_keys_rotate",
|
||||
"oauth_register_client",
|
||||
"oauth_revoke_client",
|
||||
},
|
||||
max_tools=80,
|
||||
),
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Testing Checklist
|
||||
|
||||
### Unit Tests
|
||||
|
||||
- [ ] SupabaseClient authentication
|
||||
- [ ] PostgREST CRUD operations
|
||||
- [ ] PostgREST filtering and pagination
|
||||
- [ ] GoTrue user operations
|
||||
- [ ] Storage file operations
|
||||
- [ ] Error handling for all endpoints
|
||||
|
||||
### Integration Tests
|
||||
|
||||
- [ ] Query and modify database
|
||||
- [ ] Create and authenticate user
|
||||
- [ ] Upload and download file
|
||||
- [ ] Invoke Edge Function
|
||||
- [ ] RLS policy enforcement
|
||||
- [ ] Service role bypass
|
||||
|
||||
---
|
||||
|
||||
## References
|
||||
|
||||
- [Supabase Self-Hosting Docker](https://supabase.com/docs/guides/self-hosting/docker)
|
||||
- [PostgREST Documentation](https://postgrest.org/en/stable/)
|
||||
- [GoTrue API Reference](https://github.com/supabase/auth)
|
||||
- [Supabase Storage API](https://supabase.com/docs/guides/storage)
|
||||
- [Kong API Gateway](https://docs.konghq.com/)
|
||||
- [postgres-meta](https://github.com/supabase/postgres-meta)
|
||||
|
||||
---
|
||||
|
||||
**Created**: 2025-11-29
|
||||
**Updated**: 2025-11-29 (Self-Hosted version)
|
||||
**Author**: Claude AI Assistant
|
||||
**Status**: Design Phase
|
||||
676
docs/troubleshooting.md
Normal file
676
docs/troubleshooting.md
Normal file
@@ -0,0 +1,676 @@
|
||||
# 🔧 Troubleshooting Guide
|
||||
|
||||
---
|
||||
|
||||
## Table of Contents
|
||||
|
||||
1. [Common Errors](#common-errors)
|
||||
2. [Connection Issues](#connection-issues)
|
||||
3. [Authentication Problems](#authentication-problems)
|
||||
4. [Rate Limiting Issues](#rate-limiting-issues)
|
||||
5. [Docker Problems](#docker-problems)
|
||||
6. [Performance Issues](#performance-issues)
|
||||
7. [Tool Registration Issues](#tool-registration-issues)
|
||||
8. [Logging and Debugging](#logging-and-debugging)
|
||||
9. [Getting Help](#getting-help)
|
||||
|
||||
---
|
||||
|
||||
## Common Errors
|
||||
|
||||
### Error: "No module named 'fastmcp'"
|
||||
|
||||
**Cause**: Dependencies not installed or virtual environment not activated.
|
||||
|
||||
**Solution**:
|
||||
```bash
|
||||
# Activate virtual environment
|
||||
source venv/bin/activate # Linux/Mac
|
||||
.\venv\Scripts\Activate.ps1 # Windows
|
||||
|
||||
# Install dependencies
|
||||
pip install -r requirements.txt
|
||||
```
|
||||
|
||||
### Error: "KeyError: 'WORDPRESS_SITE1_URL'"
|
||||
|
||||
**Cause**: Environment variables not configured.
|
||||
|
||||
**Solution**:
|
||||
```bash
|
||||
# Check if .env file exists
|
||||
ls -la .env
|
||||
|
||||
# If not, create from template
|
||||
cp .env.example .env
|
||||
|
||||
# Edit with your credentials
|
||||
nano .env # Linux/Mac
|
||||
notepad .env # Windows
|
||||
```
|
||||
|
||||
### Error: "ModuleNotFoundError: No module named 'core'"
|
||||
|
||||
**Cause**: Running from wrong directory or Python path issue.
|
||||
|
||||
**Solution**:
|
||||
```bash
|
||||
# Make sure you're in project root
|
||||
cd /path/to/mcphub
|
||||
|
||||
# Run the server directly
|
||||
python server.py
|
||||
|
||||
# Or add to .env
|
||||
echo "PYTHONPATH=." >> .env
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Connection Issues
|
||||
|
||||
### WordPress Site Not Accessible
|
||||
|
||||
**Symptoms**: `Connection timeout`, `Connection refused`, `Name or service not known`
|
||||
|
||||
**Diagnosis**:
|
||||
```bash
|
||||
# Test site accessibility
|
||||
curl -I https://your-wordpress-site.com
|
||||
|
||||
# Check DNS resolution
|
||||
nslocalhost your-wordpress-site.com
|
||||
|
||||
# Test with timeout
|
||||
timeout 5 curl https://your-wordpress-site.com
|
||||
```
|
||||
|
||||
**Solutions**:
|
||||
|
||||
1. **Check URL format**:
|
||||
```bash
|
||||
# Correct
|
||||
WORDPRESS_SITE1_URL=https://example.com
|
||||
|
||||
# Incorrect
|
||||
WORDPRESS_SITE1_URL=example.com # Missing https://
|
||||
WORDPRESS_SITE1_URL=https://example.com/ # Extra trailing slash
|
||||
```
|
||||
|
||||
2. **Verify HTTPS certificate**:
|
||||
```bash
|
||||
curl -v https://your-site.com 2>&1 | grep SSL
|
||||
```
|
||||
|
||||
3. **Check firewall rules**:
|
||||
- Ensure port 443 (HTTPS) is open
|
||||
- Check if IP is whitelisted (if using server firewall)
|
||||
|
||||
4. **Test from different network**:
|
||||
- Try from different IP address
|
||||
- Check if WordPress site blocks data center IPs
|
||||
|
||||
### WordPress REST API Not Available
|
||||
|
||||
**Symptoms**: `404 Not Found` on `/wp-json/`
|
||||
|
||||
**Diagnosis**:
|
||||
```bash
|
||||
curl https://your-site.com/wp-json/
|
||||
```
|
||||
|
||||
**Solutions**:
|
||||
|
||||
1. **Check permalink settings**:
|
||||
- Go to: WordPress Admin → Settings → Permalinks
|
||||
- Select any option except "Plain"
|
||||
- Click "Save Changes"
|
||||
|
||||
2. **Check .htaccess**:
|
||||
```apache
|
||||
# Must have these rules
|
||||
RewriteEngine On
|
||||
RewriteRule ^index\.php$ - [L]
|
||||
RewriteCond %{REQUEST_FILENAME} !-f
|
||||
RewriteCond %{REQUEST_FILENAME} !-d
|
||||
RewriteRule . /index.php [L]
|
||||
```
|
||||
|
||||
3. **Check nginx configuration** (if using nginx):
|
||||
```nginx
|
||||
location / {
|
||||
try_files $uri $uri/ /index.php?$args;
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Authentication Problems
|
||||
|
||||
### Error: "Invalid username or password"
|
||||
|
||||
**Cause**: Application Password not configured correctly.
|
||||
|
||||
**Solution**:
|
||||
|
||||
1. **Verify Application Password is enabled**:
|
||||
- WordPress 5.6+ only
|
||||
- Go to: Users → Your Profile
|
||||
- Look for "Application Passwords" section
|
||||
|
||||
2. **Generate new Application Password**:
|
||||
```
|
||||
Name: MCP Server
|
||||
Click: Add New Application Password
|
||||
Copy password (format: xxxx xxxx xxxx xxxx xxxx xxxx)
|
||||
```
|
||||
|
||||
3. **Update .env file**:
|
||||
```bash
|
||||
WORDPRESS_SITE1_APP_PASSWORD=xxxx xxxx xxxx xxxx xxxx xxxx
|
||||
```
|
||||
|
||||
4. **Important notes**:
|
||||
- Include spaces in password
|
||||
- Password is one-time display only
|
||||
- Each application needs unique password
|
||||
|
||||
### Error: "Application Passwords not available"
|
||||
|
||||
**Cause**: WordPress version < 5.6 or disabled by plugin/filter.
|
||||
|
||||
**Solutions**:
|
||||
|
||||
1. **Update WordPress**:
|
||||
```bash
|
||||
# Via WP-CLI
|
||||
wp core update
|
||||
```
|
||||
|
||||
2. **Check if disabled by plugin/filter**:
|
||||
- Some security plugins disable Application Passwords
|
||||
- Check: Security Plugins → Settings → Application Passwords
|
||||
|
||||
3. **Enable via code** (add to `functions.php`):
|
||||
```php
|
||||
// Remove filter that may disable it
|
||||
add_filter('wp_is_application_passwords_available', '__return_true');
|
||||
```
|
||||
|
||||
### WooCommerce Authentication Failed
|
||||
|
||||
**Cause**: Invalid Consumer Key/Secret.
|
||||
|
||||
**Solution**:
|
||||
|
||||
1. **Regenerate API keys**:
|
||||
- WooCommerce → Settings → Advanced → REST API
|
||||
- Add Key
|
||||
- Permissions: Read/Write
|
||||
- Copy both Consumer Key and Consumer Secret
|
||||
|
||||
2. **Verify format in .env**:
|
||||
```bash
|
||||
WORDPRESS_SITE1_WC_CONSUMER_KEY=ck_xxxxxxxxxxxxxxxxxxxx
|
||||
WORDPRESS_SITE1_WC_CONSUMER_SECRET=cs_xxxxxxxxxxxxxxxxxxxx
|
||||
```
|
||||
|
||||
3. **Check permissions**:
|
||||
- User must have `manage_woocommerce` capability
|
||||
- Keys must be for admin user
|
||||
|
||||
---
|
||||
|
||||
## Rate Limiting Issues
|
||||
|
||||
### Error: "Rate limit exceeded"
|
||||
|
||||
**Symptoms**: HTTP 429, "Too many requests"
|
||||
|
||||
**Diagnosis**:
|
||||
```bash
|
||||
# Check rate limit stats
|
||||
python -c "
|
||||
from src.core.rate_limiter import RateLimiter
|
||||
limiter = RateLimiter()
|
||||
print(limiter.get_stats())
|
||||
"
|
||||
```
|
||||
|
||||
**Solutions**:
|
||||
|
||||
1. **Adjust rate limits** in `.env`:
|
||||
```bash
|
||||
RATE_LIMIT_PER_MINUTE=120 # Increase from 60
|
||||
RATE_LIMIT_PER_HOUR=2000 # Increase from 1000
|
||||
RATE_LIMIT_PER_DAY=20000 # Increase from 10000
|
||||
```
|
||||
|
||||
2. **Reset rate limiter**:
|
||||
```bash
|
||||
# Via MCP tool
|
||||
reset_rate_limit()
|
||||
|
||||
# Or restart server
|
||||
docker compose restart # If using Docker
|
||||
```
|
||||
|
||||
3. **Distribute requests**:
|
||||
- Add delays between bulk operations
|
||||
- Use batching for large operations
|
||||
|
||||
### Rate Limit Not Working
|
||||
|
||||
**Symptoms**: No rate limiting being applied.
|
||||
|
||||
**Solution**:
|
||||
|
||||
Check rate limiter initialization in logs:
|
||||
```bash
|
||||
grep "Rate limiter" logs/audit.log
|
||||
```
|
||||
|
||||
Ensure rate limiter is enabled:
|
||||
```python
|
||||
# In .env
|
||||
RATE_LIMITING_ENABLED=true
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Docker Problems
|
||||
|
||||
### Container Won't Start
|
||||
|
||||
**Symptoms**: `docker compose up` fails, container exits immediately.
|
||||
|
||||
**Diagnosis**:
|
||||
```bash
|
||||
# Check container logs
|
||||
docker compose logs
|
||||
|
||||
# Check container exit code
|
||||
docker compose ps -a
|
||||
```
|
||||
|
||||
**Common Solutions**:
|
||||
|
||||
1. **Port already in use**:
|
||||
```bash
|
||||
# Check what's using port 8000
|
||||
lsof -i :8000 # Linux/Mac
|
||||
netstat -ano | findstr :8000 # Windows
|
||||
|
||||
# Change port in docker-compose.yml
|
||||
ports:
|
||||
- "8080:8000" # Use 8080 instead
|
||||
```
|
||||
|
||||
2. **Invalid environment variables**:
|
||||
```bash
|
||||
# Validate .env file
|
||||
docker compose config
|
||||
```
|
||||
|
||||
3. **Permission issues**:
|
||||
```bash
|
||||
# Fix permissions
|
||||
sudo chown -R 1000:1000 logs/
|
||||
```
|
||||
|
||||
### Container Running but Not Accessible
|
||||
|
||||
**Symptoms**: Container status shows "Up" but health check fails.
|
||||
|
||||
**Diagnosis**:
|
||||
```bash
|
||||
# Check container health
|
||||
docker compose ps
|
||||
|
||||
# Test from inside container
|
||||
docker compose exec mcp-server curl localhost:8000/health
|
||||
|
||||
# Check container network
|
||||
docker compose exec mcp-server cat /etc/hosts
|
||||
```
|
||||
|
||||
**Solutions**:
|
||||
|
||||
1. **Check binding address**:
|
||||
- Should bind to `0.0.0.0`, not `127.0.0.1`
|
||||
- Update `server.py` if needed
|
||||
|
||||
2. **Check firewall**:
|
||||
```bash
|
||||
# Allow Docker network
|
||||
sudo ufw allow from 172.0.0.0/8
|
||||
```
|
||||
|
||||
### High Memory Usage
|
||||
|
||||
**Symptoms**: Container using excessive RAM.
|
||||
|
||||
**Solutions**:
|
||||
|
||||
1. **Set memory limits** in `docker-compose.yml`:
|
||||
```yaml
|
||||
services:
|
||||
mcp-server:
|
||||
deploy:
|
||||
resources:
|
||||
limits:
|
||||
memory: 512M
|
||||
```
|
||||
|
||||
2. **Optimize logging**:
|
||||
```bash
|
||||
# Reduce log level
|
||||
LOG_LEVEL=WARNING
|
||||
```
|
||||
|
||||
3. **Clear cache**:
|
||||
```bash
|
||||
docker system prune -a
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Performance Issues
|
||||
|
||||
### Slow Response Times
|
||||
|
||||
**Symptoms**: Tools taking > 5 seconds to respond.
|
||||
|
||||
**Diagnosis**:
|
||||
```bash
|
||||
# Check health metrics
|
||||
python -c "
|
||||
from core.health import HealthMonitor
|
||||
monitor = HealthMonitor()
|
||||
print(monitor.get_all_health())
|
||||
"
|
||||
```
|
||||
|
||||
**Solutions**:
|
||||
|
||||
1. **Check WordPress site performance**:
|
||||
```bash
|
||||
# Test direct WordPress API
|
||||
time curl https://your-site.com/wp-json/wp/v2/posts
|
||||
```
|
||||
|
||||
2. **Enable caching** on WordPress:
|
||||
- Install caching plugin (WP Super Cache, W3 Total Cache)
|
||||
- Enable object caching (Redis/Memcached)
|
||||
|
||||
3. **Optimize database**:
|
||||
```bash
|
||||
# Use WP-CLI tool
|
||||
wordpress_wp_db_optimize(site="site1")
|
||||
```
|
||||
|
||||
4. **Reduce payload size**:
|
||||
```python
|
||||
# Request fewer items
|
||||
wordpress_list_posts(site="site1", per_page=10) # Instead of 100
|
||||
```
|
||||
|
||||
### High CPU Usage
|
||||
|
||||
**Symptoms**: Server using 100% CPU.
|
||||
|
||||
**Solutions**:
|
||||
|
||||
1. **Check for infinite loops** in logs:
|
||||
```bash
|
||||
tail -f logs/audit.log | grep ERROR
|
||||
```
|
||||
|
||||
2. **Limit concurrent requests**:
|
||||
```bash
|
||||
# In .env
|
||||
MAX_CONCURRENT_REQUESTS=5
|
||||
```
|
||||
|
||||
3. **Optimize queries**:
|
||||
- Use pagination
|
||||
- Filter results
|
||||
- Cache responses
|
||||
|
||||
---
|
||||
|
||||
## Tool Registration Issues
|
||||
|
||||
### Error: "Tool not found"
|
||||
|
||||
**Symptoms**: MCP reports tool doesn't exist.
|
||||
|
||||
**Diagnosis**:
|
||||
```bash
|
||||
# List all registered tools
|
||||
python -c "
|
||||
from server import mcp
|
||||
tools = app.list_tools()
|
||||
for tool in tools:
|
||||
print(tool['name'])
|
||||
"
|
||||
```
|
||||
|
||||
**Solutions**:
|
||||
|
||||
1. **Check site configuration**:
|
||||
```bash
|
||||
# Ensure site is configured in .env
|
||||
grep "WORDPRESS_SITE1" .env
|
||||
```
|
||||
|
||||
2. **Verify plugin loaded**:
|
||||
```bash
|
||||
# Check logs for plugin initialization
|
||||
grep "plugin loaded" logs/audit.log
|
||||
```
|
||||
|
||||
3. **Restart server**:
|
||||
```bash
|
||||
# Tools are registered at startup
|
||||
docker compose restart
|
||||
```
|
||||
|
||||
### Duplicate Tools
|
||||
|
||||
**Symptoms**: Same tool name registered multiple times.
|
||||
|
||||
**Cause**: Multiple plugins or sites with same alias.
|
||||
|
||||
**Solution**:
|
||||
|
||||
1. **Check for duplicate aliases**:
|
||||
```bash
|
||||
grep "ALIAS" .env | sort
|
||||
```
|
||||
|
||||
2. **Ensure unique aliases**:
|
||||
```bash
|
||||
WORDPRESS_SITE1_ALIAS=mainsite
|
||||
WORDPRESS_SITE2_ALIAS=shop # Not mainsite
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Logging and Debugging
|
||||
|
||||
### Enable Debug Logging
|
||||
|
||||
```bash
|
||||
# In .env
|
||||
LOG_LEVEL=DEBUG
|
||||
|
||||
# Restart server
|
||||
docker compose restart
|
||||
```
|
||||
|
||||
### View Real-time Logs
|
||||
|
||||
```bash
|
||||
# Audit logs
|
||||
tail -f logs/audit.log
|
||||
|
||||
# Application logs
|
||||
docker compose logs -f
|
||||
|
||||
# Filter for errors
|
||||
grep ERROR logs/audit.log
|
||||
|
||||
# Filter for specific site
|
||||
grep "site1" logs/audit.log
|
||||
```
|
||||
|
||||
### Common Log Patterns
|
||||
|
||||
**Successful request**:
|
||||
```json
|
||||
{
|
||||
"timestamp": "2025-11-11T10:30:00Z",
|
||||
"event_type": "tool_execution",
|
||||
"level": "INFO",
|
||||
"event": "wordpress_list_posts",
|
||||
"details": {
|
||||
"site": "site1",
|
||||
"status": "success"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Failed request**:
|
||||
```json
|
||||
{
|
||||
"timestamp": "2025-11-11T10:30:00Z",
|
||||
"event_type": "tool_execution",
|
||||
"level": "ERROR",
|
||||
"event": "wordpress_list_posts",
|
||||
"details": {
|
||||
"site": "site1",
|
||||
"error": "Connection timeout",
|
||||
"status": "failed"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Debugging Tips
|
||||
|
||||
1. **Test with curl**:
|
||||
```bash
|
||||
# Test WordPress directly
|
||||
curl -u "admin:xxxx xxxx xxxx xxxx xxxx xxxx" \
|
||||
https://your-site.com/wp-json/wp/v2/posts
|
||||
```
|
||||
|
||||
2. **Check environment loading**:
|
||||
```python
|
||||
python -c "
|
||||
from dotenv import load_dotenv
|
||||
import os
|
||||
load_dotenv()
|
||||
print(os.getenv('WORDPRESS_SITE1_URL'))
|
||||
"
|
||||
```
|
||||
|
||||
3. **Test individual components**:
|
||||
```bash
|
||||
# Test WordPress plugin
|
||||
pytest tests/test_wordpress_plugin.py -v
|
||||
|
||||
# Test rate limiter
|
||||
pytest tests/test_rate_limiter.py -v
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Getting Help
|
||||
|
||||
### Before Asking for Help
|
||||
|
||||
1. **Check logs**:
|
||||
```bash
|
||||
tail -50 logs/audit.log
|
||||
```
|
||||
|
||||
2. **Run health check**:
|
||||
```bash
|
||||
python -c "
|
||||
from core.health import HealthMonitor
|
||||
monitor = HealthMonitor()
|
||||
print(monitor.check_all_projects())
|
||||
"
|
||||
```
|
||||
|
||||
3. **Verify configuration**:
|
||||
```bash
|
||||
# Mask sensitive data
|
||||
grep -v "PASSWORD\|KEY\|SECRET" .env
|
||||
```
|
||||
|
||||
4. **Test connectivity**:
|
||||
```bash
|
||||
curl -I https://your-wordpress-site.com
|
||||
```
|
||||
|
||||
### What to Include in Bug Reports
|
||||
|
||||
1. **Environment information**:
|
||||
- Python version: `python --version`
|
||||
- Docker version: `docker --version`
|
||||
- OS: `uname -a` (Linux/Mac) or `systeminfo` (Windows)
|
||||
|
||||
2. **Error messages**:
|
||||
- Full error output
|
||||
- Stack trace if available
|
||||
- Relevant log entries
|
||||
|
||||
3. **Configuration** (mask sensitive data):
|
||||
- Relevant .env variables
|
||||
- docker-compose.yml modifications
|
||||
- WordPress/WooCommerce versions
|
||||
|
||||
4. **Steps to reproduce**:
|
||||
- Exact commands run
|
||||
- Expected vs actual behavior
|
||||
- Frequency (always, sometimes, once)
|
||||
|
||||
### Contact Information
|
||||
|
||||
**Email**: hello@mcphub.dev
|
||||
|
||||
**Subject format**: `[BUG] Brief description`
|
||||
|
||||
**Example**:
|
||||
```
|
||||
Subject: [BUG] wordpress_list_posts returns 403 error
|
||||
|
||||
Environment:
|
||||
- Python 3.11.5
|
||||
- Docker 24.0.6
|
||||
- WordPress 6.4
|
||||
- Ubuntu 22.04
|
||||
|
||||
Issue:
|
||||
wordpress_list_posts(site="site1") returns 403 Forbidden
|
||||
|
||||
Steps to reproduce:
|
||||
1. Configure WORDPRESS_SITE1_* in .env
|
||||
2. Run python server.py
|
||||
3. Call wordpress_list_posts(site="site1", per_page=10)
|
||||
4. Receive 403 error
|
||||
|
||||
Logs:
|
||||
[Include relevant log entries]
|
||||
|
||||
Configuration:
|
||||
WORDPRESS_SITE1_URL=https://example.com
|
||||
WORDPRESS_SITE1_USERNAME=admin
|
||||
[Application password masked]
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
---
|
||||
23
examples/.env.example
Normal file
23
examples/.env.example
Normal file
@@ -0,0 +1,23 @@
|
||||
# Example Environment Configuration
|
||||
# Copy this file to .env and fill in your credentials
|
||||
|
||||
# Primary WordPress Site
|
||||
WORDPRESS_SITE1_URL=https://example.com
|
||||
WORDPRESS_SITE1_USERNAME=admin
|
||||
WORDPRESS_SITE1_APP_PASSWORD=xxxx xxxx xxxx xxxx xxxx xxxx
|
||||
WORDPRESS_SITE1_WC_CONSUMER_KEY=ck_xxxxxxxxxxxxxxxxxxxx
|
||||
WORDPRESS_SITE1_WC_CONSUMER_SECRET=cs_xxxxxxxxxxxxxxxxxxxx
|
||||
WORDPRESS_SITE1_ALIAS=mainsite
|
||||
|
||||
# Secondary WordPress Site (Optional)
|
||||
WORDPRESS_SITE2_URL=https://shop.example.com
|
||||
WORDPRESS_SITE2_USERNAME=admin
|
||||
WORDPRESS_SITE2_APP_PASSWORD=yyyy yyyy yyyy yyyy yyyy yyyy
|
||||
WORDPRESS_SITE2_WC_CONSUMER_KEY=ck_yyyyyyyyyyyyyyyyyyyy
|
||||
WORDPRESS_SITE2_WC_CONSUMER_SECRET=cs_yyyyyyyyyyyyyyyyyyyy
|
||||
WORDPRESS_SITE2_ALIAS=shop
|
||||
|
||||
# Rate Limiting
|
||||
RATE_LIMIT_PER_MINUTE=60
|
||||
RATE_LIMIT_PER_HOUR=1000
|
||||
RATE_LIMIT_PER_DAY=10000
|
||||
145
examples/README.md
Normal file
145
examples/README.md
Normal file
@@ -0,0 +1,145 @@
|
||||
# 📚 Examples
|
||||
|
||||
This directory contains practical examples demonstrating how to use MCP Hub tools.
|
||||
|
||||
## 📂 Files
|
||||
|
||||
- **[basic_usage.py](basic_usage.py)** - Basic WordPress operations
|
||||
- **[bulk_operations.py](bulk_operations.py)** - Batch processing and bulk updates
|
||||
- **[woocommerce_shop.py](woocommerce_shop.py)** - E-commerce management examples
|
||||
- **[content_migration.py](content_migration.py)** - Migrating content between sites
|
||||
- **[.env.example](.env.example)** - Configuration template for examples
|
||||
|
||||
## 🚀 Quick Start
|
||||
|
||||
### 1. Setup Environment
|
||||
|
||||
```bash
|
||||
# Copy environment template
|
||||
cp examples/.env.example examples/.env
|
||||
|
||||
# Edit with your WordPress credentials
|
||||
nano examples/.env
|
||||
```
|
||||
|
||||
### 2. Run Examples
|
||||
|
||||
```bash
|
||||
# Activate virtual environment
|
||||
source venv/bin/activate # Linux/Mac
|
||||
.\venv\Scripts\Activate.ps1 # Windows
|
||||
|
||||
# Run an example
|
||||
python examples/basic_usage.py
|
||||
```
|
||||
|
||||
## 📖 Example Descriptions
|
||||
|
||||
### Basic Usage
|
||||
|
||||
Demonstrates fundamental operations:
|
||||
- Listing posts
|
||||
- Creating posts
|
||||
- Updating posts
|
||||
- Deleting posts
|
||||
- Working with media
|
||||
- Managing categories
|
||||
|
||||
**Use Case**: Learning the basics, quick operations
|
||||
|
||||
### Bulk Operations
|
||||
|
||||
Shows how to handle large-scale tasks:
|
||||
- Bulk post creation
|
||||
- Batch updates
|
||||
- Mass deletion
|
||||
- Progress tracking
|
||||
- Error handling
|
||||
|
||||
**Use Case**: Content migrations, mass updates, cleanup
|
||||
|
||||
### WooCommerce Shop
|
||||
|
||||
E-commerce management examples:
|
||||
- Product CRUD operations
|
||||
- Inventory management
|
||||
- Order processing
|
||||
- Customer management
|
||||
- Sales reports
|
||||
|
||||
**Use Case**: Online store management, inventory sync
|
||||
|
||||
### Content Migration
|
||||
|
||||
Cross-site content transfer:
|
||||
- Migrating posts between sites
|
||||
- Copying pages
|
||||
- Media migration
|
||||
- Taxonomy mapping
|
||||
- URL rewriting
|
||||
|
||||
**Use Case**: Site consolidation, content backup, multi-site sync
|
||||
|
||||
## 🔧 Prerequisites
|
||||
|
||||
1. **Configured WordPress sites** in `.env`
|
||||
2. **Valid credentials** (Application Passwords)
|
||||
3. **WooCommerce** installed (for e-commerce examples)
|
||||
4. **Sufficient permissions** on WordPress sites
|
||||
|
||||
## 💡 Tips
|
||||
|
||||
### Error Handling
|
||||
|
||||
All examples include comprehensive error handling:
|
||||
|
||||
```python
|
||||
try:
|
||||
result = wordpress_create_post(...)
|
||||
print(f"Success: {result}")
|
||||
except Exception as e:
|
||||
print(f"Error: {e}")
|
||||
# Log error, retry, or handle appropriately
|
||||
```
|
||||
|
||||
### Rate Limiting
|
||||
|
||||
Examples respect rate limits:
|
||||
|
||||
```python
|
||||
import time
|
||||
|
||||
for item in items:
|
||||
process(item)
|
||||
time.sleep(0.1) # Avoid hitting rate limits
|
||||
```
|
||||
|
||||
### Progress Tracking
|
||||
|
||||
For long-running operations:
|
||||
|
||||
```python
|
||||
from tqdm import tqdm
|
||||
|
||||
for post in tqdm(posts, desc="Processing posts"):
|
||||
update_post(post)
|
||||
```
|
||||
|
||||
## 🧪 Testing Examples
|
||||
|
||||
Before running on production:
|
||||
|
||||
1. **Test on staging site first**
|
||||
2. **Backup your WordPress site**
|
||||
3. **Start with small batches**
|
||||
4. **Verify results manually**
|
||||
|
||||
## 📞 Support
|
||||
|
||||
If you encounter issues with examples:
|
||||
|
||||
- Check [Troubleshooting Guide](../docs/troubleshooting.md)
|
||||
- Review [Getting Started](../docs/getting-started.md)
|
||||
- Contact: hello@mcphub.dev
|
||||
|
||||
---
|
||||
293
examples/basic_usage.py
Normal file
293
examples/basic_usage.py
Normal file
@@ -0,0 +1,293 @@
|
||||
"""
|
||||
Basic Usage Examples for MCP Hub
|
||||
|
||||
This example demonstrates fundamental WordPress operations using MCP tools.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
from datetime import datetime
|
||||
|
||||
from dotenv import load_dotenv
|
||||
|
||||
# Load environment variables
|
||||
load_dotenv()
|
||||
|
||||
# Example 1: List Posts
|
||||
async def list_posts_example():
|
||||
"""List published posts from a WordPress site."""
|
||||
print("\n" + "=" * 50)
|
||||
print("Example 1: List Posts")
|
||||
print("=" * 50)
|
||||
|
||||
try:
|
||||
# Using unified tools (recommended)
|
||||
from src.main import app
|
||||
|
||||
# List first 5 published posts
|
||||
result = await app.call_tool(
|
||||
"wordpress_list_posts",
|
||||
arguments={"site": "mainsite", "per_page": 5, "status": "publish"}, # Use site alias
|
||||
)
|
||||
|
||||
posts = json.loads(result)
|
||||
print(f"\nFound {len(posts)} posts:")
|
||||
for post in posts:
|
||||
print(f" - [{post['id']}] {post['title']['rendered']}")
|
||||
print(f" Status: {post['status']}, Date: {post['date']}")
|
||||
|
||||
except Exception as e:
|
||||
print(f"Error: {e}")
|
||||
|
||||
# Example 2: Create a Post
|
||||
async def create_post_example():
|
||||
"""Create a new WordPress post."""
|
||||
print("\n" + "=" * 50)
|
||||
print("Example 2: Create Post")
|
||||
print("=" * 50)
|
||||
|
||||
try:
|
||||
from src.main import app
|
||||
|
||||
# Create a new post
|
||||
result = await app.call_tool(
|
||||
"wordpress_create_post",
|
||||
arguments={
|
||||
"site": "mainsite",
|
||||
"title": f"Test Post - {datetime.now().strftime('%Y-%m-%d %H:%M')}",
|
||||
"content": "<p>This is a test post created via MCP tools.</p>",
|
||||
"status": "draft", # Start as draft
|
||||
"excerpt": "A test post demonstrating MCP tool usage",
|
||||
"categories": [1], # Uncategorized
|
||||
"tags": [],
|
||||
"featured_media": None,
|
||||
"slug": None,
|
||||
},
|
||||
)
|
||||
|
||||
post = json.loads(result)
|
||||
print("\nPost created successfully!")
|
||||
print(f" ID: {post['id']}")
|
||||
print(f" Title: {post['title']['rendered']}")
|
||||
print(f" Status: {post['status']}")
|
||||
print(f" Edit URL: {post['link']}")
|
||||
|
||||
return post["id"] # Return for next examples
|
||||
|
||||
except Exception as e:
|
||||
print(f"Error: {e}")
|
||||
return None
|
||||
|
||||
# Example 3: Update a Post
|
||||
async def update_post_example(post_id):
|
||||
"""Update an existing WordPress post."""
|
||||
print("\n" + "=" * 50)
|
||||
print("Example 3: Update Post")
|
||||
print("=" * 50)
|
||||
|
||||
if not post_id:
|
||||
print("No post ID provided. Skipping.")
|
||||
return
|
||||
|
||||
try:
|
||||
from src.main import app
|
||||
|
||||
# Update the post
|
||||
result = await app.call_tool(
|
||||
"wordpress_update_post",
|
||||
arguments={
|
||||
"site": "mainsite",
|
||||
"post_id": post_id,
|
||||
"title": f"Updated Test Post - {datetime.now().strftime('%H:%M')}",
|
||||
"content": "<p>This post has been updated!</p><p>Updated via MCP tools.</p>",
|
||||
"status": "publish", # Publish the post
|
||||
"slug": None,
|
||||
"excerpt": None,
|
||||
"categories": None,
|
||||
"tags": None,
|
||||
"featured_media": None,
|
||||
},
|
||||
)
|
||||
|
||||
post = json.loads(result)
|
||||
print("\nPost updated successfully!")
|
||||
print(f" ID: {post['id']}")
|
||||
print(f" New Title: {post['title']['rendered']}")
|
||||
print(f" Status: {post['status']}")
|
||||
|
||||
except Exception as e:
|
||||
print(f"Error: {e}")
|
||||
|
||||
# Example 4: List Categories
|
||||
async def list_categories_example():
|
||||
"""List WordPress categories."""
|
||||
print("\n" + "=" * 50)
|
||||
print("Example 4: List Categories")
|
||||
print("=" * 50)
|
||||
|
||||
try:
|
||||
from src.main import app
|
||||
|
||||
result = await app.call_tool(
|
||||
"wordpress_list_categories",
|
||||
arguments={"site": "mainsite", "per_page": 10, "page": 1, "hide_empty": False},
|
||||
)
|
||||
|
||||
categories = json.loads(result)
|
||||
print(f"\nFound {len(categories)} categories:")
|
||||
for cat in categories:
|
||||
print(f" - [{cat['id']}] {cat['name']} ({cat['count']} posts)")
|
||||
|
||||
except Exception as e:
|
||||
print(f"Error: {e}")
|
||||
|
||||
# Example 5: Upload Media
|
||||
async def upload_media_example():
|
||||
"""Upload media from URL to WordPress."""
|
||||
print("\n" + "=" * 50)
|
||||
print("Example 5: Upload Media from URL")
|
||||
print("=" * 50)
|
||||
|
||||
try:
|
||||
from src.main import app
|
||||
|
||||
# Upload an image from URL
|
||||
image_url = "https://via.placeholder.com/800x600.png?text=MCP+Test+Image"
|
||||
|
||||
result = await app.call_tool(
|
||||
"wordpress_upload_media_from_url",
|
||||
arguments={
|
||||
"site": "mainsite",
|
||||
"url": image_url,
|
||||
"title": "MCP Test Image",
|
||||
"alt_text": "Test image uploaded via MCP",
|
||||
"caption": "Uploaded using MCP Hub",
|
||||
},
|
||||
)
|
||||
|
||||
media = json.loads(result)
|
||||
print("\nMedia uploaded successfully!")
|
||||
print(f" ID: {media['id']}")
|
||||
print(f" Title: {media['title']['rendered']}")
|
||||
print(f" URL: {media['source_url']}")
|
||||
print(f" Size: {media['media_details'].get('filesize', 'unknown')} bytes")
|
||||
|
||||
return media["id"]
|
||||
|
||||
except Exception as e:
|
||||
print(f"Error: {e}")
|
||||
return None
|
||||
|
||||
# Example 6: Get Site Health
|
||||
async def site_health_example():
|
||||
"""Check WordPress site health."""
|
||||
print("\n" + "=" * 50)
|
||||
print("Example 6: Site Health Check")
|
||||
print("=" * 50)
|
||||
|
||||
try:
|
||||
from src.main import app
|
||||
|
||||
result = await app.call_tool("wordpress_get_site_health", arguments={"site": "mainsite"})
|
||||
|
||||
health = json.loads(result)
|
||||
print("\nSite Health:")
|
||||
print(f" Status: {health.get('status', 'unknown')}")
|
||||
print(f" WordPress: {health.get('wordpress_version', 'unknown')}")
|
||||
print(f" PHP: {health.get('php_version', 'unknown')}")
|
||||
print(f" Accessible: {health.get('accessible', 'unknown')}")
|
||||
|
||||
except Exception as e:
|
||||
print(f"Error: {e}")
|
||||
|
||||
# Example 7: Get System Metrics
|
||||
async def system_metrics_example():
|
||||
"""Check MCP server health and metrics."""
|
||||
print("\n" + "=" * 50)
|
||||
print("Example 7: System Metrics")
|
||||
print("=" * 50)
|
||||
|
||||
try:
|
||||
from src.main import app
|
||||
|
||||
result = await app.call_tool("get_system_metrics", arguments={})
|
||||
|
||||
metrics = json.loads(result)
|
||||
print("\nSystem Metrics:")
|
||||
print(f" Uptime: {metrics['uptime_seconds']:.2f} seconds")
|
||||
print(f" Total Requests: {metrics['total_requests']}")
|
||||
print(
|
||||
f" Success Rate: {(metrics['successful_requests']/metrics['total_requests']*100):.1f}%"
|
||||
)
|
||||
print(f" Avg Response Time: {metrics['average_response_time_ms']:.2f}ms")
|
||||
|
||||
except Exception as e:
|
||||
print(f"Error: {e}")
|
||||
|
||||
# Example 8: Delete Post
|
||||
async def delete_post_example(post_id):
|
||||
"""Delete a WordPress post."""
|
||||
print("\n" + "=" * 50)
|
||||
print("Example 8: Delete Post")
|
||||
print("=" * 50)
|
||||
|
||||
if not post_id:
|
||||
print("No post ID provided. Skipping.")
|
||||
return
|
||||
|
||||
try:
|
||||
from src.main import app
|
||||
|
||||
# Move to trash (not permanent delete)
|
||||
result = await app.call_tool(
|
||||
"wordpress_delete_post",
|
||||
arguments={
|
||||
"site": "mainsite",
|
||||
"post_id": post_id,
|
||||
"force": False, # Move to trash, not permanent delete
|
||||
},
|
||||
)
|
||||
|
||||
post = json.loads(result)
|
||||
print("\nPost moved to trash!")
|
||||
print(f" ID: {post['id']}")
|
||||
print(f" Title: {post['title']['rendered']}")
|
||||
print(f" Status: {post['status']}")
|
||||
|
||||
except Exception as e:
|
||||
print(f"Error: {e}")
|
||||
|
||||
# Main execution
|
||||
async def main():
|
||||
"""Run all examples in sequence."""
|
||||
print("\n" + "=" * 60)
|
||||
print("MCP Hub - Basic Usage Examples")
|
||||
print("=" * 60)
|
||||
|
||||
# Run examples
|
||||
await list_posts_example()
|
||||
post_id = await create_post_example()
|
||||
await asyncio.sleep(1) # Small delay between operations
|
||||
|
||||
if post_id:
|
||||
await update_post_example(post_id)
|
||||
await asyncio.sleep(1)
|
||||
|
||||
await list_categories_example()
|
||||
await upload_media_example()
|
||||
await site_health_example()
|
||||
await system_metrics_example()
|
||||
|
||||
# Cleanup: Delete the test post
|
||||
if post_id:
|
||||
print("\n" + "=" * 50)
|
||||
print("Cleanup: Removing test post")
|
||||
print("=" * 50)
|
||||
await delete_post_example(post_id)
|
||||
|
||||
print("\n" + "=" * 60)
|
||||
print("Examples completed successfully!")
|
||||
print("=" * 60 + "\n")
|
||||
|
||||
if __name__ == "__main__":
|
||||
asyncio.run(main())
|
||||
50
plugins/__init__.py
Normal file
50
plugins/__init__.py
Normal file
@@ -0,0 +1,50 @@
|
||||
"""
|
||||
Plugins package
|
||||
|
||||
All project type plugins are here.
|
||||
|
||||
v2.8.0 (Phase J): Directus CMS Plugin added
|
||||
v2.6.0 (Phase I): Appwrite Backend Plugin added
|
||||
v2.5.0 (Phase H): OpenPanel Analytics Plugin added
|
||||
v2.3.0 (Phase G): Supabase Self-Hosted Plugin added
|
||||
"""
|
||||
|
||||
from plugins.appwrite.plugin import AppwritePlugin
|
||||
from plugins.base import BasePlugin, PluginRegistry
|
||||
from plugins.directus.plugin import DirectusPlugin
|
||||
from plugins.gitea.plugin import GiteaPlugin
|
||||
from plugins.n8n.plugin import N8nPlugin
|
||||
from plugins.openpanel.plugin import OpenPanelPlugin
|
||||
from plugins.supabase.plugin import SupabasePlugin
|
||||
from plugins.woocommerce.plugin import WooCommercePlugin
|
||||
from plugins.wordpress.plugin import WordPressPlugin
|
||||
from plugins.wordpress_advanced.plugin import WordPressAdvancedPlugin
|
||||
|
||||
# Create global registry
|
||||
registry = PluginRegistry()
|
||||
|
||||
# Register available plugins
|
||||
registry.register("wordpress", WordPressPlugin)
|
||||
registry.register("woocommerce", WooCommercePlugin)
|
||||
registry.register("wordpress_advanced", WordPressAdvancedPlugin)
|
||||
registry.register("gitea", GiteaPlugin)
|
||||
registry.register("n8n", N8nPlugin)
|
||||
registry.register("supabase", SupabasePlugin)
|
||||
registry.register("openpanel", OpenPanelPlugin)
|
||||
registry.register("appwrite", AppwritePlugin)
|
||||
registry.register("directus", DirectusPlugin)
|
||||
|
||||
__all__ = [
|
||||
"BasePlugin",
|
||||
"PluginRegistry",
|
||||
"registry",
|
||||
"WordPressPlugin",
|
||||
"WooCommercePlugin",
|
||||
"WordPressAdvancedPlugin",
|
||||
"GiteaPlugin",
|
||||
"N8nPlugin",
|
||||
"SupabasePlugin",
|
||||
"OpenPanelPlugin",
|
||||
"AppwritePlugin",
|
||||
"DirectusPlugin",
|
||||
]
|
||||
6
plugins/appwrite/__init__.py
Normal file
6
plugins/appwrite/__init__.py
Normal file
@@ -0,0 +1,6 @@
|
||||
"""Appwrite Plugin - Self-Hosted Backend-as-a-Service Management"""
|
||||
|
||||
from plugins.appwrite.client import AppwriteClient
|
||||
from plugins.appwrite.plugin import AppwritePlugin
|
||||
|
||||
__all__ = ["AppwritePlugin", "AppwriteClient"]
|
||||
1611
plugins/appwrite/client.py
Normal file
1611
plugins/appwrite/client.py
Normal file
File diff suppressed because it is too large
Load Diff
29
plugins/appwrite/handlers/__init__.py
Normal file
29
plugins/appwrite/handlers/__init__.py
Normal file
@@ -0,0 +1,29 @@
|
||||
"""Appwrite Plugin Handlers"""
|
||||
|
||||
from plugins.appwrite.handlers import (
|
||||
databases,
|
||||
documents,
|
||||
# Phase I.4
|
||||
functions,
|
||||
messaging,
|
||||
# Phase I.3
|
||||
storage,
|
||||
system,
|
||||
teams,
|
||||
# Phase I.2
|
||||
users,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
"databases",
|
||||
"documents",
|
||||
"system",
|
||||
# Phase I.2
|
||||
"users",
|
||||
"teams",
|
||||
# Phase I.3
|
||||
"storage",
|
||||
# Phase I.4
|
||||
"functions",
|
||||
"messaging",
|
||||
]
|
||||
809
plugins/appwrite/handlers/databases.py
Normal file
809
plugins/appwrite/handlers/databases.py
Normal file
@@ -0,0 +1,809 @@
|
||||
"""
|
||||
Databases Handler - manages Appwrite databases, collections, attributes, and indexes
|
||||
|
||||
Phase I.1: 18 tools
|
||||
- Databases: 5 (list, get, create, update, delete)
|
||||
- Collections: 5 (list, get, create, update, delete)
|
||||
- Attributes: 5 (list, create_string, create_integer, create_boolean, delete)
|
||||
- Indexes: 3 (list, create, delete)
|
||||
"""
|
||||
|
||||
import json
|
||||
from typing import Any
|
||||
|
||||
from plugins.appwrite.client import AppwriteClient
|
||||
|
||||
def get_tool_specifications() -> list[dict[str, Any]]:
|
||||
"""Return tool specifications for ToolGenerator (18 tools)"""
|
||||
return [
|
||||
# =====================
|
||||
# DATABASES (5)
|
||||
# =====================
|
||||
{
|
||||
"name": "list_databases",
|
||||
"method_name": "list_databases",
|
||||
"description": "List all databases in the Appwrite project. Returns database ID, name, and creation date.",
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"queries": {
|
||||
"anyOf": [{"type": "array", "items": {"type": "string"}}, {"type": "null"}],
|
||||
"description": "Query strings for filtering (e.g., 'limit(25)', 'offset(0)')",
|
||||
},
|
||||
"search": {
|
||||
"anyOf": [{"type": "string"}, {"type": "null"}],
|
||||
"description": "Search term to filter databases by name",
|
||||
},
|
||||
},
|
||||
"required": [],
|
||||
},
|
||||
"scope": "read",
|
||||
},
|
||||
{
|
||||
"name": "get_database",
|
||||
"method_name": "get_database",
|
||||
"description": "Get database details by ID.",
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {"database_id": {"type": "string", "description": "Database ID"}},
|
||||
"required": ["database_id"],
|
||||
},
|
||||
"scope": "read",
|
||||
},
|
||||
{
|
||||
"name": "create_database",
|
||||
"method_name": "create_database",
|
||||
"description": "Create a new database. Use 'unique()' as database_id to auto-generate a unique ID.",
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"database_id": {
|
||||
"type": "string",
|
||||
"description": "Unique database ID. Use 'unique()' for auto-generation",
|
||||
},
|
||||
"name": {"type": "string", "description": "Database name"},
|
||||
"enabled": {
|
||||
"type": "boolean",
|
||||
"description": "Enable or disable the database",
|
||||
"default": True,
|
||||
},
|
||||
},
|
||||
"required": ["database_id", "name"],
|
||||
},
|
||||
"scope": "write",
|
||||
},
|
||||
{
|
||||
"name": "update_database",
|
||||
"method_name": "update_database",
|
||||
"description": "Update database name or enabled status.",
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"database_id": {"type": "string", "description": "Database ID"},
|
||||
"name": {"type": "string", "description": "New database name"},
|
||||
"enabled": {
|
||||
"anyOf": [{"type": "boolean"}, {"type": "null"}],
|
||||
"description": "Enable or disable the database",
|
||||
},
|
||||
},
|
||||
"required": ["database_id", "name"],
|
||||
},
|
||||
"scope": "write",
|
||||
},
|
||||
{
|
||||
"name": "delete_database",
|
||||
"method_name": "delete_database",
|
||||
"description": "Delete a database and all its collections. This action is irreversible.",
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"database_id": {"type": "string", "description": "Database ID to delete"}
|
||||
},
|
||||
"required": ["database_id"],
|
||||
},
|
||||
"scope": "admin",
|
||||
},
|
||||
# =====================
|
||||
# COLLECTIONS (5)
|
||||
# =====================
|
||||
{
|
||||
"name": "list_collections",
|
||||
"method_name": "list_collections",
|
||||
"description": "List all collections in a database.",
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"database_id": {"type": "string", "description": "Database ID"},
|
||||
"queries": {
|
||||
"anyOf": [{"type": "array", "items": {"type": "string"}}, {"type": "null"}],
|
||||
"description": "Query strings for filtering",
|
||||
},
|
||||
"search": {
|
||||
"anyOf": [{"type": "string"}, {"type": "null"}],
|
||||
"description": "Search term to filter collections",
|
||||
},
|
||||
},
|
||||
"required": ["database_id"],
|
||||
},
|
||||
"scope": "read",
|
||||
},
|
||||
{
|
||||
"name": "get_collection",
|
||||
"method_name": "get_collection",
|
||||
"description": "Get collection details including attributes and indexes.",
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"database_id": {"type": "string", "description": "Database ID"},
|
||||
"collection_id": {"type": "string", "description": "Collection ID"},
|
||||
},
|
||||
"required": ["database_id", "collection_id"],
|
||||
},
|
||||
"scope": "read",
|
||||
},
|
||||
{
|
||||
"name": "create_collection",
|
||||
"method_name": "create_collection",
|
||||
"description": "Create a new collection in a database. Use 'unique()' for auto-generated ID.",
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"database_id": {"type": "string", "description": "Database ID"},
|
||||
"collection_id": {
|
||||
"type": "string",
|
||||
"description": "Unique collection ID. Use 'unique()' for auto-generation",
|
||||
},
|
||||
"name": {"type": "string", "description": "Collection name"},
|
||||
"permissions": {
|
||||
"anyOf": [{"type": "array", "items": {"type": "string"}}, {"type": "null"}],
|
||||
"description": "Collection permissions (e.g., 'read(\"any\")', 'write(\"users\")')",
|
||||
},
|
||||
"document_security": {
|
||||
"type": "boolean",
|
||||
"description": "Enable document-level security",
|
||||
"default": True,
|
||||
},
|
||||
"enabled": {
|
||||
"type": "boolean",
|
||||
"description": "Enable or disable the collection",
|
||||
"default": True,
|
||||
},
|
||||
},
|
||||
"required": ["database_id", "collection_id", "name"],
|
||||
},
|
||||
"scope": "write",
|
||||
},
|
||||
{
|
||||
"name": "update_collection",
|
||||
"method_name": "update_collection",
|
||||
"description": "Update collection settings.",
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"database_id": {"type": "string", "description": "Database ID"},
|
||||
"collection_id": {"type": "string", "description": "Collection ID"},
|
||||
"name": {"type": "string", "description": "New collection name"},
|
||||
"permissions": {
|
||||
"anyOf": [{"type": "array", "items": {"type": "string"}}, {"type": "null"}],
|
||||
"description": "New permissions",
|
||||
},
|
||||
"document_security": {
|
||||
"anyOf": [{"type": "boolean"}, {"type": "null"}],
|
||||
"description": "Enable/disable document-level security",
|
||||
},
|
||||
"enabled": {
|
||||
"anyOf": [{"type": "boolean"}, {"type": "null"}],
|
||||
"description": "Enable/disable the collection",
|
||||
},
|
||||
},
|
||||
"required": ["database_id", "collection_id", "name"],
|
||||
},
|
||||
"scope": "write",
|
||||
},
|
||||
{
|
||||
"name": "delete_collection",
|
||||
"method_name": "delete_collection",
|
||||
"description": "Delete a collection and all its documents. This action is irreversible.",
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"database_id": {"type": "string", "description": "Database ID"},
|
||||
"collection_id": {"type": "string", "description": "Collection ID to delete"},
|
||||
},
|
||||
"required": ["database_id", "collection_id"],
|
||||
},
|
||||
"scope": "admin",
|
||||
},
|
||||
# =====================
|
||||
# ATTRIBUTES (5)
|
||||
# =====================
|
||||
{
|
||||
"name": "list_attributes",
|
||||
"method_name": "list_attributes",
|
||||
"description": "List all attributes (fields/columns) of a collection.",
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"database_id": {"type": "string", "description": "Database ID"},
|
||||
"collection_id": {"type": "string", "description": "Collection ID"},
|
||||
"queries": {
|
||||
"anyOf": [{"type": "array", "items": {"type": "string"}}, {"type": "null"}],
|
||||
"description": "Query strings for filtering",
|
||||
},
|
||||
},
|
||||
"required": ["database_id", "collection_id"],
|
||||
},
|
||||
"scope": "read",
|
||||
},
|
||||
{
|
||||
"name": "create_string_attribute",
|
||||
"method_name": "create_string_attribute",
|
||||
"description": "Create a string attribute in a collection.",
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"database_id": {"type": "string", "description": "Database ID"},
|
||||
"collection_id": {"type": "string", "description": "Collection ID"},
|
||||
"key": {"type": "string", "description": "Attribute key (field name)"},
|
||||
"size": {
|
||||
"type": "integer",
|
||||
"description": "Maximum string length",
|
||||
"default": 255,
|
||||
},
|
||||
"required": {
|
||||
"type": "boolean",
|
||||
"description": "Is this field required?",
|
||||
"default": False,
|
||||
},
|
||||
"default": {
|
||||
"anyOf": [{"type": "string"}, {"type": "null"}],
|
||||
"description": "Default value",
|
||||
},
|
||||
"array": {
|
||||
"type": "boolean",
|
||||
"description": "Is this an array attribute?",
|
||||
"default": False,
|
||||
},
|
||||
"encrypt": {
|
||||
"type": "boolean",
|
||||
"description": "Encrypt the attribute value",
|
||||
"default": False,
|
||||
},
|
||||
},
|
||||
"required": ["database_id", "collection_id", "key"],
|
||||
},
|
||||
"scope": "write",
|
||||
},
|
||||
{
|
||||
"name": "create_integer_attribute",
|
||||
"method_name": "create_integer_attribute",
|
||||
"description": "Create an integer attribute in a collection.",
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"database_id": {"type": "string", "description": "Database ID"},
|
||||
"collection_id": {"type": "string", "description": "Collection ID"},
|
||||
"key": {"type": "string", "description": "Attribute key (field name)"},
|
||||
"required": {
|
||||
"type": "boolean",
|
||||
"description": "Is this field required?",
|
||||
"default": False,
|
||||
},
|
||||
"min": {
|
||||
"anyOf": [{"type": "integer"}, {"type": "null"}],
|
||||
"description": "Minimum value",
|
||||
},
|
||||
"max": {
|
||||
"anyOf": [{"type": "integer"}, {"type": "null"}],
|
||||
"description": "Maximum value",
|
||||
},
|
||||
"default": {
|
||||
"anyOf": [{"type": "integer"}, {"type": "null"}],
|
||||
"description": "Default value",
|
||||
},
|
||||
"array": {
|
||||
"type": "boolean",
|
||||
"description": "Is this an array attribute?",
|
||||
"default": False,
|
||||
},
|
||||
},
|
||||
"required": ["database_id", "collection_id", "key"],
|
||||
},
|
||||
"scope": "write",
|
||||
},
|
||||
{
|
||||
"name": "create_boolean_attribute",
|
||||
"method_name": "create_boolean_attribute",
|
||||
"description": "Create a boolean attribute in a collection.",
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"database_id": {"type": "string", "description": "Database ID"},
|
||||
"collection_id": {"type": "string", "description": "Collection ID"},
|
||||
"key": {"type": "string", "description": "Attribute key (field name)"},
|
||||
"required": {
|
||||
"type": "boolean",
|
||||
"description": "Is this field required?",
|
||||
"default": False,
|
||||
},
|
||||
"default": {
|
||||
"anyOf": [{"type": "boolean"}, {"type": "null"}],
|
||||
"description": "Default value",
|
||||
},
|
||||
"array": {
|
||||
"type": "boolean",
|
||||
"description": "Is this an array attribute?",
|
||||
"default": False,
|
||||
},
|
||||
},
|
||||
"required": ["database_id", "collection_id", "key"],
|
||||
},
|
||||
"scope": "write",
|
||||
},
|
||||
{
|
||||
"name": "delete_attribute",
|
||||
"method_name": "delete_attribute",
|
||||
"description": "Delete an attribute from a collection. This will remove the field from all documents.",
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"database_id": {"type": "string", "description": "Database ID"},
|
||||
"collection_id": {"type": "string", "description": "Collection ID"},
|
||||
"key": {"type": "string", "description": "Attribute key to delete"},
|
||||
},
|
||||
"required": ["database_id", "collection_id", "key"],
|
||||
},
|
||||
"scope": "admin",
|
||||
},
|
||||
# =====================
|
||||
# INDEXES (3)
|
||||
# =====================
|
||||
{
|
||||
"name": "list_indexes",
|
||||
"method_name": "list_indexes",
|
||||
"description": "List all indexes of a collection.",
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"database_id": {"type": "string", "description": "Database ID"},
|
||||
"collection_id": {"type": "string", "description": "Collection ID"},
|
||||
"queries": {
|
||||
"anyOf": [{"type": "array", "items": {"type": "string"}}, {"type": "null"}],
|
||||
"description": "Query strings for filtering",
|
||||
},
|
||||
},
|
||||
"required": ["database_id", "collection_id"],
|
||||
},
|
||||
"scope": "read",
|
||||
},
|
||||
{
|
||||
"name": "create_index",
|
||||
"method_name": "create_index",
|
||||
"description": "Create an index on collection attributes for faster queries.",
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"database_id": {"type": "string", "description": "Database ID"},
|
||||
"collection_id": {"type": "string", "description": "Collection ID"},
|
||||
"key": {"type": "string", "description": "Index key (unique name)"},
|
||||
"type": {
|
||||
"type": "string",
|
||||
"enum": ["key", "unique", "fulltext"],
|
||||
"description": "Index type: key (regular), unique, or fulltext",
|
||||
},
|
||||
"attributes": {
|
||||
"type": "array",
|
||||
"items": {"type": "string"},
|
||||
"description": "Array of attribute keys to index",
|
||||
},
|
||||
"orders": {
|
||||
"anyOf": [
|
||||
{"type": "array", "items": {"type": "string", "enum": ["ASC", "DESC"]}},
|
||||
{"type": "null"},
|
||||
],
|
||||
"description": "Sort order for each attribute (ASC or DESC)",
|
||||
},
|
||||
},
|
||||
"required": ["database_id", "collection_id", "key", "type", "attributes"],
|
||||
},
|
||||
"scope": "write",
|
||||
},
|
||||
{
|
||||
"name": "delete_index",
|
||||
"method_name": "delete_index",
|
||||
"description": "Delete an index from a collection.",
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"database_id": {"type": "string", "description": "Database ID"},
|
||||
"collection_id": {"type": "string", "description": "Collection ID"},
|
||||
"key": {"type": "string", "description": "Index key to delete"},
|
||||
},
|
||||
"required": ["database_id", "collection_id", "key"],
|
||||
},
|
||||
"scope": "admin",
|
||||
},
|
||||
]
|
||||
|
||||
# =====================
|
||||
# HANDLER FUNCTIONS
|
||||
# =====================
|
||||
|
||||
async def list_databases(
|
||||
client: AppwriteClient, queries: list[str] | None = None, search: str | None = None
|
||||
) -> str:
|
||||
"""List all databases."""
|
||||
try:
|
||||
result = await client.list_databases(queries=queries, search=search)
|
||||
databases = result.get("databases", [])
|
||||
|
||||
response = {
|
||||
"success": True,
|
||||
"total": result.get("total", len(databases)),
|
||||
"databases": databases,
|
||||
}
|
||||
return json.dumps(response, indent=2, ensure_ascii=False)
|
||||
|
||||
except Exception as e:
|
||||
return json.dumps({"success": False, "error": str(e)}, indent=2)
|
||||
|
||||
async def get_database(client: AppwriteClient, database_id: str) -> str:
|
||||
"""Get database by ID."""
|
||||
try:
|
||||
result = await client.get_database(database_id)
|
||||
return json.dumps({"success": True, "database": result}, indent=2, ensure_ascii=False)
|
||||
|
||||
except Exception as e:
|
||||
return json.dumps({"success": False, "error": str(e)}, indent=2)
|
||||
|
||||
async def create_database(
|
||||
client: AppwriteClient, database_id: str, name: str, enabled: bool = True
|
||||
) -> str:
|
||||
"""Create a new database."""
|
||||
try:
|
||||
result = await client.create_database(database_id=database_id, name=name, enabled=enabled)
|
||||
return json.dumps(
|
||||
{
|
||||
"success": True,
|
||||
"message": f"Database '{name}' created successfully",
|
||||
"database": result,
|
||||
},
|
||||
indent=2,
|
||||
ensure_ascii=False,
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
return json.dumps({"success": False, "error": str(e)}, indent=2)
|
||||
|
||||
async def update_database(
|
||||
client: AppwriteClient, database_id: str, name: str, enabled: bool | None = None
|
||||
) -> str:
|
||||
"""Update database."""
|
||||
try:
|
||||
result = await client.update_database(database_id=database_id, name=name, enabled=enabled)
|
||||
return json.dumps(
|
||||
{"success": True, "message": "Database updated successfully", "database": result},
|
||||
indent=2,
|
||||
ensure_ascii=False,
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
return json.dumps({"success": False, "error": str(e)}, indent=2)
|
||||
|
||||
async def delete_database(client: AppwriteClient, database_id: str) -> str:
|
||||
"""Delete database."""
|
||||
try:
|
||||
await client.delete_database(database_id)
|
||||
return json.dumps(
|
||||
{"success": True, "message": f"Database '{database_id}' deleted successfully"}, indent=2
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
return json.dumps({"success": False, "error": str(e)}, indent=2)
|
||||
|
||||
async def list_collections(
|
||||
client: AppwriteClient,
|
||||
database_id: str,
|
||||
queries: list[str] | None = None,
|
||||
search: str | None = None,
|
||||
) -> str:
|
||||
"""List collections in a database."""
|
||||
try:
|
||||
result = await client.list_collections(
|
||||
database_id=database_id, queries=queries, search=search
|
||||
)
|
||||
collections = result.get("collections", [])
|
||||
|
||||
response = {
|
||||
"success": True,
|
||||
"database_id": database_id,
|
||||
"total": result.get("total", len(collections)),
|
||||
"collections": collections,
|
||||
}
|
||||
return json.dumps(response, indent=2, ensure_ascii=False)
|
||||
|
||||
except Exception as e:
|
||||
return json.dumps({"success": False, "error": str(e)}, indent=2)
|
||||
|
||||
async def get_collection(client: AppwriteClient, database_id: str, collection_id: str) -> str:
|
||||
"""Get collection details."""
|
||||
try:
|
||||
result = await client.get_collection(database_id, collection_id)
|
||||
return json.dumps({"success": True, "collection": result}, indent=2, ensure_ascii=False)
|
||||
|
||||
except Exception as e:
|
||||
return json.dumps({"success": False, "error": str(e)}, indent=2)
|
||||
|
||||
async def create_collection(
|
||||
client: AppwriteClient,
|
||||
database_id: str,
|
||||
collection_id: str,
|
||||
name: str,
|
||||
permissions: list[str] | None = None,
|
||||
document_security: bool = True,
|
||||
enabled: bool = True,
|
||||
) -> str:
|
||||
"""Create a new collection."""
|
||||
try:
|
||||
result = await client.create_collection(
|
||||
database_id=database_id,
|
||||
collection_id=collection_id,
|
||||
name=name,
|
||||
permissions=permissions,
|
||||
document_security=document_security,
|
||||
enabled=enabled,
|
||||
)
|
||||
return json.dumps(
|
||||
{
|
||||
"success": True,
|
||||
"message": f"Collection '{name}' created successfully",
|
||||
"collection": result,
|
||||
},
|
||||
indent=2,
|
||||
ensure_ascii=False,
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
return json.dumps({"success": False, "error": str(e)}, indent=2)
|
||||
|
||||
async def update_collection(
|
||||
client: AppwriteClient,
|
||||
database_id: str,
|
||||
collection_id: str,
|
||||
name: str,
|
||||
permissions: list[str] | None = None,
|
||||
document_security: bool | None = None,
|
||||
enabled: bool | None = None,
|
||||
) -> str:
|
||||
"""Update collection."""
|
||||
try:
|
||||
result = await client.update_collection(
|
||||
database_id=database_id,
|
||||
collection_id=collection_id,
|
||||
name=name,
|
||||
permissions=permissions,
|
||||
document_security=document_security,
|
||||
enabled=enabled,
|
||||
)
|
||||
return json.dumps(
|
||||
{"success": True, "message": "Collection updated successfully", "collection": result},
|
||||
indent=2,
|
||||
ensure_ascii=False,
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
return json.dumps({"success": False, "error": str(e)}, indent=2)
|
||||
|
||||
async def delete_collection(client: AppwriteClient, database_id: str, collection_id: str) -> str:
|
||||
"""Delete collection."""
|
||||
try:
|
||||
await client.delete_collection(database_id, collection_id)
|
||||
return json.dumps(
|
||||
{"success": True, "message": f"Collection '{collection_id}' deleted successfully"},
|
||||
indent=2,
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
return json.dumps({"success": False, "error": str(e)}, indent=2)
|
||||
|
||||
async def list_attributes(
|
||||
client: AppwriteClient, database_id: str, collection_id: str, queries: list[str] | None = None
|
||||
) -> str:
|
||||
"""List collection attributes."""
|
||||
try:
|
||||
result = await client.list_attributes(
|
||||
database_id=database_id, collection_id=collection_id, queries=queries
|
||||
)
|
||||
attributes = result.get("attributes", [])
|
||||
|
||||
response = {
|
||||
"success": True,
|
||||
"database_id": database_id,
|
||||
"collection_id": collection_id,
|
||||
"total": result.get("total", len(attributes)),
|
||||
"attributes": attributes,
|
||||
}
|
||||
return json.dumps(response, indent=2, ensure_ascii=False)
|
||||
|
||||
except Exception as e:
|
||||
return json.dumps({"success": False, "error": str(e)}, indent=2)
|
||||
|
||||
async def create_string_attribute(
|
||||
client: AppwriteClient,
|
||||
database_id: str,
|
||||
collection_id: str,
|
||||
key: str,
|
||||
size: int = 255,
|
||||
required: bool = False,
|
||||
default: str | None = None,
|
||||
array: bool = False,
|
||||
encrypt: bool = False,
|
||||
) -> str:
|
||||
"""Create string attribute."""
|
||||
try:
|
||||
result = await client.create_string_attribute(
|
||||
database_id=database_id,
|
||||
collection_id=collection_id,
|
||||
key=key,
|
||||
size=size,
|
||||
required=required,
|
||||
default=default,
|
||||
array=array,
|
||||
encrypt=encrypt,
|
||||
)
|
||||
return json.dumps(
|
||||
{
|
||||
"success": True,
|
||||
"message": f"String attribute '{key}' created successfully",
|
||||
"attribute": result,
|
||||
},
|
||||
indent=2,
|
||||
ensure_ascii=False,
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
return json.dumps({"success": False, "error": str(e)}, indent=2)
|
||||
|
||||
async def create_integer_attribute(
|
||||
client: AppwriteClient,
|
||||
database_id: str,
|
||||
collection_id: str,
|
||||
key: str,
|
||||
required: bool = False,
|
||||
min: int | None = None,
|
||||
max: int | None = None,
|
||||
default: int | None = None,
|
||||
array: bool = False,
|
||||
) -> str:
|
||||
"""Create integer attribute."""
|
||||
try:
|
||||
result = await client.create_integer_attribute(
|
||||
database_id=database_id,
|
||||
collection_id=collection_id,
|
||||
key=key,
|
||||
required=required,
|
||||
min=min,
|
||||
max=max,
|
||||
default=default,
|
||||
array=array,
|
||||
)
|
||||
return json.dumps(
|
||||
{
|
||||
"success": True,
|
||||
"message": f"Integer attribute '{key}' created successfully",
|
||||
"attribute": result,
|
||||
},
|
||||
indent=2,
|
||||
ensure_ascii=False,
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
return json.dumps({"success": False, "error": str(e)}, indent=2)
|
||||
|
||||
async def create_boolean_attribute(
|
||||
client: AppwriteClient,
|
||||
database_id: str,
|
||||
collection_id: str,
|
||||
key: str,
|
||||
required: bool = False,
|
||||
default: bool | None = None,
|
||||
array: bool = False,
|
||||
) -> str:
|
||||
"""Create boolean attribute."""
|
||||
try:
|
||||
result = await client.create_boolean_attribute(
|
||||
database_id=database_id,
|
||||
collection_id=collection_id,
|
||||
key=key,
|
||||
required=required,
|
||||
default=default,
|
||||
array=array,
|
||||
)
|
||||
return json.dumps(
|
||||
{
|
||||
"success": True,
|
||||
"message": f"Boolean attribute '{key}' created successfully",
|
||||
"attribute": result,
|
||||
},
|
||||
indent=2,
|
||||
ensure_ascii=False,
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
return json.dumps({"success": False, "error": str(e)}, indent=2)
|
||||
|
||||
async def delete_attribute(
|
||||
client: AppwriteClient, database_id: str, collection_id: str, key: str
|
||||
) -> str:
|
||||
"""Delete attribute."""
|
||||
try:
|
||||
await client.delete_attribute(database_id, collection_id, key)
|
||||
return json.dumps(
|
||||
{"success": True, "message": f"Attribute '{key}' deleted successfully"}, indent=2
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
return json.dumps({"success": False, "error": str(e)}, indent=2)
|
||||
|
||||
async def list_indexes(
|
||||
client: AppwriteClient, database_id: str, collection_id: str, queries: list[str] | None = None
|
||||
) -> str:
|
||||
"""List collection indexes."""
|
||||
try:
|
||||
result = await client.list_indexes(
|
||||
database_id=database_id, collection_id=collection_id, queries=queries
|
||||
)
|
||||
indexes = result.get("indexes", [])
|
||||
|
||||
response = {
|
||||
"success": True,
|
||||
"database_id": database_id,
|
||||
"collection_id": collection_id,
|
||||
"total": result.get("total", len(indexes)),
|
||||
"indexes": indexes,
|
||||
}
|
||||
return json.dumps(response, indent=2, ensure_ascii=False)
|
||||
|
||||
except Exception as e:
|
||||
return json.dumps({"success": False, "error": str(e)}, indent=2)
|
||||
|
||||
async def create_index(
|
||||
client: AppwriteClient,
|
||||
database_id: str,
|
||||
collection_id: str,
|
||||
key: str,
|
||||
type: str,
|
||||
attributes: list[str],
|
||||
orders: list[str] | None = None,
|
||||
) -> str:
|
||||
"""Create index."""
|
||||
try:
|
||||
result = await client.create_index(
|
||||
database_id=database_id,
|
||||
collection_id=collection_id,
|
||||
key=key,
|
||||
type=type,
|
||||
attributes=attributes,
|
||||
orders=orders,
|
||||
)
|
||||
return json.dumps(
|
||||
{"success": True, "message": f"Index '{key}' created successfully", "index": result},
|
||||
indent=2,
|
||||
ensure_ascii=False,
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
return json.dumps({"success": False, "error": str(e)}, indent=2)
|
||||
|
||||
async def delete_index(
|
||||
client: AppwriteClient, database_id: str, collection_id: str, key: str
|
||||
) -> str:
|
||||
"""Delete index."""
|
||||
try:
|
||||
await client.delete_index(database_id, collection_id, key)
|
||||
return json.dumps(
|
||||
{"success": True, "message": f"Index '{key}' deleted successfully"}, indent=2
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
return json.dumps({"success": False, "error": str(e)}, indent=2)
|
||||
752
plugins/appwrite/handlers/documents.py
Normal file
752
plugins/appwrite/handlers/documents.py
Normal file
@@ -0,0 +1,752 @@
|
||||
"""
|
||||
Documents Handler - manages Appwrite document CRUD operations
|
||||
|
||||
Phase I.1: 12 tools
|
||||
- Documents CRUD: 5 (list, get, create, update, delete)
|
||||
- Bulk Operations: 3 (bulk_create, bulk_update, bulk_delete)
|
||||
- Query/Count: 4 (search, count, get_by_query, list_with_cursor)
|
||||
"""
|
||||
|
||||
import json
|
||||
from typing import Any
|
||||
|
||||
from plugins.appwrite.client import AppwriteClient
|
||||
|
||||
# =====================
|
||||
# QUERY HELPERS (Appwrite 1.7.4 JSON format)
|
||||
# =====================
|
||||
|
||||
def _query_limit(value: int) -> str:
|
||||
"""Build limit query in JSON format."""
|
||||
return json.dumps({"method": "limit", "values": [value]})
|
||||
|
||||
def _query_offset(value: int) -> str:
|
||||
"""Build offset query in JSON format."""
|
||||
return json.dumps({"method": "offset", "values": [value]})
|
||||
|
||||
def _query_order_asc(attribute: str) -> str:
|
||||
"""Build orderAsc query in JSON format."""
|
||||
return json.dumps({"method": "orderAsc", "values": [attribute]})
|
||||
|
||||
def _query_order_desc(attribute: str) -> str:
|
||||
"""Build orderDesc query in JSON format."""
|
||||
return json.dumps({"method": "orderDesc", "values": [attribute]})
|
||||
|
||||
def _query_cursor_after(document_id: str) -> str:
|
||||
"""Build cursorAfter query in JSON format."""
|
||||
return json.dumps({"method": "cursorAfter", "values": [document_id]})
|
||||
|
||||
def _query_cursor_before(document_id: str) -> str:
|
||||
"""Build cursorBefore query in JSON format."""
|
||||
return json.dumps({"method": "cursorBefore", "values": [document_id]})
|
||||
|
||||
def _query_search(attribute: str, value: str) -> str:
|
||||
"""Build search query in JSON format."""
|
||||
return json.dumps({"method": "search", "attribute": attribute, "values": [value]})
|
||||
|
||||
def _query_equal(attribute: str, values: list[Any]) -> str:
|
||||
"""Build equal query in JSON format."""
|
||||
return json.dumps({"method": "equal", "attribute": attribute, "values": values})
|
||||
|
||||
def get_tool_specifications() -> list[dict[str, Any]]:
|
||||
"""Return tool specifications for ToolGenerator (12 tools)"""
|
||||
return [
|
||||
# =====================
|
||||
# DOCUMENT CRUD (5)
|
||||
# =====================
|
||||
{
|
||||
"name": "list_documents",
|
||||
"method_name": "list_documents",
|
||||
"description": """List documents in a collection with powerful query support.
|
||||
|
||||
Queries must be JSON objects (Appwrite 1.7.4 format):
|
||||
- {"method": "equal", "attribute": "status", "values": ["active"]}
|
||||
- {"method": "notEqual", "attribute": "type", "values": ["draft"]}
|
||||
- {"method": "greaterThan", "attribute": "price", "values": [100]}
|
||||
- {"method": "search", "attribute": "title", "values": ["keyword"]}
|
||||
- {"method": "orderAsc", "values": ["createdAt"]}
|
||||
- {"method": "orderDesc", "values": ["createdAt"]}
|
||||
- {"method": "limit", "values": [25]}
|
||||
- {"method": "offset", "values": [50]}
|
||||
- {"method": "cursorAfter", "values": ["documentId"]}""",
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"database_id": {"type": "string", "description": "Database ID"},
|
||||
"collection_id": {"type": "string", "description": "Collection ID"},
|
||||
"queries": {
|
||||
"anyOf": [{"type": "array", "items": {"type": "string"}}, {"type": "null"}],
|
||||
"description": "Query JSON strings for filtering, sorting, and pagination",
|
||||
"examples": [
|
||||
[
|
||||
'{"method":"equal","attribute":"status","values":["active"]}',
|
||||
'{"method":"limit","values":[25]}',
|
||||
]
|
||||
],
|
||||
},
|
||||
},
|
||||
"required": ["database_id", "collection_id"],
|
||||
},
|
||||
"scope": "read",
|
||||
},
|
||||
{
|
||||
"name": "get_document",
|
||||
"method_name": "get_document",
|
||||
"description": "Get a single document by ID.",
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"database_id": {"type": "string", "description": "Database ID"},
|
||||
"collection_id": {"type": "string", "description": "Collection ID"},
|
||||
"document_id": {"type": "string", "description": "Document ID"},
|
||||
"queries": {
|
||||
"anyOf": [{"type": "array", "items": {"type": "string"}}, {"type": "null"}],
|
||||
"description": "Optional queries for selecting specific attributes",
|
||||
},
|
||||
},
|
||||
"required": ["database_id", "collection_id", "document_id"],
|
||||
},
|
||||
"scope": "read",
|
||||
},
|
||||
{
|
||||
"name": "create_document",
|
||||
"method_name": "create_document",
|
||||
"description": "Create a new document. Use 'unique()' as document_id for auto-generated ID.",
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"database_id": {"type": "string", "description": "Database ID"},
|
||||
"collection_id": {"type": "string", "description": "Collection ID"},
|
||||
"document_id": {
|
||||
"type": "string",
|
||||
"description": "Unique document ID. Use 'unique()' for auto-generation",
|
||||
},
|
||||
"data": {
|
||||
"type": "object",
|
||||
"description": "Document data (key-value pairs matching collection attributes)",
|
||||
},
|
||||
"permissions": {
|
||||
"anyOf": [{"type": "array", "items": {"type": "string"}}, {"type": "null"}],
|
||||
"description": "Document permissions (e.g., 'read(\"user:123\")', 'write(\"team:456\")')",
|
||||
},
|
||||
},
|
||||
"required": ["database_id", "collection_id", "document_id", "data"],
|
||||
},
|
||||
"scope": "write",
|
||||
},
|
||||
{
|
||||
"name": "update_document",
|
||||
"method_name": "update_document",
|
||||
"description": "Update an existing document. Only provided fields will be updated.",
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"database_id": {"type": "string", "description": "Database ID"},
|
||||
"collection_id": {"type": "string", "description": "Collection ID"},
|
||||
"document_id": {"type": "string", "description": "Document ID"},
|
||||
"data": {
|
||||
"anyOf": [{"type": "object"}, {"type": "null"}],
|
||||
"description": "Fields to update",
|
||||
},
|
||||
"permissions": {
|
||||
"anyOf": [{"type": "array", "items": {"type": "string"}}, {"type": "null"}],
|
||||
"description": "New permissions (replaces existing)",
|
||||
},
|
||||
},
|
||||
"required": ["database_id", "collection_id", "document_id"],
|
||||
},
|
||||
"scope": "write",
|
||||
},
|
||||
{
|
||||
"name": "delete_document",
|
||||
"method_name": "delete_document",
|
||||
"description": "Delete a document by ID.",
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"database_id": {"type": "string", "description": "Database ID"},
|
||||
"collection_id": {"type": "string", "description": "Collection ID"},
|
||||
"document_id": {"type": "string", "description": "Document ID to delete"},
|
||||
},
|
||||
"required": ["database_id", "collection_id", "document_id"],
|
||||
},
|
||||
"scope": "write",
|
||||
},
|
||||
# =====================
|
||||
# BULK OPERATIONS (3)
|
||||
# =====================
|
||||
{
|
||||
"name": "bulk_create_documents",
|
||||
"method_name": "bulk_create_documents",
|
||||
"description": """Create multiple documents at once. More efficient than creating one by one.
|
||||
|
||||
Example documents array:
|
||||
[
|
||||
{"document_id": "unique()", "data": {"title": "Doc 1", "status": "active"}},
|
||||
{"document_id": "doc-2", "data": {"title": "Doc 2", "status": "draft"}, "permissions": ["read(\\"any\\")"]}
|
||||
]
|
||||
|
||||
Each document must have:
|
||||
- document_id: Use 'unique()' for auto-generated ID or provide your own
|
||||
- data: Object with field values matching collection attributes""",
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"database_id": {"type": "string", "description": "Database ID"},
|
||||
"collection_id": {"type": "string", "description": "Collection ID"},
|
||||
"documents": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"document_id": {
|
||||
"type": "string",
|
||||
"description": "Document ID. Use 'unique()' for auto-generation",
|
||||
},
|
||||
"data": {
|
||||
"type": "object",
|
||||
"description": "Document data (key-value pairs matching collection attributes)",
|
||||
},
|
||||
"permissions": {
|
||||
"anyOf": [
|
||||
{"type": "array", "items": {"type": "string"}},
|
||||
{"type": "null"},
|
||||
],
|
||||
"description": "Optional permissions for this document",
|
||||
},
|
||||
},
|
||||
"required": ["document_id", "data"],
|
||||
},
|
||||
"description": "Array of documents to create. Each must have document_id and data.",
|
||||
},
|
||||
},
|
||||
"required": ["database_id", "collection_id", "documents"],
|
||||
},
|
||||
"scope": "write",
|
||||
},
|
||||
{
|
||||
"name": "bulk_update_documents",
|
||||
"method_name": "bulk_update_documents",
|
||||
"description": "Update multiple documents at once.",
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"database_id": {"type": "string", "description": "Database ID"},
|
||||
"collection_id": {"type": "string", "description": "Collection ID"},
|
||||
"updates": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"document_id": {"type": "string"},
|
||||
"data": {"type": "object"},
|
||||
"permissions": {
|
||||
"anyOf": [
|
||||
{"type": "array", "items": {"type": "string"}},
|
||||
{"type": "null"},
|
||||
]
|
||||
},
|
||||
},
|
||||
"required": ["document_id", "data"],
|
||||
},
|
||||
"description": "Array of document updates",
|
||||
},
|
||||
},
|
||||
"required": ["database_id", "collection_id", "updates"],
|
||||
},
|
||||
"scope": "write",
|
||||
},
|
||||
{
|
||||
"name": "bulk_delete_documents",
|
||||
"method_name": "bulk_delete_documents",
|
||||
"description": "Delete multiple documents at once.",
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"database_id": {"type": "string", "description": "Database ID"},
|
||||
"collection_id": {"type": "string", "description": "Collection ID"},
|
||||
"document_ids": {
|
||||
"type": "array",
|
||||
"items": {"type": "string"},
|
||||
"description": "Array of document IDs to delete",
|
||||
},
|
||||
},
|
||||
"required": ["database_id", "collection_id", "document_ids"],
|
||||
},
|
||||
"scope": "write",
|
||||
},
|
||||
# =====================
|
||||
# QUERY/SEARCH (4)
|
||||
# =====================
|
||||
{
|
||||
"name": "search_documents",
|
||||
"method_name": "search_documents",
|
||||
"description": "Full-text search in documents. Requires a fulltext index on the searched attribute.",
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"database_id": {"type": "string", "description": "Database ID"},
|
||||
"collection_id": {"type": "string", "description": "Collection ID"},
|
||||
"attribute": {
|
||||
"type": "string",
|
||||
"description": "Attribute name to search in (must have fulltext index)",
|
||||
},
|
||||
"query": {"type": "string", "description": "Search query string"},
|
||||
"limit": {"type": "integer", "description": "Maximum results", "default": 25},
|
||||
},
|
||||
"required": ["database_id", "collection_id", "attribute", "query"],
|
||||
},
|
||||
"scope": "read",
|
||||
},
|
||||
{
|
||||
"name": "count_documents",
|
||||
"method_name": "count_documents",
|
||||
"description": "Count documents in a collection with optional filters.",
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"database_id": {"type": "string", "description": "Database ID"},
|
||||
"collection_id": {"type": "string", "description": "Collection ID"},
|
||||
"queries": {
|
||||
"anyOf": [{"type": "array", "items": {"type": "string"}}, {"type": "null"}],
|
||||
"description": "Optional filter queries",
|
||||
},
|
||||
},
|
||||
"required": ["database_id", "collection_id"],
|
||||
},
|
||||
"scope": "read",
|
||||
},
|
||||
{
|
||||
"name": "get_documents_by_ids",
|
||||
"method_name": "get_documents_by_ids",
|
||||
"description": "Get multiple documents by their IDs.",
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"database_id": {"type": "string", "description": "Database ID"},
|
||||
"collection_id": {"type": "string", "description": "Collection ID"},
|
||||
"document_ids": {
|
||||
"type": "array",
|
||||
"items": {"type": "string"},
|
||||
"description": "Array of document IDs to fetch",
|
||||
},
|
||||
},
|
||||
"required": ["database_id", "collection_id", "document_ids"],
|
||||
},
|
||||
"scope": "read",
|
||||
},
|
||||
{
|
||||
"name": "list_documents_paginated",
|
||||
"method_name": "list_documents_paginated",
|
||||
"description": "List documents with cursor-based pagination for efficient large dataset traversal.",
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"database_id": {"type": "string", "description": "Database ID"},
|
||||
"collection_id": {"type": "string", "description": "Collection ID"},
|
||||
"limit": {
|
||||
"type": "integer",
|
||||
"description": "Results per page",
|
||||
"default": 25,
|
||||
"maximum": 100,
|
||||
},
|
||||
"cursor": {
|
||||
"anyOf": [{"type": "string"}, {"type": "null"}],
|
||||
"description": "Cursor from previous response for next page",
|
||||
},
|
||||
"cursor_direction": {
|
||||
"type": "string",
|
||||
"enum": ["after", "before"],
|
||||
"description": "Cursor direction",
|
||||
"default": "after",
|
||||
},
|
||||
"order_attribute": {
|
||||
"anyOf": [{"type": "string"}, {"type": "null"}],
|
||||
"description": "Attribute to order by (default: $id)",
|
||||
},
|
||||
"order_type": {
|
||||
"type": "string",
|
||||
"enum": ["ASC", "DESC"],
|
||||
"description": "Sort order",
|
||||
"default": "DESC",
|
||||
},
|
||||
"filters": {
|
||||
"anyOf": [{"type": "array", "items": {"type": "string"}}, {"type": "null"}],
|
||||
"description": "Additional filter queries",
|
||||
},
|
||||
},
|
||||
"required": ["database_id", "collection_id"],
|
||||
},
|
||||
"scope": "read",
|
||||
},
|
||||
]
|
||||
|
||||
# =====================
|
||||
# HANDLER FUNCTIONS
|
||||
# =====================
|
||||
|
||||
async def list_documents(
|
||||
client: AppwriteClient, database_id: str, collection_id: str, queries: list[str] | None = None
|
||||
) -> str:
|
||||
"""List documents with queries."""
|
||||
try:
|
||||
result = await client.list_documents(
|
||||
database_id=database_id, collection_id=collection_id, queries=queries
|
||||
)
|
||||
documents = result.get("documents", [])
|
||||
|
||||
response = {
|
||||
"success": True,
|
||||
"database_id": database_id,
|
||||
"collection_id": collection_id,
|
||||
"total": result.get("total", len(documents)),
|
||||
"documents": documents,
|
||||
}
|
||||
return json.dumps(response, indent=2, ensure_ascii=False)
|
||||
|
||||
except Exception as e:
|
||||
return json.dumps({"success": False, "error": str(e)}, indent=2)
|
||||
|
||||
async def get_document(
|
||||
client: AppwriteClient,
|
||||
database_id: str,
|
||||
collection_id: str,
|
||||
document_id: str,
|
||||
queries: list[str] | None = None,
|
||||
) -> str:
|
||||
"""Get document by ID."""
|
||||
try:
|
||||
result = await client.get_document(
|
||||
database_id=database_id,
|
||||
collection_id=collection_id,
|
||||
document_id=document_id,
|
||||
queries=queries,
|
||||
)
|
||||
return json.dumps({"success": True, "document": result}, indent=2, ensure_ascii=False)
|
||||
|
||||
except Exception as e:
|
||||
return json.dumps({"success": False, "error": str(e)}, indent=2)
|
||||
|
||||
async def create_document(
|
||||
client: AppwriteClient,
|
||||
database_id: str,
|
||||
collection_id: str,
|
||||
document_id: str,
|
||||
data: dict[str, Any],
|
||||
permissions: list[str] | None = None,
|
||||
) -> str:
|
||||
"""Create a new document."""
|
||||
try:
|
||||
result = await client.create_document(
|
||||
database_id=database_id,
|
||||
collection_id=collection_id,
|
||||
document_id=document_id,
|
||||
data=data,
|
||||
permissions=permissions,
|
||||
)
|
||||
return json.dumps(
|
||||
{"success": True, "message": "Document created successfully", "document": result},
|
||||
indent=2,
|
||||
ensure_ascii=False,
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
return json.dumps({"success": False, "error": str(e)}, indent=2)
|
||||
|
||||
async def update_document(
|
||||
client: AppwriteClient,
|
||||
database_id: str,
|
||||
collection_id: str,
|
||||
document_id: str,
|
||||
data: dict[str, Any] | None = None,
|
||||
permissions: list[str] | None = None,
|
||||
) -> str:
|
||||
"""Update document."""
|
||||
try:
|
||||
result = await client.update_document(
|
||||
database_id=database_id,
|
||||
collection_id=collection_id,
|
||||
document_id=document_id,
|
||||
data=data,
|
||||
permissions=permissions,
|
||||
)
|
||||
return json.dumps(
|
||||
{"success": True, "message": "Document updated successfully", "document": result},
|
||||
indent=2,
|
||||
ensure_ascii=False,
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
return json.dumps({"success": False, "error": str(e)}, indent=2)
|
||||
|
||||
async def delete_document(
|
||||
client: AppwriteClient, database_id: str, collection_id: str, document_id: str
|
||||
) -> str:
|
||||
"""Delete document."""
|
||||
try:
|
||||
await client.delete_document(database_id, collection_id, document_id)
|
||||
return json.dumps(
|
||||
{"success": True, "message": f"Document '{document_id}' deleted successfully"}, indent=2
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
return json.dumps({"success": False, "error": str(e)}, indent=2)
|
||||
|
||||
async def bulk_create_documents(
|
||||
client: AppwriteClient, database_id: str, collection_id: str, documents: list[dict[str, Any]]
|
||||
) -> str:
|
||||
"""Create multiple documents."""
|
||||
try:
|
||||
results = []
|
||||
errors = []
|
||||
|
||||
for doc in documents:
|
||||
try:
|
||||
result = await client.create_document(
|
||||
database_id=database_id,
|
||||
collection_id=collection_id,
|
||||
document_id=doc.get("document_id", "unique()"),
|
||||
data=doc["data"],
|
||||
permissions=doc.get("permissions"),
|
||||
)
|
||||
results.append({"id": result.get("$id"), "success": True})
|
||||
except Exception as e:
|
||||
errors.append({"document_id": doc.get("document_id"), "error": str(e)})
|
||||
|
||||
response = {
|
||||
"success": len(errors) == 0,
|
||||
"created": len(results),
|
||||
"failed": len(errors),
|
||||
"results": results,
|
||||
}
|
||||
if errors:
|
||||
response["errors"] = errors
|
||||
|
||||
return json.dumps(response, indent=2, ensure_ascii=False)
|
||||
|
||||
except Exception as e:
|
||||
return json.dumps({"success": False, "error": str(e)}, indent=2)
|
||||
|
||||
async def bulk_update_documents(
|
||||
client: AppwriteClient, database_id: str, collection_id: str, updates: list[dict[str, Any]]
|
||||
) -> str:
|
||||
"""Update multiple documents."""
|
||||
try:
|
||||
results = []
|
||||
errors = []
|
||||
|
||||
for update in updates:
|
||||
try:
|
||||
result = await client.update_document(
|
||||
database_id=database_id,
|
||||
collection_id=collection_id,
|
||||
document_id=update["document_id"],
|
||||
data=update.get("data"),
|
||||
permissions=update.get("permissions"),
|
||||
)
|
||||
results.append({"id": result.get("$id"), "success": True})
|
||||
except Exception as e:
|
||||
errors.append({"document_id": update["document_id"], "error": str(e)})
|
||||
|
||||
response = {
|
||||
"success": len(errors) == 0,
|
||||
"updated": len(results),
|
||||
"failed": len(errors),
|
||||
"results": results,
|
||||
}
|
||||
if errors:
|
||||
response["errors"] = errors
|
||||
|
||||
return json.dumps(response, indent=2, ensure_ascii=False)
|
||||
|
||||
except Exception as e:
|
||||
return json.dumps({"success": False, "error": str(e)}, indent=2)
|
||||
|
||||
async def bulk_delete_documents(
|
||||
client: AppwriteClient, database_id: str, collection_id: str, document_ids: list[str]
|
||||
) -> str:
|
||||
"""Delete multiple documents."""
|
||||
try:
|
||||
results = []
|
||||
errors = []
|
||||
|
||||
for doc_id in document_ids:
|
||||
try:
|
||||
await client.delete_document(database_id, collection_id, doc_id)
|
||||
results.append({"id": doc_id, "success": True})
|
||||
except Exception as e:
|
||||
errors.append({"document_id": doc_id, "error": str(e)})
|
||||
|
||||
response = {
|
||||
"success": len(errors) == 0,
|
||||
"deleted": len(results),
|
||||
"failed": len(errors),
|
||||
"results": results,
|
||||
}
|
||||
if errors:
|
||||
response["errors"] = errors
|
||||
|
||||
return json.dumps(response, indent=2, ensure_ascii=False)
|
||||
|
||||
except Exception as e:
|
||||
return json.dumps({"success": False, "error": str(e)}, indent=2)
|
||||
|
||||
async def search_documents(
|
||||
client: AppwriteClient,
|
||||
database_id: str,
|
||||
collection_id: str,
|
||||
attribute: str,
|
||||
query: str,
|
||||
limit: int = 25,
|
||||
) -> str:
|
||||
"""Full-text search in documents."""
|
||||
try:
|
||||
# Appwrite 1.7.4 JSON format for queries
|
||||
queries = [_query_search(attribute, query), _query_limit(limit)]
|
||||
result = await client.list_documents(
|
||||
database_id=database_id, collection_id=collection_id, queries=queries
|
||||
)
|
||||
documents = result.get("documents", [])
|
||||
|
||||
response = {
|
||||
"success": True,
|
||||
"query": query,
|
||||
"attribute": attribute,
|
||||
"total": result.get("total", len(documents)),
|
||||
"documents": documents,
|
||||
}
|
||||
return json.dumps(response, indent=2, ensure_ascii=False)
|
||||
|
||||
except Exception as e:
|
||||
error_msg = str(e)
|
||||
if "fulltext" in error_msg.lower() or "index" in error_msg.lower():
|
||||
error_msg += " (Hint: Make sure you have a fulltext index on the searched attribute)"
|
||||
return json.dumps({"success": False, "error": error_msg}, indent=2)
|
||||
|
||||
async def count_documents(
|
||||
client: AppwriteClient, database_id: str, collection_id: str, queries: list[str] | None = None
|
||||
) -> str:
|
||||
"""Count documents in collection."""
|
||||
try:
|
||||
# Build query list: start with user filters, add limit(1) to minimize data
|
||||
count_queries = []
|
||||
|
||||
# Add user-provided filter queries (make a copy to avoid mutating input)
|
||||
if queries and len(queries) > 0:
|
||||
count_queries.extend(queries)
|
||||
|
||||
# Always add limit(1) to minimize data transfer - Appwrite 1.7.4 JSON format
|
||||
count_queries.append(_query_limit(1))
|
||||
|
||||
result = await client.list_documents(
|
||||
database_id=database_id, collection_id=collection_id, queries=count_queries
|
||||
)
|
||||
|
||||
response = {
|
||||
"success": True,
|
||||
"database_id": database_id,
|
||||
"collection_id": collection_id,
|
||||
"count": result.get("total", 0),
|
||||
}
|
||||
return json.dumps(response, indent=2, ensure_ascii=False)
|
||||
|
||||
except Exception as e:
|
||||
return json.dumps({"success": False, "error": str(e)}, indent=2)
|
||||
|
||||
async def get_documents_by_ids(
|
||||
client: AppwriteClient, database_id: str, collection_id: str, document_ids: list[str]
|
||||
) -> str:
|
||||
"""Get multiple documents by IDs."""
|
||||
try:
|
||||
documents = []
|
||||
errors = []
|
||||
|
||||
for doc_id in document_ids:
|
||||
try:
|
||||
doc = await client.get_document(
|
||||
database_id=database_id, collection_id=collection_id, document_id=doc_id
|
||||
)
|
||||
documents.append(doc)
|
||||
except Exception as e:
|
||||
errors.append({"document_id": doc_id, "error": str(e)})
|
||||
|
||||
response = {
|
||||
"success": len(errors) == 0,
|
||||
"found": len(documents),
|
||||
"not_found": len(errors),
|
||||
"documents": documents,
|
||||
}
|
||||
if errors:
|
||||
response["errors"] = errors
|
||||
|
||||
return json.dumps(response, indent=2, ensure_ascii=False)
|
||||
|
||||
except Exception as e:
|
||||
return json.dumps({"success": False, "error": str(e)}, indent=2)
|
||||
|
||||
async def list_documents_paginated(
|
||||
client: AppwriteClient,
|
||||
database_id: str,
|
||||
collection_id: str,
|
||||
limit: int = 25,
|
||||
cursor: str | None = None,
|
||||
cursor_direction: str = "after",
|
||||
order_attribute: str | None = None,
|
||||
order_type: str = "DESC",
|
||||
filters: list[str] | None = None,
|
||||
) -> str:
|
||||
"""List documents with cursor pagination."""
|
||||
try:
|
||||
# Build query list (don't mutate input) - Appwrite 1.7.4 JSON format
|
||||
queries = []
|
||||
|
||||
# Add user-provided filter queries
|
||||
if filters and len(filters) > 0:
|
||||
queries.extend(filters)
|
||||
|
||||
# Add ordering only if explicitly specified
|
||||
if order_attribute:
|
||||
if order_type == "DESC":
|
||||
queries.append(_query_order_desc(order_attribute))
|
||||
else:
|
||||
queries.append(_query_order_asc(order_attribute))
|
||||
|
||||
# Add cursor
|
||||
if cursor:
|
||||
if cursor_direction == "after":
|
||||
queries.append(_query_cursor_after(cursor))
|
||||
else:
|
||||
queries.append(_query_cursor_before(cursor))
|
||||
|
||||
# Add limit only if queries exist or limit differs from default
|
||||
if queries or limit != 25:
|
||||
queries.append(_query_limit(min(limit, 100)))
|
||||
|
||||
result = await client.list_documents(
|
||||
database_id=database_id,
|
||||
collection_id=collection_id,
|
||||
queries=queries if queries else None,
|
||||
)
|
||||
documents = result.get("documents", [])
|
||||
|
||||
# Determine next cursor
|
||||
next_cursor = None
|
||||
if documents and len(documents) == limit:
|
||||
next_cursor = documents[-1].get("$id")
|
||||
|
||||
response = {
|
||||
"success": True,
|
||||
"total": result.get("total", 0),
|
||||
"count": len(documents),
|
||||
"documents": documents,
|
||||
"pagination": {
|
||||
"limit": limit,
|
||||
"cursor": cursor,
|
||||
"next_cursor": next_cursor,
|
||||
"has_more": next_cursor is not None,
|
||||
},
|
||||
}
|
||||
return json.dumps(response, indent=2, ensure_ascii=False)
|
||||
|
||||
except Exception as e:
|
||||
return json.dumps({"success": False, "error": str(e)}, indent=2)
|
||||
629
plugins/appwrite/handlers/functions.py
Normal file
629
plugins/appwrite/handlers/functions.py
Normal file
@@ -0,0 +1,629 @@
|
||||
"""
|
||||
Functions Handler - manages Appwrite serverless functions
|
||||
|
||||
Phase I.4: 14 tools
|
||||
- Functions: 5 (list, get, create, update, delete)
|
||||
- Deployments: 5 (list, get, create, delete, activate)
|
||||
- Executions: 4 (list, get, create, delete)
|
||||
"""
|
||||
|
||||
import json
|
||||
from typing import Any
|
||||
|
||||
from plugins.appwrite.client import AppwriteClient
|
||||
|
||||
def get_tool_specifications() -> list[dict[str, Any]]:
|
||||
"""Return tool specifications for ToolGenerator (14 tools)"""
|
||||
return [
|
||||
# =====================
|
||||
# FUNCTIONS (5)
|
||||
# =====================
|
||||
{
|
||||
"name": "list_functions",
|
||||
"method_name": "list_functions",
|
||||
"description": "List all serverless functions in the project.",
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"queries": {
|
||||
"anyOf": [{"type": "array", "items": {"type": "string"}}, {"type": "null"}],
|
||||
"description": "Query strings for filtering",
|
||||
},
|
||||
"search": {
|
||||
"anyOf": [{"type": "string"}, {"type": "null"}],
|
||||
"description": "Search term to filter functions",
|
||||
},
|
||||
},
|
||||
"required": [],
|
||||
},
|
||||
"scope": "read",
|
||||
},
|
||||
{
|
||||
"name": "get_function",
|
||||
"method_name": "get_function",
|
||||
"description": "Get function details including deployment status and configuration.",
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {"function_id": {"type": "string", "description": "Function ID"}},
|
||||
"required": ["function_id"],
|
||||
},
|
||||
"scope": "read",
|
||||
},
|
||||
{
|
||||
"name": "create_function",
|
||||
"method_name": "create_function",
|
||||
"description": "Create a new serverless function.",
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"function_id": {
|
||||
"type": "string",
|
||||
"description": "Unique function ID. Use 'unique()' for auto-generation",
|
||||
},
|
||||
"name": {"type": "string", "description": "Function name"},
|
||||
"runtime": {
|
||||
"type": "string",
|
||||
"description": "Runtime (e.g., 'node-18.0', 'python-3.9', 'php-8.0', 'ruby-3.0')",
|
||||
"examples": [
|
||||
"node-18.0",
|
||||
"node-20.0",
|
||||
"python-3.9",
|
||||
"python-3.11",
|
||||
"php-8.0",
|
||||
"php-8.2",
|
||||
"ruby-3.0",
|
||||
"go-1.21",
|
||||
"dart-3.0",
|
||||
"rust-1.70",
|
||||
],
|
||||
},
|
||||
"execute": {
|
||||
"anyOf": [{"type": "array", "items": {"type": "string"}}, {"type": "null"}],
|
||||
"description": "Execution permissions (e.g., ['any', 'users'])",
|
||||
},
|
||||
"events": {
|
||||
"anyOf": [{"type": "array", "items": {"type": "string"}}, {"type": "null"}],
|
||||
"description": "Events to trigger function (e.g., ['databases.*.collections.*.documents.*.create'])",
|
||||
},
|
||||
"schedule": {
|
||||
"anyOf": [{"type": "string"}, {"type": "null"}],
|
||||
"description": "Cron schedule (e.g., '0 * * * *' for every hour)",
|
||||
},
|
||||
"timeout": {
|
||||
"type": "integer",
|
||||
"description": "Execution timeout in seconds",
|
||||
"default": 15,
|
||||
"minimum": 1,
|
||||
"maximum": 900,
|
||||
},
|
||||
"enabled": {
|
||||
"type": "boolean",
|
||||
"description": "Enable function",
|
||||
"default": True,
|
||||
},
|
||||
"logging": {
|
||||
"type": "boolean",
|
||||
"description": "Enable logging",
|
||||
"default": True,
|
||||
},
|
||||
"entrypoint": {
|
||||
"anyOf": [{"type": "string"}, {"type": "null"}],
|
||||
"description": "Entrypoint file (e.g., 'src/main.js')",
|
||||
},
|
||||
"commands": {
|
||||
"anyOf": [{"type": "string"}, {"type": "null"}],
|
||||
"description": "Build commands",
|
||||
},
|
||||
},
|
||||
"required": ["function_id", "name", "runtime"],
|
||||
},
|
||||
"scope": "write",
|
||||
},
|
||||
{
|
||||
"name": "update_function",
|
||||
"method_name": "update_function",
|
||||
"description": "Update function configuration.",
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"function_id": {"type": "string", "description": "Function ID"},
|
||||
"name": {"type": "string", "description": "Function name"},
|
||||
"runtime": {
|
||||
"anyOf": [{"type": "string"}, {"type": "null"}],
|
||||
"description": "New runtime",
|
||||
},
|
||||
"execute": {
|
||||
"anyOf": [{"type": "array", "items": {"type": "string"}}, {"type": "null"}],
|
||||
"description": "New execution permissions",
|
||||
},
|
||||
"events": {
|
||||
"anyOf": [{"type": "array", "items": {"type": "string"}}, {"type": "null"}],
|
||||
"description": "New trigger events",
|
||||
},
|
||||
"schedule": {
|
||||
"anyOf": [{"type": "string"}, {"type": "null"}],
|
||||
"description": "New cron schedule",
|
||||
},
|
||||
"timeout": {
|
||||
"anyOf": [{"type": "integer"}, {"type": "null"}],
|
||||
"description": "New timeout",
|
||||
},
|
||||
"enabled": {
|
||||
"anyOf": [{"type": "boolean"}, {"type": "null"}],
|
||||
"description": "Enable/disable function",
|
||||
},
|
||||
},
|
||||
"required": ["function_id", "name"],
|
||||
},
|
||||
"scope": "write",
|
||||
},
|
||||
{
|
||||
"name": "delete_function",
|
||||
"method_name": "delete_function",
|
||||
"description": "Delete a function and all its deployments.",
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"function_id": {"type": "string", "description": "Function ID to delete"}
|
||||
},
|
||||
"required": ["function_id"],
|
||||
},
|
||||
"scope": "admin",
|
||||
},
|
||||
# =====================
|
||||
# DEPLOYMENTS (5)
|
||||
# =====================
|
||||
{
|
||||
"name": "list_deployments",
|
||||
"method_name": "list_deployments",
|
||||
"description": "List all deployments of a function.",
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"function_id": {"type": "string", "description": "Function ID"},
|
||||
"queries": {
|
||||
"anyOf": [{"type": "array", "items": {"type": "string"}}, {"type": "null"}],
|
||||
"description": "Query strings for filtering",
|
||||
},
|
||||
"search": {
|
||||
"anyOf": [{"type": "string"}, {"type": "null"}],
|
||||
"description": "Search term",
|
||||
},
|
||||
},
|
||||
"required": ["function_id"],
|
||||
},
|
||||
"scope": "read",
|
||||
},
|
||||
{
|
||||
"name": "get_deployment",
|
||||
"method_name": "get_deployment",
|
||||
"description": "Get deployment details including build status and logs.",
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"function_id": {"type": "string", "description": "Function ID"},
|
||||
"deployment_id": {"type": "string", "description": "Deployment ID"},
|
||||
},
|
||||
"required": ["function_id", "deployment_id"],
|
||||
},
|
||||
"scope": "read",
|
||||
},
|
||||
{
|
||||
"name": "delete_deployment",
|
||||
"method_name": "delete_deployment",
|
||||
"description": "Delete a deployment.",
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"function_id": {"type": "string", "description": "Function ID"},
|
||||
"deployment_id": {"type": "string", "description": "Deployment ID to delete"},
|
||||
},
|
||||
"required": ["function_id", "deployment_id"],
|
||||
},
|
||||
"scope": "write",
|
||||
},
|
||||
{
|
||||
"name": "activate_deployment",
|
||||
"method_name": "activate_deployment",
|
||||
"description": "Activate a deployment (set as the active version for the function).",
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"function_id": {"type": "string", "description": "Function ID"},
|
||||
"deployment_id": {"type": "string", "description": "Deployment ID to activate"},
|
||||
},
|
||||
"required": ["function_id", "deployment_id"],
|
||||
},
|
||||
"scope": "write",
|
||||
},
|
||||
{
|
||||
"name": "get_active_deployment",
|
||||
"method_name": "get_active_deployment",
|
||||
"description": "Get the currently active deployment for a function.",
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {"function_id": {"type": "string", "description": "Function ID"}},
|
||||
"required": ["function_id"],
|
||||
},
|
||||
"scope": "read",
|
||||
},
|
||||
# =====================
|
||||
# EXECUTIONS (4)
|
||||
# =====================
|
||||
{
|
||||
"name": "list_executions",
|
||||
"method_name": "list_executions",
|
||||
"description": "List execution history for a function.",
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"function_id": {"type": "string", "description": "Function ID"},
|
||||
"queries": {
|
||||
"anyOf": [{"type": "array", "items": {"type": "string"}}, {"type": "null"}],
|
||||
"description": "Query strings for filtering",
|
||||
},
|
||||
"search": {
|
||||
"anyOf": [{"type": "string"}, {"type": "null"}],
|
||||
"description": "Search term",
|
||||
},
|
||||
},
|
||||
"required": ["function_id"],
|
||||
},
|
||||
"scope": "read",
|
||||
},
|
||||
{
|
||||
"name": "get_execution",
|
||||
"method_name": "get_execution",
|
||||
"description": "Get execution details including response, logs, and timing.",
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"function_id": {"type": "string", "description": "Function ID"},
|
||||
"execution_id": {"type": "string", "description": "Execution ID"},
|
||||
},
|
||||
"required": ["function_id", "execution_id"],
|
||||
},
|
||||
"scope": "read",
|
||||
},
|
||||
{
|
||||
"name": "execute_function",
|
||||
"method_name": "execute_function",
|
||||
"description": "Execute a function immediately.",
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"function_id": {"type": "string", "description": "Function ID"},
|
||||
"body": {
|
||||
"anyOf": [{"type": "string"}, {"type": "null"}],
|
||||
"description": "Request body (JSON string)",
|
||||
},
|
||||
"async_execution": {
|
||||
"type": "boolean",
|
||||
"description": "Run asynchronously (don't wait for response)",
|
||||
"default": False,
|
||||
},
|
||||
"path": {
|
||||
"anyOf": [{"type": "string"}, {"type": "null"}],
|
||||
"description": "Custom execution path",
|
||||
},
|
||||
"method": {
|
||||
"type": "string",
|
||||
"enum": ["GET", "POST", "PUT", "PATCH", "DELETE"],
|
||||
"description": "HTTP method",
|
||||
"default": "POST",
|
||||
},
|
||||
"headers": {
|
||||
"anyOf": [{"type": "object"}, {"type": "null"}],
|
||||
"description": "Custom headers",
|
||||
},
|
||||
},
|
||||
"required": ["function_id"],
|
||||
},
|
||||
"scope": "write",
|
||||
},
|
||||
{
|
||||
"name": "delete_execution",
|
||||
"method_name": "delete_execution",
|
||||
"description": "Delete an execution log entry.",
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"function_id": {"type": "string", "description": "Function ID"},
|
||||
"execution_id": {"type": "string", "description": "Execution ID to delete"},
|
||||
},
|
||||
"required": ["function_id", "execution_id"],
|
||||
},
|
||||
"scope": "write",
|
||||
},
|
||||
]
|
||||
|
||||
# =====================
|
||||
# HANDLER FUNCTIONS
|
||||
# =====================
|
||||
|
||||
async def list_functions(
|
||||
client: AppwriteClient, queries: list[str] | None = None, search: str | None = None
|
||||
) -> str:
|
||||
"""List all functions."""
|
||||
try:
|
||||
result = await client.list_functions(queries=queries, search=search)
|
||||
functions = result.get("functions", [])
|
||||
|
||||
response = {
|
||||
"success": True,
|
||||
"total": result.get("total", len(functions)),
|
||||
"functions": functions,
|
||||
}
|
||||
return json.dumps(response, indent=2, ensure_ascii=False)
|
||||
|
||||
except Exception as e:
|
||||
return json.dumps({"success": False, "error": str(e)}, indent=2)
|
||||
|
||||
async def get_function(client: AppwriteClient, function_id: str) -> str:
|
||||
"""Get function by ID."""
|
||||
try:
|
||||
result = await client.get_function(function_id)
|
||||
return json.dumps({"success": True, "function": result}, indent=2, ensure_ascii=False)
|
||||
|
||||
except Exception as e:
|
||||
return json.dumps({"success": False, "error": str(e)}, indent=2)
|
||||
|
||||
async def create_function(
|
||||
client: AppwriteClient,
|
||||
function_id: str,
|
||||
name: str,
|
||||
runtime: str,
|
||||
execute: list[str] | None = None,
|
||||
events: list[str] | None = None,
|
||||
schedule: str | None = None,
|
||||
timeout: int = 15,
|
||||
enabled: bool = True,
|
||||
logging: bool = True,
|
||||
entrypoint: str | None = None,
|
||||
commands: str | None = None,
|
||||
) -> str:
|
||||
"""Create a new function."""
|
||||
try:
|
||||
result = await client.create_function(
|
||||
function_id=function_id,
|
||||
name=name,
|
||||
runtime=runtime,
|
||||
execute=execute,
|
||||
events=events,
|
||||
schedule=schedule,
|
||||
timeout=timeout,
|
||||
enabled=enabled,
|
||||
logging=logging,
|
||||
entrypoint=entrypoint,
|
||||
commands=commands,
|
||||
)
|
||||
return json.dumps(
|
||||
{
|
||||
"success": True,
|
||||
"message": f"Function '{name}' created successfully",
|
||||
"function": result,
|
||||
},
|
||||
indent=2,
|
||||
ensure_ascii=False,
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
return json.dumps({"success": False, "error": str(e)}, indent=2)
|
||||
|
||||
async def update_function(
|
||||
client: AppwriteClient,
|
||||
function_id: str,
|
||||
name: str,
|
||||
runtime: str | None = None,
|
||||
execute: list[str] | None = None,
|
||||
events: list[str] | None = None,
|
||||
schedule: str | None = None,
|
||||
timeout: int | None = None,
|
||||
enabled: bool | None = None,
|
||||
) -> str:
|
||||
"""Update function."""
|
||||
try:
|
||||
result = await client.update_function(
|
||||
function_id=function_id,
|
||||
name=name,
|
||||
runtime=runtime,
|
||||
execute=execute,
|
||||
events=events,
|
||||
schedule=schedule,
|
||||
timeout=timeout,
|
||||
enabled=enabled,
|
||||
)
|
||||
return json.dumps(
|
||||
{"success": True, "message": "Function updated successfully", "function": result},
|
||||
indent=2,
|
||||
ensure_ascii=False,
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
return json.dumps({"success": False, "error": str(e)}, indent=2)
|
||||
|
||||
async def delete_function(client: AppwriteClient, function_id: str) -> str:
|
||||
"""Delete function."""
|
||||
try:
|
||||
await client.delete_function(function_id)
|
||||
return json.dumps(
|
||||
{"success": True, "message": f"Function '{function_id}' deleted successfully"}, indent=2
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
return json.dumps({"success": False, "error": str(e)}, indent=2)
|
||||
|
||||
async def list_deployments(
|
||||
client: AppwriteClient,
|
||||
function_id: str,
|
||||
queries: list[str] | None = None,
|
||||
search: str | None = None,
|
||||
) -> str:
|
||||
"""List function deployments."""
|
||||
try:
|
||||
result = await client.list_deployments(
|
||||
function_id=function_id, queries=queries, search=search
|
||||
)
|
||||
deployments = result.get("deployments", [])
|
||||
|
||||
response = {
|
||||
"success": True,
|
||||
"function_id": function_id,
|
||||
"total": result.get("total", len(deployments)),
|
||||
"deployments": deployments,
|
||||
}
|
||||
return json.dumps(response, indent=2, ensure_ascii=False)
|
||||
|
||||
except Exception as e:
|
||||
return json.dumps({"success": False, "error": str(e)}, indent=2)
|
||||
|
||||
async def get_deployment(client: AppwriteClient, function_id: str, deployment_id: str) -> str:
|
||||
"""Get deployment by ID."""
|
||||
try:
|
||||
result = await client.get_deployment(function_id, deployment_id)
|
||||
return json.dumps({"success": True, "deployment": result}, indent=2, ensure_ascii=False)
|
||||
|
||||
except Exception as e:
|
||||
return json.dumps({"success": False, "error": str(e)}, indent=2)
|
||||
|
||||
async def delete_deployment(client: AppwriteClient, function_id: str, deployment_id: str) -> str:
|
||||
"""Delete deployment."""
|
||||
try:
|
||||
await client.delete_deployment(function_id, deployment_id)
|
||||
return json.dumps(
|
||||
{"success": True, "message": f"Deployment '{deployment_id}' deleted successfully"},
|
||||
indent=2,
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
return json.dumps({"success": False, "error": str(e)}, indent=2)
|
||||
|
||||
async def activate_deployment(client: AppwriteClient, function_id: str, deployment_id: str) -> str:
|
||||
"""Activate deployment."""
|
||||
try:
|
||||
result = await client.update_deployment(function_id, deployment_id)
|
||||
return json.dumps(
|
||||
{
|
||||
"success": True,
|
||||
"message": f"Deployment '{deployment_id}' activated successfully",
|
||||
"deployment": result,
|
||||
},
|
||||
indent=2,
|
||||
ensure_ascii=False,
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
return json.dumps({"success": False, "error": str(e)}, indent=2)
|
||||
|
||||
async def get_active_deployment(client: AppwriteClient, function_id: str) -> str:
|
||||
"""Get active deployment for function."""
|
||||
try:
|
||||
func = await client.get_function(function_id)
|
||||
deployment_id = func.get("deployment")
|
||||
|
||||
if not deployment_id:
|
||||
return json.dumps(
|
||||
{
|
||||
"success": True,
|
||||
"message": "No active deployment",
|
||||
"function_id": function_id,
|
||||
"active_deployment": None,
|
||||
},
|
||||
indent=2,
|
||||
)
|
||||
|
||||
deployment = await client.get_deployment(function_id, deployment_id)
|
||||
return json.dumps(
|
||||
{"success": True, "function_id": function_id, "active_deployment": deployment},
|
||||
indent=2,
|
||||
ensure_ascii=False,
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
return json.dumps({"success": False, "error": str(e)}, indent=2)
|
||||
|
||||
async def list_executions(
|
||||
client: AppwriteClient,
|
||||
function_id: str,
|
||||
queries: list[str] | None = None,
|
||||
search: str | None = None,
|
||||
) -> str:
|
||||
"""List function executions."""
|
||||
try:
|
||||
result = await client.list_executions(
|
||||
function_id=function_id, queries=queries, search=search
|
||||
)
|
||||
executions = result.get("executions", [])
|
||||
|
||||
response = {
|
||||
"success": True,
|
||||
"function_id": function_id,
|
||||
"total": result.get("total", len(executions)),
|
||||
"executions": executions,
|
||||
}
|
||||
return json.dumps(response, indent=2, ensure_ascii=False)
|
||||
|
||||
except Exception as e:
|
||||
return json.dumps({"success": False, "error": str(e)}, indent=2)
|
||||
|
||||
async def get_execution(client: AppwriteClient, function_id: str, execution_id: str) -> str:
|
||||
"""Get execution by ID."""
|
||||
try:
|
||||
result = await client.get_execution(function_id, execution_id)
|
||||
return json.dumps({"success": True, "execution": result}, indent=2, ensure_ascii=False)
|
||||
|
||||
except Exception as e:
|
||||
return json.dumps({"success": False, "error": str(e)}, indent=2)
|
||||
|
||||
async def execute_function(
|
||||
client: AppwriteClient,
|
||||
function_id: str,
|
||||
body: str | None = None,
|
||||
async_execution: bool = False,
|
||||
path: str | None = None,
|
||||
method: str = "POST",
|
||||
headers: dict[str, str] | None = None,
|
||||
) -> str:
|
||||
"""Execute function."""
|
||||
try:
|
||||
result = await client.create_execution(
|
||||
function_id=function_id,
|
||||
body=body,
|
||||
async_execution=async_execution,
|
||||
path=path,
|
||||
method=method,
|
||||
headers=headers,
|
||||
)
|
||||
|
||||
response = {
|
||||
"success": True,
|
||||
"execution_id": result.get("$id"),
|
||||
"status": result.get("status"),
|
||||
"status_code": result.get("responseStatusCode"),
|
||||
"duration": result.get("duration"),
|
||||
"response_body": result.get("responseBody"),
|
||||
"logs": result.get("logs"),
|
||||
"errors": result.get("errors"),
|
||||
}
|
||||
|
||||
if async_execution:
|
||||
response["message"] = "Function execution started asynchronously"
|
||||
else:
|
||||
response["message"] = "Function executed successfully"
|
||||
|
||||
return json.dumps(response, indent=2, ensure_ascii=False)
|
||||
|
||||
except Exception as e:
|
||||
return json.dumps({"success": False, "error": str(e)}, indent=2)
|
||||
|
||||
async def delete_execution(client: AppwriteClient, function_id: str, execution_id: str) -> str:
|
||||
"""Delete execution."""
|
||||
try:
|
||||
await client.delete_execution(function_id, execution_id)
|
||||
return json.dumps(
|
||||
{"success": True, "message": f"Execution '{execution_id}' deleted successfully"},
|
||||
indent=2,
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
return json.dumps({"success": False, "error": str(e)}, indent=2)
|
||||
579
plugins/appwrite/handlers/messaging.py
Normal file
579
plugins/appwrite/handlers/messaging.py
Normal file
@@ -0,0 +1,579 @@
|
||||
"""
|
||||
Messaging Handler - manages Appwrite messaging (Email, SMS, Push notifications)
|
||||
|
||||
Phase I.4: 12 tools
|
||||
- Topics: 4 (list, get, create, delete)
|
||||
- Subscribers: 2 (create, delete)
|
||||
- Messages: 6 (list, get, send_email, send_sms, send_push, delete)
|
||||
"""
|
||||
|
||||
import json
|
||||
from typing import Any
|
||||
|
||||
from plugins.appwrite.client import AppwriteClient
|
||||
|
||||
def get_tool_specifications() -> list[dict[str, Any]]:
|
||||
"""Return tool specifications for ToolGenerator (12 tools)"""
|
||||
return [
|
||||
# =====================
|
||||
# TOPICS (4)
|
||||
# =====================
|
||||
{
|
||||
"name": "list_topics",
|
||||
"method_name": "list_topics",
|
||||
"description": "List all messaging topics. Topics are groups for sending messages to multiple subscribers.",
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"queries": {
|
||||
"anyOf": [{"type": "array", "items": {"type": "string"}}, {"type": "null"}],
|
||||
"description": "Query strings for filtering",
|
||||
},
|
||||
"search": {
|
||||
"anyOf": [{"type": "string"}, {"type": "null"}],
|
||||
"description": "Search term",
|
||||
},
|
||||
},
|
||||
"required": [],
|
||||
},
|
||||
"scope": "read",
|
||||
},
|
||||
{
|
||||
"name": "get_topic",
|
||||
"method_name": "get_topic",
|
||||
"description": "Get topic details by ID.",
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {"topic_id": {"type": "string", "description": "Topic ID"}},
|
||||
"required": ["topic_id"],
|
||||
},
|
||||
"scope": "read",
|
||||
},
|
||||
{
|
||||
"name": "create_topic",
|
||||
"method_name": "create_topic",
|
||||
"description": "Create a new messaging topic.",
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"topic_id": {
|
||||
"type": "string",
|
||||
"description": "Unique topic ID. Use 'unique()' for auto-generation",
|
||||
},
|
||||
"name": {"type": "string", "description": "Topic name"},
|
||||
"subscribe": {
|
||||
"anyOf": [{"type": "array", "items": {"type": "string"}}, {"type": "null"}],
|
||||
"description": "Permissions for who can subscribe (e.g., ['users', 'any'])",
|
||||
},
|
||||
},
|
||||
"required": ["topic_id", "name"],
|
||||
},
|
||||
"scope": "write",
|
||||
},
|
||||
{
|
||||
"name": "delete_topic",
|
||||
"method_name": "delete_topic",
|
||||
"description": "Delete a messaging topic.",
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {"topic_id": {"type": "string", "description": "Topic ID to delete"}},
|
||||
"required": ["topic_id"],
|
||||
},
|
||||
"scope": "write",
|
||||
},
|
||||
# =====================
|
||||
# SUBSCRIBERS (2)
|
||||
# =====================
|
||||
{
|
||||
"name": "create_subscriber",
|
||||
"method_name": "create_subscriber",
|
||||
"description": "Add a subscriber to a topic.",
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"topic_id": {"type": "string", "description": "Topic ID"},
|
||||
"subscriber_id": {
|
||||
"type": "string",
|
||||
"description": "Unique subscriber ID. Use 'unique()' for auto-generation",
|
||||
},
|
||||
"target_id": {
|
||||
"type": "string",
|
||||
"description": "Target ID (user's target for messaging)",
|
||||
},
|
||||
},
|
||||
"required": ["topic_id", "subscriber_id", "target_id"],
|
||||
},
|
||||
"scope": "write",
|
||||
},
|
||||
{
|
||||
"name": "delete_subscriber",
|
||||
"method_name": "delete_subscriber",
|
||||
"description": "Remove a subscriber from a topic.",
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"topic_id": {"type": "string", "description": "Topic ID"},
|
||||
"subscriber_id": {"type": "string", "description": "Subscriber ID to remove"},
|
||||
},
|
||||
"required": ["topic_id", "subscriber_id"],
|
||||
},
|
||||
"scope": "write",
|
||||
},
|
||||
# =====================
|
||||
# MESSAGES (6)
|
||||
# =====================
|
||||
{
|
||||
"name": "list_messages",
|
||||
"method_name": "list_messages",
|
||||
"description": "List all messages (sent and draft).",
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"queries": {
|
||||
"anyOf": [{"type": "array", "items": {"type": "string"}}, {"type": "null"}],
|
||||
"description": "Query strings for filtering",
|
||||
},
|
||||
"search": {
|
||||
"anyOf": [{"type": "string"}, {"type": "null"}],
|
||||
"description": "Search term",
|
||||
},
|
||||
},
|
||||
"required": [],
|
||||
},
|
||||
"scope": "read",
|
||||
},
|
||||
{
|
||||
"name": "get_message",
|
||||
"method_name": "get_message",
|
||||
"description": "Get message details by ID.",
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {"message_id": {"type": "string", "description": "Message ID"}},
|
||||
"required": ["message_id"],
|
||||
},
|
||||
"scope": "read",
|
||||
},
|
||||
{
|
||||
"name": "send_email",
|
||||
"method_name": "send_email",
|
||||
"description": "Send an email message. Requires configured email provider.",
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"message_id": {
|
||||
"type": "string",
|
||||
"description": "Unique message ID. Use 'unique()' for auto-generation",
|
||||
},
|
||||
"subject": {"type": "string", "description": "Email subject"},
|
||||
"content": {
|
||||
"type": "string",
|
||||
"description": "Email content (HTML supported if html=true)",
|
||||
},
|
||||
"topics": {
|
||||
"anyOf": [{"type": "array", "items": {"type": "string"}}, {"type": "null"}],
|
||||
"description": "Topic IDs to send to",
|
||||
},
|
||||
"users": {
|
||||
"anyOf": [{"type": "array", "items": {"type": "string"}}, {"type": "null"}],
|
||||
"description": "User IDs to send to",
|
||||
},
|
||||
"targets": {
|
||||
"anyOf": [{"type": "array", "items": {"type": "string"}}, {"type": "null"}],
|
||||
"description": "Target IDs to send to",
|
||||
},
|
||||
"cc": {
|
||||
"anyOf": [{"type": "array", "items": {"type": "string"}}, {"type": "null"}],
|
||||
"description": "CC recipients",
|
||||
},
|
||||
"bcc": {
|
||||
"anyOf": [{"type": "array", "items": {"type": "string"}}, {"type": "null"}],
|
||||
"description": "BCC recipients",
|
||||
},
|
||||
"html": {
|
||||
"type": "boolean",
|
||||
"description": "Send as HTML email",
|
||||
"default": True,
|
||||
},
|
||||
"draft": {
|
||||
"type": "boolean",
|
||||
"description": "Save as draft instead of sending",
|
||||
"default": False,
|
||||
},
|
||||
"scheduled_at": {
|
||||
"anyOf": [{"type": "string"}, {"type": "null"}],
|
||||
"description": "Schedule send time (ISO 8601 format)",
|
||||
},
|
||||
},
|
||||
"required": ["message_id", "subject", "content"],
|
||||
},
|
||||
"scope": "write",
|
||||
},
|
||||
{
|
||||
"name": "send_sms",
|
||||
"method_name": "send_sms",
|
||||
"description": "Send an SMS message. Requires configured SMS provider.",
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"message_id": {
|
||||
"type": "string",
|
||||
"description": "Unique message ID. Use 'unique()' for auto-generation",
|
||||
},
|
||||
"content": {"type": "string", "description": "SMS content"},
|
||||
"topics": {
|
||||
"anyOf": [{"type": "array", "items": {"type": "string"}}, {"type": "null"}],
|
||||
"description": "Topic IDs to send to",
|
||||
},
|
||||
"users": {
|
||||
"anyOf": [{"type": "array", "items": {"type": "string"}}, {"type": "null"}],
|
||||
"description": "User IDs to send to",
|
||||
},
|
||||
"targets": {
|
||||
"anyOf": [{"type": "array", "items": {"type": "string"}}, {"type": "null"}],
|
||||
"description": "Target IDs to send to",
|
||||
},
|
||||
"draft": {"type": "boolean", "description": "Save as draft", "default": False},
|
||||
"scheduled_at": {
|
||||
"anyOf": [{"type": "string"}, {"type": "null"}],
|
||||
"description": "Schedule send time (ISO 8601 format)",
|
||||
},
|
||||
},
|
||||
"required": ["message_id", "content"],
|
||||
},
|
||||
"scope": "write",
|
||||
},
|
||||
{
|
||||
"name": "send_push",
|
||||
"method_name": "send_push",
|
||||
"description": "Send a push notification. Requires configured push provider (FCM/APNs).",
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"message_id": {
|
||||
"type": "string",
|
||||
"description": "Unique message ID. Use 'unique()' for auto-generation",
|
||||
},
|
||||
"title": {"type": "string", "description": "Notification title"},
|
||||
"body": {"type": "string", "description": "Notification body"},
|
||||
"topics": {
|
||||
"anyOf": [{"type": "array", "items": {"type": "string"}}, {"type": "null"}],
|
||||
"description": "Topic IDs to send to",
|
||||
},
|
||||
"users": {
|
||||
"anyOf": [{"type": "array", "items": {"type": "string"}}, {"type": "null"}],
|
||||
"description": "User IDs to send to",
|
||||
},
|
||||
"targets": {
|
||||
"anyOf": [{"type": "array", "items": {"type": "string"}}, {"type": "null"}],
|
||||
"description": "Target IDs to send to",
|
||||
},
|
||||
"data": {
|
||||
"anyOf": [{"type": "object"}, {"type": "null"}],
|
||||
"description": "Custom data payload",
|
||||
},
|
||||
"action": {
|
||||
"anyOf": [{"type": "string"}, {"type": "null"}],
|
||||
"description": "Action URL",
|
||||
},
|
||||
"image": {
|
||||
"anyOf": [{"type": "string"}, {"type": "null"}],
|
||||
"description": "Image URL",
|
||||
},
|
||||
"icon": {
|
||||
"anyOf": [{"type": "string"}, {"type": "null"}],
|
||||
"description": "Icon URL",
|
||||
},
|
||||
"sound": {
|
||||
"anyOf": [{"type": "string"}, {"type": "null"}],
|
||||
"description": "Sound name",
|
||||
},
|
||||
"badge": {
|
||||
"anyOf": [{"type": "integer"}, {"type": "null"}],
|
||||
"description": "Badge number (iOS)",
|
||||
},
|
||||
"draft": {"type": "boolean", "description": "Save as draft", "default": False},
|
||||
"scheduled_at": {
|
||||
"anyOf": [{"type": "string"}, {"type": "null"}],
|
||||
"description": "Schedule send time (ISO 8601 format)",
|
||||
},
|
||||
},
|
||||
"required": ["message_id", "title", "body"],
|
||||
},
|
||||
"scope": "write",
|
||||
},
|
||||
{
|
||||
"name": "delete_message",
|
||||
"method_name": "delete_message",
|
||||
"description": "Delete a message.",
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"message_id": {"type": "string", "description": "Message ID to delete"}
|
||||
},
|
||||
"required": ["message_id"],
|
||||
},
|
||||
"scope": "write",
|
||||
},
|
||||
]
|
||||
|
||||
# =====================
|
||||
# HANDLER FUNCTIONS
|
||||
# =====================
|
||||
|
||||
async def list_topics(
|
||||
client: AppwriteClient, queries: list[str] | None = None, search: str | None = None
|
||||
) -> str:
|
||||
"""List all topics."""
|
||||
try:
|
||||
result = await client.list_topics(queries=queries, search=search)
|
||||
topics = result.get("topics", [])
|
||||
|
||||
response = {"success": True, "total": result.get("total", len(topics)), "topics": topics}
|
||||
return json.dumps(response, indent=2, ensure_ascii=False)
|
||||
|
||||
except Exception as e:
|
||||
return json.dumps({"success": False, "error": str(e)}, indent=2)
|
||||
|
||||
async def get_topic(client: AppwriteClient, topic_id: str) -> str:
|
||||
"""Get topic by ID."""
|
||||
try:
|
||||
result = await client.get_topic(topic_id)
|
||||
return json.dumps({"success": True, "topic": result}, indent=2, ensure_ascii=False)
|
||||
|
||||
except Exception as e:
|
||||
return json.dumps({"success": False, "error": str(e)}, indent=2)
|
||||
|
||||
async def create_topic(
|
||||
client: AppwriteClient, topic_id: str, name: str, subscribe: list[str] | None = None
|
||||
) -> str:
|
||||
"""Create a new topic."""
|
||||
try:
|
||||
result = await client.create_topic(topic_id=topic_id, name=name, subscribe=subscribe)
|
||||
return json.dumps(
|
||||
{"success": True, "message": f"Topic '{name}' created successfully", "topic": result},
|
||||
indent=2,
|
||||
ensure_ascii=False,
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
return json.dumps({"success": False, "error": str(e)}, indent=2)
|
||||
|
||||
async def delete_topic(client: AppwriteClient, topic_id: str) -> str:
|
||||
"""Delete topic."""
|
||||
try:
|
||||
await client.delete_topic(topic_id)
|
||||
return json.dumps(
|
||||
{"success": True, "message": f"Topic '{topic_id}' deleted successfully"}, indent=2
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
return json.dumps({"success": False, "error": str(e)}, indent=2)
|
||||
|
||||
async def create_subscriber(
|
||||
client: AppwriteClient, topic_id: str, subscriber_id: str, target_id: str
|
||||
) -> str:
|
||||
"""Add subscriber to topic."""
|
||||
try:
|
||||
result = await client.create_subscriber(
|
||||
topic_id=topic_id, subscriber_id=subscriber_id, target_id=target_id
|
||||
)
|
||||
return json.dumps(
|
||||
{"success": True, "message": "Subscriber added to topic", "subscriber": result},
|
||||
indent=2,
|
||||
ensure_ascii=False,
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
return json.dumps({"success": False, "error": str(e)}, indent=2)
|
||||
|
||||
async def delete_subscriber(client: AppwriteClient, topic_id: str, subscriber_id: str) -> str:
|
||||
"""Remove subscriber from topic."""
|
||||
try:
|
||||
await client.delete_subscriber(topic_id, subscriber_id)
|
||||
return json.dumps(
|
||||
{"success": True, "message": f"Subscriber '{subscriber_id}' removed from topic"},
|
||||
indent=2,
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
return json.dumps({"success": False, "error": str(e)}, indent=2)
|
||||
|
||||
async def list_messages(
|
||||
client: AppwriteClient, queries: list[str] | None = None, search: str | None = None
|
||||
) -> str:
|
||||
"""List all messages."""
|
||||
try:
|
||||
result = await client.list_messages(queries=queries, search=search)
|
||||
messages = result.get("messages", [])
|
||||
|
||||
response = {
|
||||
"success": True,
|
||||
"total": result.get("total", len(messages)),
|
||||
"messages": messages,
|
||||
}
|
||||
return json.dumps(response, indent=2, ensure_ascii=False)
|
||||
|
||||
except Exception as e:
|
||||
return json.dumps({"success": False, "error": str(e)}, indent=2)
|
||||
|
||||
async def get_message(client: AppwriteClient, message_id: str) -> str:
|
||||
"""Get message by ID."""
|
||||
try:
|
||||
result = await client.get_message(message_id)
|
||||
return json.dumps({"success": True, "message": result}, indent=2, ensure_ascii=False)
|
||||
|
||||
except Exception as e:
|
||||
return json.dumps({"success": False, "error": str(e)}, indent=2)
|
||||
|
||||
async def send_email(
|
||||
client: AppwriteClient,
|
||||
message_id: str,
|
||||
subject: str,
|
||||
content: str,
|
||||
topics: list[str] | None = None,
|
||||
users: list[str] | None = None,
|
||||
targets: list[str] | None = None,
|
||||
cc: list[str] | None = None,
|
||||
bcc: list[str] | None = None,
|
||||
html: bool = True,
|
||||
draft: bool = False,
|
||||
scheduled_at: str | None = None,
|
||||
) -> str:
|
||||
"""Send email message."""
|
||||
try:
|
||||
result = await client.create_email(
|
||||
message_id=message_id,
|
||||
subject=subject,
|
||||
content=content,
|
||||
topics=topics,
|
||||
users=users,
|
||||
targets=targets,
|
||||
cc=cc,
|
||||
bcc=bcc,
|
||||
html=html,
|
||||
draft=draft,
|
||||
scheduled_at=scheduled_at,
|
||||
)
|
||||
|
||||
action = "saved as draft" if draft else "sent"
|
||||
if scheduled_at:
|
||||
action = f"scheduled for {scheduled_at}"
|
||||
|
||||
return json.dumps(
|
||||
{"success": True, "message": f"Email {action} successfully", "email": result},
|
||||
indent=2,
|
||||
ensure_ascii=False,
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
error_msg = str(e)
|
||||
if "provider" in error_msg.lower():
|
||||
error_msg += " (Hint: Make sure you have configured an email provider in Appwrite)"
|
||||
return json.dumps({"success": False, "error": error_msg}, indent=2)
|
||||
|
||||
async def send_sms(
|
||||
client: AppwriteClient,
|
||||
message_id: str,
|
||||
content: str,
|
||||
topics: list[str] | None = None,
|
||||
users: list[str] | None = None,
|
||||
targets: list[str] | None = None,
|
||||
draft: bool = False,
|
||||
scheduled_at: str | None = None,
|
||||
) -> str:
|
||||
"""Send SMS message."""
|
||||
try:
|
||||
result = await client.create_sms(
|
||||
message_id=message_id,
|
||||
content=content,
|
||||
topics=topics,
|
||||
users=users,
|
||||
targets=targets,
|
||||
draft=draft,
|
||||
scheduled_at=scheduled_at,
|
||||
)
|
||||
|
||||
action = "saved as draft" if draft else "sent"
|
||||
if scheduled_at:
|
||||
action = f"scheduled for {scheduled_at}"
|
||||
|
||||
return json.dumps(
|
||||
{"success": True, "message": f"SMS {action} successfully", "sms": result},
|
||||
indent=2,
|
||||
ensure_ascii=False,
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
error_msg = str(e)
|
||||
if "provider" in error_msg.lower():
|
||||
error_msg += " (Hint: Make sure you have configured an SMS provider in Appwrite)"
|
||||
return json.dumps({"success": False, "error": error_msg}, indent=2)
|
||||
|
||||
async def send_push(
|
||||
client: AppwriteClient,
|
||||
message_id: str,
|
||||
title: str,
|
||||
body: str,
|
||||
topics: list[str] | None = None,
|
||||
users: list[str] | None = None,
|
||||
targets: list[str] | None = None,
|
||||
data: dict[str, Any] | None = None,
|
||||
action: str | None = None,
|
||||
image: str | None = None,
|
||||
icon: str | None = None,
|
||||
sound: str | None = None,
|
||||
badge: int | None = None,
|
||||
draft: bool = False,
|
||||
scheduled_at: str | None = None,
|
||||
) -> str:
|
||||
"""Send push notification."""
|
||||
try:
|
||||
result = await client.create_push(
|
||||
message_id=message_id,
|
||||
title=title,
|
||||
body=body,
|
||||
topics=topics,
|
||||
users=users,
|
||||
targets=targets,
|
||||
data_payload=data,
|
||||
action=action,
|
||||
image=image,
|
||||
icon=icon,
|
||||
sound=sound,
|
||||
badge=badge,
|
||||
draft=draft,
|
||||
scheduled_at=scheduled_at,
|
||||
)
|
||||
|
||||
action_text = "saved as draft" if draft else "sent"
|
||||
if scheduled_at:
|
||||
action_text = f"scheduled for {scheduled_at}"
|
||||
|
||||
return json.dumps(
|
||||
{
|
||||
"success": True,
|
||||
"message": f"Push notification {action_text} successfully",
|
||||
"push": result,
|
||||
},
|
||||
indent=2,
|
||||
ensure_ascii=False,
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
error_msg = str(e)
|
||||
if "provider" in error_msg.lower():
|
||||
error_msg += (
|
||||
" (Hint: Make sure you have configured a push provider (FCM/APNs) in Appwrite)"
|
||||
)
|
||||
return json.dumps({"success": False, "error": error_msg}, indent=2)
|
||||
|
||||
async def delete_message(client: AppwriteClient, message_id: str) -> str:
|
||||
"""Delete message."""
|
||||
try:
|
||||
await client.delete_message(message_id)
|
||||
return json.dumps(
|
||||
{"success": True, "message": f"Message '{message_id}' deleted successfully"}, indent=2
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
return json.dumps({"success": False, "error": str(e)}, indent=2)
|
||||
685
plugins/appwrite/handlers/storage.py
Normal file
685
plugins/appwrite/handlers/storage.py
Normal file
@@ -0,0 +1,685 @@
|
||||
"""
|
||||
Storage Handler - manages Appwrite storage buckets and files
|
||||
|
||||
Phase I.3: 14 tools
|
||||
- Buckets: 5 (list, get, create, update, delete)
|
||||
- Files: 9 (list, get, create, update, delete, download, preview, view, get_url)
|
||||
"""
|
||||
|
||||
import json
|
||||
from typing import Any
|
||||
|
||||
from plugins.appwrite.client import AppwriteClient
|
||||
|
||||
def get_tool_specifications() -> list[dict[str, Any]]:
|
||||
"""Return tool specifications for ToolGenerator (14 tools)"""
|
||||
return [
|
||||
# =====================
|
||||
# BUCKETS (5)
|
||||
# =====================
|
||||
{
|
||||
"name": "list_buckets",
|
||||
"method_name": "list_buckets",
|
||||
"description": "List all storage buckets.",
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"queries": {
|
||||
"anyOf": [{"type": "array", "items": {"type": "string"}}, {"type": "null"}],
|
||||
"description": "Query strings for filtering",
|
||||
},
|
||||
"search": {
|
||||
"anyOf": [{"type": "string"}, {"type": "null"}],
|
||||
"description": "Search term to filter buckets",
|
||||
},
|
||||
},
|
||||
"required": [],
|
||||
},
|
||||
"scope": "read",
|
||||
},
|
||||
{
|
||||
"name": "get_bucket",
|
||||
"method_name": "get_bucket",
|
||||
"description": "Get bucket details by ID.",
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {"bucket_id": {"type": "string", "description": "Bucket ID"}},
|
||||
"required": ["bucket_id"],
|
||||
},
|
||||
"scope": "read",
|
||||
},
|
||||
{
|
||||
"name": "create_bucket",
|
||||
"method_name": "create_bucket",
|
||||
"description": "Create a new storage bucket.",
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"bucket_id": {
|
||||
"type": "string",
|
||||
"description": "Unique bucket ID. Use 'unique()' for auto-generation",
|
||||
},
|
||||
"name": {"type": "string", "description": "Bucket name"},
|
||||
"permissions": {
|
||||
"anyOf": [{"type": "array", "items": {"type": "string"}}, {"type": "null"}],
|
||||
"description": "Bucket permissions",
|
||||
},
|
||||
"file_security": {
|
||||
"type": "boolean",
|
||||
"description": "Enable file-level security",
|
||||
"default": True,
|
||||
},
|
||||
"enabled": {"type": "boolean", "description": "Enable bucket", "default": True},
|
||||
"maximum_file_size": {
|
||||
"anyOf": [{"type": "integer"}, {"type": "null"}],
|
||||
"description": "Maximum file size in bytes",
|
||||
},
|
||||
"allowed_file_extensions": {
|
||||
"anyOf": [{"type": "array", "items": {"type": "string"}}, {"type": "null"}],
|
||||
"description": "Allowed file extensions (e.g., ['jpg', 'png', 'pdf'])",
|
||||
},
|
||||
"compression": {
|
||||
"type": "string",
|
||||
"enum": ["none", "gzip", "zstd"],
|
||||
"description": "Compression algorithm",
|
||||
"default": "none",
|
||||
},
|
||||
"encryption": {
|
||||
"type": "boolean",
|
||||
"description": "Enable encryption",
|
||||
"default": True,
|
||||
},
|
||||
"antivirus": {
|
||||
"type": "boolean",
|
||||
"description": "Enable antivirus scanning",
|
||||
"default": True,
|
||||
},
|
||||
},
|
||||
"required": ["bucket_id", "name"],
|
||||
},
|
||||
"scope": "write",
|
||||
},
|
||||
{
|
||||
"name": "update_bucket",
|
||||
"method_name": "update_bucket",
|
||||
"description": "Update bucket settings.",
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"bucket_id": {"type": "string", "description": "Bucket ID"},
|
||||
"name": {"type": "string", "description": "Bucket name"},
|
||||
"permissions": {
|
||||
"anyOf": [{"type": "array", "items": {"type": "string"}}, {"type": "null"}],
|
||||
"description": "New permissions",
|
||||
},
|
||||
"enabled": {
|
||||
"anyOf": [{"type": "boolean"}, {"type": "null"}],
|
||||
"description": "Enable/disable bucket",
|
||||
},
|
||||
"maximum_file_size": {
|
||||
"anyOf": [{"type": "integer"}, {"type": "null"}],
|
||||
"description": "Maximum file size in bytes",
|
||||
},
|
||||
"allowed_file_extensions": {
|
||||
"anyOf": [{"type": "array", "items": {"type": "string"}}, {"type": "null"}],
|
||||
"description": "Allowed extensions",
|
||||
},
|
||||
},
|
||||
"required": ["bucket_id", "name"],
|
||||
},
|
||||
"scope": "write",
|
||||
},
|
||||
{
|
||||
"name": "delete_bucket",
|
||||
"method_name": "delete_bucket",
|
||||
"description": "Delete a storage bucket. All files in the bucket will be deleted.",
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"bucket_id": {"type": "string", "description": "Bucket ID to delete"}
|
||||
},
|
||||
"required": ["bucket_id"],
|
||||
},
|
||||
"scope": "admin",
|
||||
},
|
||||
# =====================
|
||||
# FILES (9)
|
||||
# =====================
|
||||
{
|
||||
"name": "list_files",
|
||||
"method_name": "list_files",
|
||||
"description": "List files in a bucket.",
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"bucket_id": {"type": "string", "description": "Bucket ID"},
|
||||
"queries": {
|
||||
"anyOf": [{"type": "array", "items": {"type": "string"}}, {"type": "null"}],
|
||||
"description": "Query strings for filtering",
|
||||
},
|
||||
"search": {
|
||||
"anyOf": [{"type": "string"}, {"type": "null"}],
|
||||
"description": "Search term to filter files by name",
|
||||
},
|
||||
},
|
||||
"required": ["bucket_id"],
|
||||
},
|
||||
"scope": "read",
|
||||
},
|
||||
{
|
||||
"name": "get_file",
|
||||
"method_name": "get_file",
|
||||
"description": "Get file metadata (not content).",
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"bucket_id": {"type": "string", "description": "Bucket ID"},
|
||||
"file_id": {"type": "string", "description": "File ID"},
|
||||
},
|
||||
"required": ["bucket_id", "file_id"],
|
||||
},
|
||||
"scope": "read",
|
||||
},
|
||||
{
|
||||
"name": "delete_file",
|
||||
"method_name": "delete_file",
|
||||
"description": "Delete a file from storage.",
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"bucket_id": {"type": "string", "description": "Bucket ID"},
|
||||
"file_id": {"type": "string", "description": "File ID to delete"},
|
||||
},
|
||||
"required": ["bucket_id", "file_id"],
|
||||
},
|
||||
"scope": "write",
|
||||
},
|
||||
{
|
||||
"name": "download_file",
|
||||
"method_name": "download_file",
|
||||
"description": "Download file content. Returns base64 encoded data.",
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"bucket_id": {"type": "string", "description": "Bucket ID"},
|
||||
"file_id": {"type": "string", "description": "File ID"},
|
||||
},
|
||||
"required": ["bucket_id", "file_id"],
|
||||
},
|
||||
"scope": "read",
|
||||
},
|
||||
{
|
||||
"name": "get_file_preview",
|
||||
"method_name": "get_file_preview",
|
||||
"description": "Get image preview with transformations (resize, crop, etc.). Only works for image files.",
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"bucket_id": {"type": "string", "description": "Bucket ID"},
|
||||
"file_id": {"type": "string", "description": "File ID"},
|
||||
"width": {
|
||||
"anyOf": [
|
||||
{"type": "integer", "minimum": 0, "maximum": 4000},
|
||||
{"type": "null"},
|
||||
],
|
||||
"description": "Width in pixels (0-4000)",
|
||||
},
|
||||
"height": {
|
||||
"anyOf": [
|
||||
{"type": "integer", "minimum": 0, "maximum": 4000},
|
||||
{"type": "null"},
|
||||
],
|
||||
"description": "Height in pixels (0-4000)",
|
||||
},
|
||||
"gravity": {
|
||||
"anyOf": [
|
||||
{
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"center",
|
||||
"top",
|
||||
"top-left",
|
||||
"top-right",
|
||||
"left",
|
||||
"right",
|
||||
"bottom",
|
||||
"bottom-left",
|
||||
"bottom-right",
|
||||
],
|
||||
},
|
||||
{"type": "null"},
|
||||
],
|
||||
"description": "Crop gravity",
|
||||
},
|
||||
"quality": {
|
||||
"anyOf": [
|
||||
{"type": "integer", "minimum": 0, "maximum": 100},
|
||||
{"type": "null"},
|
||||
],
|
||||
"description": "Image quality (0-100)",
|
||||
},
|
||||
"border_width": {
|
||||
"anyOf": [{"type": "integer"}, {"type": "null"}],
|
||||
"description": "Border width in pixels",
|
||||
},
|
||||
"border_color": {
|
||||
"anyOf": [{"type": "string"}, {"type": "null"}],
|
||||
"description": "Border color (hex without #)",
|
||||
},
|
||||
"border_radius": {
|
||||
"anyOf": [{"type": "integer"}, {"type": "null"}],
|
||||
"description": "Border radius for rounded corners",
|
||||
},
|
||||
"rotation": {
|
||||
"anyOf": [
|
||||
{"type": "integer", "minimum": 0, "maximum": 360},
|
||||
{"type": "null"},
|
||||
],
|
||||
"description": "Rotation angle (0-360)",
|
||||
},
|
||||
"background": {
|
||||
"anyOf": [{"type": "string"}, {"type": "null"}],
|
||||
"description": "Background color (hex without #)",
|
||||
},
|
||||
"output": {
|
||||
"anyOf": [
|
||||
{
|
||||
"type": "string",
|
||||
"enum": ["jpeg", "jpg", "png", "gif", "webp", "avif"],
|
||||
},
|
||||
{"type": "null"},
|
||||
],
|
||||
"description": "Output format",
|
||||
},
|
||||
},
|
||||
"required": ["bucket_id", "file_id"],
|
||||
},
|
||||
"scope": "read",
|
||||
},
|
||||
{
|
||||
"name": "get_file_view",
|
||||
"method_name": "get_file_view",
|
||||
"description": "Get file content for viewing in browser. Returns base64 encoded data.",
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"bucket_id": {"type": "string", "description": "Bucket ID"},
|
||||
"file_id": {"type": "string", "description": "File ID"},
|
||||
},
|
||||
"required": ["bucket_id", "file_id"],
|
||||
},
|
||||
"scope": "read",
|
||||
},
|
||||
{
|
||||
"name": "get_file_url",
|
||||
"method_name": "get_file_url",
|
||||
"description": "Get the public URL for a file (if bucket is public).",
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"bucket_id": {"type": "string", "description": "Bucket ID"},
|
||||
"file_id": {"type": "string", "description": "File ID"},
|
||||
"url_type": {
|
||||
"type": "string",
|
||||
"enum": ["view", "download", "preview"],
|
||||
"description": "Type of URL to generate",
|
||||
"default": "view",
|
||||
},
|
||||
},
|
||||
"required": ["bucket_id", "file_id"],
|
||||
},
|
||||
"scope": "read",
|
||||
},
|
||||
{
|
||||
"name": "bulk_delete_files",
|
||||
"method_name": "bulk_delete_files",
|
||||
"description": "Delete multiple files from a bucket.",
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"bucket_id": {"type": "string", "description": "Bucket ID"},
|
||||
"file_ids": {
|
||||
"type": "array",
|
||||
"items": {"type": "string"},
|
||||
"description": "Array of file IDs to delete",
|
||||
},
|
||||
},
|
||||
"required": ["bucket_id", "file_ids"],
|
||||
},
|
||||
"scope": "write",
|
||||
},
|
||||
{
|
||||
"name": "get_bucket_stats",
|
||||
"method_name": "get_bucket_stats",
|
||||
"description": "Get storage statistics for a bucket (file count and total size).",
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {"bucket_id": {"type": "string", "description": "Bucket ID"}},
|
||||
"required": ["bucket_id"],
|
||||
},
|
||||
"scope": "read",
|
||||
},
|
||||
]
|
||||
|
||||
# =====================
|
||||
# HANDLER FUNCTIONS
|
||||
# =====================
|
||||
|
||||
async def list_buckets(
|
||||
client: AppwriteClient, queries: list[str] | None = None, search: str | None = None
|
||||
) -> str:
|
||||
"""List all buckets."""
|
||||
try:
|
||||
result = await client.list_buckets(queries=queries, search=search)
|
||||
buckets = result.get("buckets", [])
|
||||
|
||||
response = {"success": True, "total": result.get("total", len(buckets)), "buckets": buckets}
|
||||
return json.dumps(response, indent=2, ensure_ascii=False)
|
||||
|
||||
except Exception as e:
|
||||
return json.dumps({"success": False, "error": str(e)}, indent=2)
|
||||
|
||||
async def get_bucket(client: AppwriteClient, bucket_id: str) -> str:
|
||||
"""Get bucket by ID."""
|
||||
try:
|
||||
result = await client.get_bucket(bucket_id)
|
||||
return json.dumps({"success": True, "bucket": result}, indent=2, ensure_ascii=False)
|
||||
|
||||
except Exception as e:
|
||||
return json.dumps({"success": False, "error": str(e)}, indent=2)
|
||||
|
||||
async def create_bucket(
|
||||
client: AppwriteClient,
|
||||
bucket_id: str,
|
||||
name: str,
|
||||
permissions: list[str] | None = None,
|
||||
file_security: bool = True,
|
||||
enabled: bool = True,
|
||||
maximum_file_size: int | None = None,
|
||||
allowed_file_extensions: list[str] | None = None,
|
||||
compression: str = "none",
|
||||
encryption: bool = True,
|
||||
antivirus: bool = True,
|
||||
) -> str:
|
||||
"""Create a new bucket."""
|
||||
try:
|
||||
result = await client.create_bucket(
|
||||
bucket_id=bucket_id,
|
||||
name=name,
|
||||
permissions=permissions,
|
||||
file_security=file_security,
|
||||
enabled=enabled,
|
||||
maximum_file_size=maximum_file_size,
|
||||
allowed_file_extensions=allowed_file_extensions,
|
||||
compression=compression,
|
||||
encryption=encryption,
|
||||
antivirus=antivirus,
|
||||
)
|
||||
return json.dumps(
|
||||
{"success": True, "message": f"Bucket '{name}' created successfully", "bucket": result},
|
||||
indent=2,
|
||||
ensure_ascii=False,
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
return json.dumps({"success": False, "error": str(e)}, indent=2)
|
||||
|
||||
async def update_bucket(
|
||||
client: AppwriteClient,
|
||||
bucket_id: str,
|
||||
name: str,
|
||||
permissions: list[str] | None = None,
|
||||
enabled: bool | None = None,
|
||||
maximum_file_size: int | None = None,
|
||||
allowed_file_extensions: list[str] | None = None,
|
||||
) -> str:
|
||||
"""Update bucket."""
|
||||
try:
|
||||
result = await client.update_bucket(
|
||||
bucket_id=bucket_id,
|
||||
name=name,
|
||||
permissions=permissions,
|
||||
enabled=enabled,
|
||||
maximum_file_size=maximum_file_size,
|
||||
allowed_file_extensions=allowed_file_extensions,
|
||||
)
|
||||
return json.dumps(
|
||||
{"success": True, "message": "Bucket updated successfully", "bucket": result},
|
||||
indent=2,
|
||||
ensure_ascii=False,
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
return json.dumps({"success": False, "error": str(e)}, indent=2)
|
||||
|
||||
async def delete_bucket(client: AppwriteClient, bucket_id: str) -> str:
|
||||
"""Delete bucket."""
|
||||
try:
|
||||
await client.delete_bucket(bucket_id)
|
||||
return json.dumps(
|
||||
{"success": True, "message": f"Bucket '{bucket_id}' deleted successfully"}, indent=2
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
return json.dumps({"success": False, "error": str(e)}, indent=2)
|
||||
|
||||
async def list_files(
|
||||
client: AppwriteClient,
|
||||
bucket_id: str,
|
||||
queries: list[str] | None = None,
|
||||
search: str | None = None,
|
||||
) -> str:
|
||||
"""List files in bucket."""
|
||||
try:
|
||||
result = await client.list_files(bucket_id=bucket_id, queries=queries, search=search)
|
||||
files = result.get("files", [])
|
||||
|
||||
response = {
|
||||
"success": True,
|
||||
"bucket_id": bucket_id,
|
||||
"total": result.get("total", len(files)),
|
||||
"files": files,
|
||||
}
|
||||
return json.dumps(response, indent=2, ensure_ascii=False)
|
||||
|
||||
except Exception as e:
|
||||
return json.dumps({"success": False, "error": str(e)}, indent=2)
|
||||
|
||||
async def get_file(client: AppwriteClient, bucket_id: str, file_id: str) -> str:
|
||||
"""Get file metadata."""
|
||||
try:
|
||||
result = await client.get_file(bucket_id, file_id)
|
||||
return json.dumps({"success": True, "file": result}, indent=2, ensure_ascii=False)
|
||||
|
||||
except Exception as e:
|
||||
return json.dumps({"success": False, "error": str(e)}, indent=2)
|
||||
|
||||
async def delete_file(client: AppwriteClient, bucket_id: str, file_id: str) -> str:
|
||||
"""Delete file."""
|
||||
try:
|
||||
await client.delete_file(bucket_id, file_id)
|
||||
return json.dumps(
|
||||
{"success": True, "message": f"File '{file_id}' deleted successfully"}, indent=2
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
return json.dumps({"success": False, "error": str(e)}, indent=2)
|
||||
|
||||
async def download_file(client: AppwriteClient, bucket_id: str, file_id: str) -> str:
|
||||
"""Download file content."""
|
||||
try:
|
||||
result = await client.get_file_download(bucket_id, file_id)
|
||||
return json.dumps(
|
||||
{
|
||||
"success": True,
|
||||
"file_id": file_id,
|
||||
"content_type": result.get("content_type"),
|
||||
"size": result.get("size"),
|
||||
"data_base64": result.get("data"),
|
||||
},
|
||||
indent=2,
|
||||
ensure_ascii=False,
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
return json.dumps({"success": False, "error": str(e)}, indent=2)
|
||||
|
||||
async def get_file_preview(
|
||||
client: AppwriteClient,
|
||||
bucket_id: str,
|
||||
file_id: str,
|
||||
width: int | None = None,
|
||||
height: int | None = None,
|
||||
gravity: str | None = None,
|
||||
quality: int | None = None,
|
||||
border_width: int | None = None,
|
||||
border_color: str | None = None,
|
||||
border_radius: int | None = None,
|
||||
rotation: int | None = None,
|
||||
background: str | None = None,
|
||||
output: str | None = None,
|
||||
) -> str:
|
||||
"""Get file preview with transformations."""
|
||||
try:
|
||||
result = await client.get_file_preview(
|
||||
bucket_id=bucket_id,
|
||||
file_id=file_id,
|
||||
width=width,
|
||||
height=height,
|
||||
gravity=gravity,
|
||||
quality=quality,
|
||||
border_width=border_width,
|
||||
border_color=border_color,
|
||||
border_radius=border_radius,
|
||||
rotation=rotation,
|
||||
background=background,
|
||||
output=output,
|
||||
)
|
||||
return json.dumps(
|
||||
{
|
||||
"success": True,
|
||||
"file_id": file_id,
|
||||
"transformations": {
|
||||
"width": width,
|
||||
"height": height,
|
||||
"quality": quality,
|
||||
"output": output,
|
||||
},
|
||||
"content_type": result.get("content_type"),
|
||||
"size": result.get("size"),
|
||||
"data_base64": result.get("data"),
|
||||
},
|
||||
indent=2,
|
||||
ensure_ascii=False,
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
return json.dumps({"success": False, "error": str(e)}, indent=2)
|
||||
|
||||
async def get_file_view(client: AppwriteClient, bucket_id: str, file_id: str) -> str:
|
||||
"""Get file for viewing."""
|
||||
try:
|
||||
result = await client.get_file_view(bucket_id, file_id)
|
||||
return json.dumps(
|
||||
{
|
||||
"success": True,
|
||||
"file_id": file_id,
|
||||
"content_type": result.get("content_type"),
|
||||
"size": result.get("size"),
|
||||
"data_base64": result.get("data"),
|
||||
},
|
||||
indent=2,
|
||||
ensure_ascii=False,
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
return json.dumps({"success": False, "error": str(e)}, indent=2)
|
||||
|
||||
async def get_file_url(
|
||||
client: AppwriteClient, bucket_id: str, file_id: str, url_type: str = "view"
|
||||
) -> str:
|
||||
"""Get file URL."""
|
||||
try:
|
||||
base_url = client.base_url
|
||||
if url_type == "download":
|
||||
url = f"{base_url}/storage/buckets/{bucket_id}/files/{file_id}/download"
|
||||
elif url_type == "preview":
|
||||
url = f"{base_url}/storage/buckets/{bucket_id}/files/{file_id}/preview"
|
||||
else:
|
||||
url = f"{base_url}/storage/buckets/{bucket_id}/files/{file_id}/view"
|
||||
|
||||
return json.dumps(
|
||||
{
|
||||
"success": True,
|
||||
"file_id": file_id,
|
||||
"url_type": url_type,
|
||||
"url": url,
|
||||
"note": "URL requires authentication unless bucket is public",
|
||||
},
|
||||
indent=2,
|
||||
ensure_ascii=False,
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
return json.dumps({"success": False, "error": str(e)}, indent=2)
|
||||
|
||||
async def bulk_delete_files(client: AppwriteClient, bucket_id: str, file_ids: list[str]) -> str:
|
||||
"""Delete multiple files."""
|
||||
try:
|
||||
results = []
|
||||
errors = []
|
||||
|
||||
for file_id in file_ids:
|
||||
try:
|
||||
await client.delete_file(bucket_id, file_id)
|
||||
results.append({"id": file_id, "success": True})
|
||||
except Exception as e:
|
||||
errors.append({"file_id": file_id, "error": str(e)})
|
||||
|
||||
response = {
|
||||
"success": len(errors) == 0,
|
||||
"deleted": len(results),
|
||||
"failed": len(errors),
|
||||
"results": results,
|
||||
}
|
||||
if errors:
|
||||
response["errors"] = errors
|
||||
|
||||
return json.dumps(response, indent=2, ensure_ascii=False)
|
||||
|
||||
except Exception as e:
|
||||
return json.dumps({"success": False, "error": str(e)}, indent=2)
|
||||
|
||||
async def get_bucket_stats(client: AppwriteClient, bucket_id: str) -> str:
|
||||
"""Get bucket statistics."""
|
||||
try:
|
||||
# Get files without query params to avoid syntax errors
|
||||
# Appwrite returns first 25 files by default
|
||||
result = await client.list_files(bucket_id=bucket_id)
|
||||
files = result.get("files", [])
|
||||
total = result.get("total", len(files))
|
||||
|
||||
total_size = sum(f.get("sizeOriginal", 0) for f in files)
|
||||
|
||||
return json.dumps(
|
||||
{
|
||||
"success": True,
|
||||
"bucket_id": bucket_id,
|
||||
"stats": {
|
||||
"file_count": total,
|
||||
"total_size_bytes": total_size,
|
||||
"total_size_mb": round(total_size / (1024 * 1024), 2),
|
||||
"sample_count": len(files),
|
||||
},
|
||||
"note": (
|
||||
f"Size calculated from {len(files)} sampled files"
|
||||
if total > len(files)
|
||||
else None
|
||||
),
|
||||
},
|
||||
indent=2,
|
||||
ensure_ascii=False,
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
return json.dumps({"success": False, "error": str(e)}, indent=2)
|
||||
298
plugins/appwrite/handlers/system.py
Normal file
298
plugins/appwrite/handlers/system.py
Normal file
@@ -0,0 +1,298 @@
|
||||
"""
|
||||
System Handler - manages Appwrite health checks and system utilities
|
||||
|
||||
Phase I.1: 8 tools
|
||||
- Health: 6 (health_check, health_db, health_cache, health_storage, health_queue, health_time)
|
||||
- Avatars: 2 (get_avatar_initials, get_qr_code)
|
||||
"""
|
||||
|
||||
import json
|
||||
from typing import Any
|
||||
|
||||
from plugins.appwrite.client import AppwriteClient
|
||||
|
||||
def get_tool_specifications() -> list[dict[str, Any]]:
|
||||
"""Return tool specifications for ToolGenerator (8 tools)"""
|
||||
return [
|
||||
# =====================
|
||||
# HEALTH (6)
|
||||
# =====================
|
||||
{
|
||||
"name": "health_check",
|
||||
"method_name": "health_check",
|
||||
"description": "Comprehensive health check of all Appwrite services. Returns status of database, cache, storage, and other services.",
|
||||
"schema": {"type": "object", "properties": {}, "required": []},
|
||||
"scope": "read",
|
||||
},
|
||||
{
|
||||
"name": "health_db",
|
||||
"method_name": "health_db",
|
||||
"description": "Check database health status. Returns ping time and connection status.",
|
||||
"schema": {"type": "object", "properties": {}, "required": []},
|
||||
"scope": "read",
|
||||
},
|
||||
{
|
||||
"name": "health_cache",
|
||||
"method_name": "health_cache",
|
||||
"description": "Check cache (Redis) health status.",
|
||||
"schema": {"type": "object", "properties": {}, "required": []},
|
||||
"scope": "read",
|
||||
},
|
||||
{
|
||||
"name": "health_storage",
|
||||
"method_name": "health_storage",
|
||||
"description": "Check storage health status. Verifies local and S3 storage availability.",
|
||||
"schema": {"type": "object", "properties": {}, "required": []},
|
||||
"scope": "read",
|
||||
},
|
||||
{
|
||||
"name": "health_queue",
|
||||
"method_name": "health_queue",
|
||||
"description": "Check queue health status. Returns queue sizes and processing status.",
|
||||
"schema": {"type": "object", "properties": {}, "required": []},
|
||||
"scope": "read",
|
||||
},
|
||||
{
|
||||
"name": "health_time",
|
||||
"method_name": "health_time",
|
||||
"description": "Check time synchronization status. Important for security and token validation.",
|
||||
"schema": {"type": "object", "properties": {}, "required": []},
|
||||
"scope": "read",
|
||||
},
|
||||
# =====================
|
||||
# AVATARS (2)
|
||||
# =====================
|
||||
{
|
||||
"name": "get_avatar_initials",
|
||||
"method_name": "get_avatar_initials",
|
||||
"description": "Generate an avatar image from name initials. Returns base64 encoded image.",
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"name": {
|
||||
"anyOf": [{"type": "string"}, {"type": "null"}],
|
||||
"description": "Full name to generate initials from",
|
||||
},
|
||||
"width": {
|
||||
"type": "integer",
|
||||
"description": "Image width in pixels",
|
||||
"default": 100,
|
||||
"minimum": 1,
|
||||
"maximum": 2000,
|
||||
},
|
||||
"height": {
|
||||
"type": "integer",
|
||||
"description": "Image height in pixels",
|
||||
"default": 100,
|
||||
"minimum": 1,
|
||||
"maximum": 2000,
|
||||
},
|
||||
"background": {
|
||||
"anyOf": [{"type": "string"}, {"type": "null"}],
|
||||
"description": "Background color in hex (without #)",
|
||||
},
|
||||
},
|
||||
"required": [],
|
||||
},
|
||||
"scope": "read",
|
||||
},
|
||||
{
|
||||
"name": "get_qr_code",
|
||||
"method_name": "get_qr_code",
|
||||
"description": "Generate a QR code image for any text or URL. Returns base64 encoded PNG.",
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"text": {"type": "string", "description": "Text or URL to encode in QR code"},
|
||||
"size": {
|
||||
"type": "integer",
|
||||
"description": "QR code size in pixels (width = height)",
|
||||
"default": 400,
|
||||
"minimum": 100,
|
||||
"maximum": 1000,
|
||||
},
|
||||
"margin": {
|
||||
"type": "integer",
|
||||
"description": "Margin around QR code in modules",
|
||||
"default": 1,
|
||||
"minimum": 0,
|
||||
"maximum": 10,
|
||||
},
|
||||
},
|
||||
"required": ["text"],
|
||||
},
|
||||
"scope": "read",
|
||||
},
|
||||
]
|
||||
|
||||
# =====================
|
||||
# HANDLER FUNCTIONS
|
||||
# =====================
|
||||
|
||||
async def health_check(client: AppwriteClient) -> str:
|
||||
"""Comprehensive health check of all services."""
|
||||
try:
|
||||
result = await client.health_check()
|
||||
|
||||
response = {
|
||||
"success": True,
|
||||
"healthy": result.get("healthy", False),
|
||||
"services": result.get("services", {}),
|
||||
"message": (
|
||||
"All services operational" if result.get("healthy") else "Some services have issues"
|
||||
),
|
||||
}
|
||||
return json.dumps(response, indent=2, ensure_ascii=False)
|
||||
|
||||
except Exception as e:
|
||||
return json.dumps({"success": False, "healthy": False, "error": str(e)}, indent=2)
|
||||
|
||||
async def health_db(client: AppwriteClient) -> str:
|
||||
"""Check database health."""
|
||||
try:
|
||||
result = await client.health_db()
|
||||
|
||||
response = {
|
||||
"success": True,
|
||||
"service": "database",
|
||||
"status": result.get("status", "unknown"),
|
||||
"ping": result.get("ping"),
|
||||
"message": (
|
||||
"Database is healthy" if result.get("status") == "pass" else "Database has issues"
|
||||
),
|
||||
}
|
||||
return json.dumps(response, indent=2, ensure_ascii=False)
|
||||
|
||||
except Exception as e:
|
||||
return json.dumps(
|
||||
{"success": False, "service": "database", "status": "error", "error": str(e)}, indent=2
|
||||
)
|
||||
|
||||
async def health_cache(client: AppwriteClient) -> str:
|
||||
"""Check cache health."""
|
||||
try:
|
||||
result = await client.health_cache()
|
||||
|
||||
response = {
|
||||
"success": True,
|
||||
"service": "cache",
|
||||
"status": result.get("status", "unknown"),
|
||||
"ping": result.get("ping"),
|
||||
"message": "Cache is healthy" if result.get("status") == "pass" else "Cache has issues",
|
||||
}
|
||||
return json.dumps(response, indent=2, ensure_ascii=False)
|
||||
|
||||
except Exception as e:
|
||||
return json.dumps(
|
||||
{"success": False, "service": "cache", "status": "error", "error": str(e)}, indent=2
|
||||
)
|
||||
|
||||
async def health_storage(client: AppwriteClient) -> str:
|
||||
"""Check storage health."""
|
||||
try:
|
||||
result = await client.health_storage_local()
|
||||
|
||||
response = {
|
||||
"success": True,
|
||||
"service": "storage",
|
||||
"status": result.get("status", "unknown"),
|
||||
"ping": result.get("ping"),
|
||||
"message": (
|
||||
"Storage is healthy" if result.get("status") == "pass" else "Storage has issues"
|
||||
),
|
||||
}
|
||||
return json.dumps(response, indent=2, ensure_ascii=False)
|
||||
|
||||
except Exception as e:
|
||||
return json.dumps(
|
||||
{"success": False, "service": "storage", "status": "error", "error": str(e)}, indent=2
|
||||
)
|
||||
|
||||
async def health_queue(client: AppwriteClient) -> str:
|
||||
"""Check queue health."""
|
||||
try:
|
||||
result = await client.health_queue()
|
||||
|
||||
response = {
|
||||
"success": True,
|
||||
"service": "queue",
|
||||
"status": result.get("status", "unknown"),
|
||||
"size": result.get("size"),
|
||||
"message": "Queue is healthy" if result.get("status") == "pass" else "Queue has issues",
|
||||
}
|
||||
return json.dumps(response, indent=2, ensure_ascii=False)
|
||||
|
||||
except Exception as e:
|
||||
return json.dumps(
|
||||
{"success": False, "service": "queue", "status": "error", "error": str(e)}, indent=2
|
||||
)
|
||||
|
||||
async def health_time(client: AppwriteClient) -> str:
|
||||
"""Check time synchronization."""
|
||||
try:
|
||||
result = await client.health_time()
|
||||
|
||||
response = {
|
||||
"success": True,
|
||||
"service": "time",
|
||||
"local_time": result.get("localTime"),
|
||||
"remote_time": result.get("remoteTime"),
|
||||
"diff": result.get("diff"),
|
||||
"message": (
|
||||
"Time is synchronized"
|
||||
if abs(result.get("diff", 999)) < 60
|
||||
else "Time drift detected"
|
||||
),
|
||||
}
|
||||
return json.dumps(response, indent=2, ensure_ascii=False)
|
||||
|
||||
except Exception as e:
|
||||
return json.dumps(
|
||||
{"success": False, "service": "time", "status": "error", "error": str(e)}, indent=2
|
||||
)
|
||||
|
||||
async def get_avatar_initials(
|
||||
client: AppwriteClient,
|
||||
name: str | None = None,
|
||||
width: int = 100,
|
||||
height: int = 100,
|
||||
background: str | None = None,
|
||||
) -> str:
|
||||
"""Generate avatar from initials."""
|
||||
try:
|
||||
result = await client.get_avatar_initials(
|
||||
name=name, width=width, height=height, background=background
|
||||
)
|
||||
|
||||
response = {
|
||||
"success": True,
|
||||
"message": f"Avatar generated for '{name or 'Anonymous'}'",
|
||||
"width": width,
|
||||
"height": height,
|
||||
"content_type": result.get("content_type", "image/png"),
|
||||
"size": result.get("size"),
|
||||
"data_base64": result.get("data"),
|
||||
}
|
||||
return json.dumps(response, indent=2, ensure_ascii=False)
|
||||
|
||||
except Exception as e:
|
||||
return json.dumps({"success": False, "error": str(e)}, indent=2)
|
||||
|
||||
async def get_qr_code(client: AppwriteClient, text: str, size: int = 400, margin: int = 1) -> str:
|
||||
"""Generate QR code."""
|
||||
try:
|
||||
result = await client.get_qr_code(text=text, size=size, margin=margin)
|
||||
|
||||
response = {
|
||||
"success": True,
|
||||
"message": "QR code generated successfully",
|
||||
"text": text,
|
||||
"size": size,
|
||||
"content_type": result.get("content_type", "image/png"),
|
||||
"data_size": result.get("size"),
|
||||
"data_base64": result.get("data"),
|
||||
}
|
||||
return json.dumps(response, indent=2, ensure_ascii=False)
|
||||
|
||||
except Exception as e:
|
||||
return json.dumps({"success": False, "error": str(e)}, indent=2)
|
||||
374
plugins/appwrite/handlers/teams.py
Normal file
374
plugins/appwrite/handlers/teams.py
Normal file
@@ -0,0 +1,374 @@
|
||||
"""
|
||||
Teams Handler - manages Appwrite teams and memberships
|
||||
|
||||
Phase I.2: 10 tools
|
||||
- Teams: 5 (list, get, create, update, delete)
|
||||
- Memberships: 5 (list_memberships, create_membership, update_membership, delete_membership, get_membership_status)
|
||||
"""
|
||||
|
||||
import json
|
||||
from typing import Any
|
||||
|
||||
from plugins.appwrite.client import AppwriteClient
|
||||
|
||||
def get_tool_specifications() -> list[dict[str, Any]]:
|
||||
"""Return tool specifications for ToolGenerator (10 tools)"""
|
||||
return [
|
||||
# =====================
|
||||
# TEAMS (5)
|
||||
# =====================
|
||||
{
|
||||
"name": "list_teams",
|
||||
"method_name": "list_teams",
|
||||
"description": "List all teams in the project.",
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"queries": {
|
||||
"anyOf": [{"type": "array", "items": {"type": "string"}}, {"type": "null"}],
|
||||
"description": "Query strings for filtering",
|
||||
},
|
||||
"search": {
|
||||
"anyOf": [{"type": "string"}, {"type": "null"}],
|
||||
"description": "Search term to filter teams by name",
|
||||
},
|
||||
},
|
||||
"required": [],
|
||||
},
|
||||
"scope": "read",
|
||||
},
|
||||
{
|
||||
"name": "get_team",
|
||||
"method_name": "get_team",
|
||||
"description": "Get team details by ID.",
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {"team_id": {"type": "string", "description": "Team ID"}},
|
||||
"required": ["team_id"],
|
||||
},
|
||||
"scope": "read",
|
||||
},
|
||||
{
|
||||
"name": "create_team",
|
||||
"method_name": "create_team",
|
||||
"description": "Create a new team. Use 'unique()' for auto-generated team ID.",
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"team_id": {
|
||||
"type": "string",
|
||||
"description": "Unique team ID. Use 'unique()' for auto-generation",
|
||||
},
|
||||
"name": {"type": "string", "description": "Team name"},
|
||||
"roles": {
|
||||
"anyOf": [{"type": "array", "items": {"type": "string"}}, {"type": "null"}],
|
||||
"description": "Array of roles available in this team (e.g., ['owner', 'editor', 'viewer'])",
|
||||
},
|
||||
},
|
||||
"required": ["team_id", "name"],
|
||||
},
|
||||
"scope": "write",
|
||||
},
|
||||
{
|
||||
"name": "update_team",
|
||||
"method_name": "update_team",
|
||||
"description": "Update team name.",
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"team_id": {"type": "string", "description": "Team ID"},
|
||||
"name": {"type": "string", "description": "New team name"},
|
||||
},
|
||||
"required": ["team_id", "name"],
|
||||
},
|
||||
"scope": "write",
|
||||
},
|
||||
{
|
||||
"name": "delete_team",
|
||||
"method_name": "delete_team",
|
||||
"description": "Delete a team and all its memberships. This action is irreversible.",
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {"team_id": {"type": "string", "description": "Team ID to delete"}},
|
||||
"required": ["team_id"],
|
||||
},
|
||||
"scope": "admin",
|
||||
},
|
||||
# =====================
|
||||
# MEMBERSHIPS (5)
|
||||
# =====================
|
||||
{
|
||||
"name": "list_team_memberships",
|
||||
"method_name": "list_team_memberships",
|
||||
"description": "List all memberships (members) of a team.",
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"team_id": {"type": "string", "description": "Team ID"},
|
||||
"queries": {
|
||||
"anyOf": [{"type": "array", "items": {"type": "string"}}, {"type": "null"}],
|
||||
"description": "Query strings for filtering",
|
||||
},
|
||||
"search": {
|
||||
"anyOf": [{"type": "string"}, {"type": "null"}],
|
||||
"description": "Search term to filter members",
|
||||
},
|
||||
},
|
||||
"required": ["team_id"],
|
||||
},
|
||||
"scope": "read",
|
||||
},
|
||||
{
|
||||
"name": "create_team_membership",
|
||||
"method_name": "create_team_membership",
|
||||
"description": "Invite a user to join a team. Can invite by email, phone, or existing user ID.",
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"team_id": {"type": "string", "description": "Team ID"},
|
||||
"roles": {
|
||||
"type": "array",
|
||||
"items": {"type": "string"},
|
||||
"description": "Roles to assign to the member",
|
||||
},
|
||||
"email": {
|
||||
"anyOf": [{"type": "string", "format": "email"}, {"type": "null"}],
|
||||
"description": "Email address to invite",
|
||||
},
|
||||
"user_id": {
|
||||
"anyOf": [{"type": "string"}, {"type": "null"}],
|
||||
"description": "Existing user ID to add",
|
||||
},
|
||||
"phone": {
|
||||
"anyOf": [{"type": "string"}, {"type": "null"}],
|
||||
"description": "Phone number to invite",
|
||||
},
|
||||
"url": {
|
||||
"anyOf": [{"type": "string"}, {"type": "null"}],
|
||||
"description": "Redirect URL after accepting invitation",
|
||||
},
|
||||
"name": {
|
||||
"anyOf": [{"type": "string"}, {"type": "null"}],
|
||||
"description": "Name of the invitee",
|
||||
},
|
||||
},
|
||||
"required": ["team_id", "roles"],
|
||||
},
|
||||
"scope": "write",
|
||||
},
|
||||
{
|
||||
"name": "update_membership",
|
||||
"method_name": "update_membership",
|
||||
"description": "Update team membership roles.",
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"team_id": {"type": "string", "description": "Team ID"},
|
||||
"membership_id": {"type": "string", "description": "Membership ID"},
|
||||
"roles": {
|
||||
"type": "array",
|
||||
"items": {"type": "string"},
|
||||
"description": "New roles for the member",
|
||||
},
|
||||
},
|
||||
"required": ["team_id", "membership_id", "roles"],
|
||||
},
|
||||
"scope": "write",
|
||||
},
|
||||
{
|
||||
"name": "delete_membership",
|
||||
"method_name": "delete_membership",
|
||||
"description": "Remove a member from a team.",
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"team_id": {"type": "string", "description": "Team ID"},
|
||||
"membership_id": {"type": "string", "description": "Membership ID to remove"},
|
||||
},
|
||||
"required": ["team_id", "membership_id"],
|
||||
},
|
||||
"scope": "write",
|
||||
},
|
||||
{
|
||||
"name": "get_team_prefs",
|
||||
"method_name": "get_team_prefs",
|
||||
"description": "Get team preferences/settings.",
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {"team_id": {"type": "string", "description": "Team ID"}},
|
||||
"required": ["team_id"],
|
||||
},
|
||||
"scope": "read",
|
||||
},
|
||||
]
|
||||
|
||||
# =====================
|
||||
# HANDLER FUNCTIONS
|
||||
# =====================
|
||||
|
||||
async def list_teams(
|
||||
client: AppwriteClient, queries: list[str] | None = None, search: str | None = None
|
||||
) -> str:
|
||||
"""List all teams."""
|
||||
try:
|
||||
result = await client.list_teams(queries=queries, search=search)
|
||||
teams = result.get("teams", [])
|
||||
|
||||
response = {"success": True, "total": result.get("total", len(teams)), "teams": teams}
|
||||
return json.dumps(response, indent=2, ensure_ascii=False)
|
||||
|
||||
except Exception as e:
|
||||
return json.dumps({"success": False, "error": str(e)}, indent=2)
|
||||
|
||||
async def get_team(client: AppwriteClient, team_id: str) -> str:
|
||||
"""Get team by ID."""
|
||||
try:
|
||||
result = await client.get_team(team_id)
|
||||
return json.dumps({"success": True, "team": result}, indent=2, ensure_ascii=False)
|
||||
|
||||
except Exception as e:
|
||||
return json.dumps({"success": False, "error": str(e)}, indent=2)
|
||||
|
||||
async def create_team(
|
||||
client: AppwriteClient, team_id: str, name: str, roles: list[str] | None = None
|
||||
) -> str:
|
||||
"""Create a new team."""
|
||||
try:
|
||||
result = await client.create_team(team_id=team_id, name=name, roles=roles)
|
||||
return json.dumps(
|
||||
{"success": True, "message": f"Team '{name}' created successfully", "team": result},
|
||||
indent=2,
|
||||
ensure_ascii=False,
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
return json.dumps({"success": False, "error": str(e)}, indent=2)
|
||||
|
||||
async def update_team(client: AppwriteClient, team_id: str, name: str) -> str:
|
||||
"""Update team name."""
|
||||
try:
|
||||
result = await client.update_team(team_id=team_id, name=name)
|
||||
return json.dumps(
|
||||
{"success": True, "message": "Team updated successfully", "team": result},
|
||||
indent=2,
|
||||
ensure_ascii=False,
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
return json.dumps({"success": False, "error": str(e)}, indent=2)
|
||||
|
||||
async def delete_team(client: AppwriteClient, team_id: str) -> str:
|
||||
"""Delete team."""
|
||||
try:
|
||||
await client.delete_team(team_id)
|
||||
return json.dumps(
|
||||
{"success": True, "message": f"Team '{team_id}' deleted successfully"}, indent=2
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
return json.dumps({"success": False, "error": str(e)}, indent=2)
|
||||
|
||||
async def list_team_memberships(
|
||||
client: AppwriteClient,
|
||||
team_id: str,
|
||||
queries: list[str] | None = None,
|
||||
search: str | None = None,
|
||||
) -> str:
|
||||
"""List team memberships."""
|
||||
try:
|
||||
result = await client.list_team_memberships(team_id=team_id, queries=queries, search=search)
|
||||
memberships = result.get("memberships", [])
|
||||
|
||||
response = {
|
||||
"success": True,
|
||||
"team_id": team_id,
|
||||
"total": result.get("total", len(memberships)),
|
||||
"memberships": memberships,
|
||||
}
|
||||
return json.dumps(response, indent=2, ensure_ascii=False)
|
||||
|
||||
except Exception as e:
|
||||
return json.dumps({"success": False, "error": str(e)}, indent=2)
|
||||
|
||||
async def create_team_membership(
|
||||
client: AppwriteClient,
|
||||
team_id: str,
|
||||
roles: list[str],
|
||||
email: str | None = None,
|
||||
user_id: str | None = None,
|
||||
phone: str | None = None,
|
||||
url: str | None = None,
|
||||
name: str | None = None,
|
||||
) -> str:
|
||||
"""Create team membership (invite)."""
|
||||
try:
|
||||
result = await client.create_team_membership(
|
||||
team_id=team_id,
|
||||
roles=roles,
|
||||
email=email,
|
||||
user_id=user_id,
|
||||
phone=phone,
|
||||
url=url,
|
||||
name=name,
|
||||
)
|
||||
target = email or user_id or phone or "user"
|
||||
return json.dumps(
|
||||
{
|
||||
"success": True,
|
||||
"message": f"Membership created/invitation sent to {target}",
|
||||
"membership": result,
|
||||
},
|
||||
indent=2,
|
||||
ensure_ascii=False,
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
return json.dumps({"success": False, "error": str(e)}, indent=2)
|
||||
|
||||
async def update_membership(
|
||||
client: AppwriteClient, team_id: str, membership_id: str, roles: list[str]
|
||||
) -> str:
|
||||
"""Update membership roles."""
|
||||
try:
|
||||
result = await client.update_membership(
|
||||
team_id=team_id, membership_id=membership_id, roles=roles
|
||||
)
|
||||
return json.dumps(
|
||||
{
|
||||
"success": True,
|
||||
"message": f"Membership roles updated to: {roles}",
|
||||
"membership": result,
|
||||
},
|
||||
indent=2,
|
||||
ensure_ascii=False,
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
return json.dumps({"success": False, "error": str(e)}, indent=2)
|
||||
|
||||
async def delete_membership(client: AppwriteClient, team_id: str, membership_id: str) -> str:
|
||||
"""Delete membership."""
|
||||
try:
|
||||
await client.delete_membership(team_id, membership_id)
|
||||
return json.dumps(
|
||||
{"success": True, "message": f"Membership '{membership_id}' removed from team"},
|
||||
indent=2,
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
return json.dumps({"success": False, "error": str(e)}, indent=2)
|
||||
|
||||
async def get_team_prefs(client: AppwriteClient, team_id: str) -> str:
|
||||
"""Get team preferences (placeholder - requires team prefs endpoint)."""
|
||||
try:
|
||||
# Note: Team prefs endpoint may vary by Appwrite version
|
||||
# This is a simplified version that returns team info
|
||||
result = await client.get_team(team_id)
|
||||
return json.dumps(
|
||||
{"success": True, "team_id": team_id, "prefs": result.get("prefs", {}), "team": result},
|
||||
indent=2,
|
||||
ensure_ascii=False,
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
return json.dumps({"success": False, "error": str(e)}, indent=2)
|
||||
384
plugins/appwrite/handlers/users.py
Normal file
384
plugins/appwrite/handlers/users.py
Normal file
@@ -0,0 +1,384 @@
|
||||
"""
|
||||
Users Handler - manages Appwrite user operations
|
||||
|
||||
Phase I.2: 12 tools
|
||||
- User CRUD: 5 (list, get, create, update, delete)
|
||||
- User Properties: 4 (update_email, update_phone, update_status, update_labels)
|
||||
- Sessions: 3 (list_sessions, delete_sessions, delete_session)
|
||||
"""
|
||||
|
||||
import json
|
||||
from typing import Any
|
||||
|
||||
from plugins.appwrite.client import AppwriteClient
|
||||
|
||||
def get_tool_specifications() -> list[dict[str, Any]]:
|
||||
"""Return tool specifications for ToolGenerator (12 tools)"""
|
||||
return [
|
||||
# =====================
|
||||
# USER CRUD (5)
|
||||
# =====================
|
||||
{
|
||||
"name": "list_users",
|
||||
"method_name": "list_users",
|
||||
"description": "List all users in the project with optional filtering and search.",
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"queries": {
|
||||
"anyOf": [{"type": "array", "items": {"type": "string"}}, {"type": "null"}],
|
||||
"description": "Query strings (e.g., 'limit(25)', 'offset(0)')",
|
||||
},
|
||||
"search": {
|
||||
"anyOf": [{"type": "string"}, {"type": "null"}],
|
||||
"description": "Search term to filter users by name or email",
|
||||
},
|
||||
},
|
||||
"required": [],
|
||||
},
|
||||
"scope": "read",
|
||||
},
|
||||
{
|
||||
"name": "get_user",
|
||||
"method_name": "get_user",
|
||||
"description": "Get user details by ID.",
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {"user_id": {"type": "string", "description": "User ID"}},
|
||||
"required": ["user_id"],
|
||||
},
|
||||
"scope": "read",
|
||||
},
|
||||
{
|
||||
"name": "create_user",
|
||||
"method_name": "create_user",
|
||||
"description": "Create a new user. Use 'unique()' for auto-generated user ID.",
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"user_id": {
|
||||
"type": "string",
|
||||
"description": "Unique user ID. Use 'unique()' for auto-generation",
|
||||
},
|
||||
"email": {
|
||||
"anyOf": [{"type": "string", "format": "email"}, {"type": "null"}],
|
||||
"description": "User email address",
|
||||
},
|
||||
"phone": {
|
||||
"anyOf": [{"type": "string"}, {"type": "null"}],
|
||||
"description": "User phone number (E.164 format)",
|
||||
},
|
||||
"password": {
|
||||
"anyOf": [{"type": "string", "minLength": 8}, {"type": "null"}],
|
||||
"description": "User password (min 8 characters)",
|
||||
},
|
||||
"name": {
|
||||
"anyOf": [{"type": "string"}, {"type": "null"}],
|
||||
"description": "User display name",
|
||||
},
|
||||
},
|
||||
"required": ["user_id"],
|
||||
},
|
||||
"scope": "write",
|
||||
},
|
||||
{
|
||||
"name": "update_user_name",
|
||||
"method_name": "update_user_name",
|
||||
"description": "Update user display name.",
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"user_id": {"type": "string", "description": "User ID"},
|
||||
"name": {"type": "string", "description": "New display name"},
|
||||
},
|
||||
"required": ["user_id", "name"],
|
||||
},
|
||||
"scope": "write",
|
||||
},
|
||||
{
|
||||
"name": "delete_user",
|
||||
"method_name": "delete_user",
|
||||
"description": "Delete a user. This action is irreversible.",
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {"user_id": {"type": "string", "description": "User ID to delete"}},
|
||||
"required": ["user_id"],
|
||||
},
|
||||
"scope": "admin",
|
||||
},
|
||||
# =====================
|
||||
# USER PROPERTIES (4)
|
||||
# =====================
|
||||
{
|
||||
"name": "update_user_email",
|
||||
"method_name": "update_user_email",
|
||||
"description": "Update user email address.",
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"user_id": {"type": "string", "description": "User ID"},
|
||||
"email": {
|
||||
"type": "string",
|
||||
"format": "email",
|
||||
"description": "New email address",
|
||||
},
|
||||
},
|
||||
"required": ["user_id", "email"],
|
||||
},
|
||||
"scope": "write",
|
||||
},
|
||||
{
|
||||
"name": "update_user_phone",
|
||||
"method_name": "update_user_phone",
|
||||
"description": "Update user phone number.",
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"user_id": {"type": "string", "description": "User ID"},
|
||||
"number": {
|
||||
"type": "string",
|
||||
"description": "New phone number (E.164 format, e.g., +14155552671)",
|
||||
},
|
||||
},
|
||||
"required": ["user_id", "number"],
|
||||
},
|
||||
"scope": "write",
|
||||
},
|
||||
{
|
||||
"name": "update_user_status",
|
||||
"method_name": "update_user_status",
|
||||
"description": "Enable or disable a user account.",
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"user_id": {"type": "string", "description": "User ID"},
|
||||
"status": {
|
||||
"type": "boolean",
|
||||
"description": "True to enable, False to disable",
|
||||
},
|
||||
},
|
||||
"required": ["user_id", "status"],
|
||||
},
|
||||
"scope": "admin",
|
||||
},
|
||||
{
|
||||
"name": "update_user_labels",
|
||||
"method_name": "update_user_labels",
|
||||
"description": "Set user labels for permission management. Labels can be used in permission strings like 'label:admin'.",
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"user_id": {"type": "string", "description": "User ID"},
|
||||
"labels": {
|
||||
"type": "array",
|
||||
"items": {"type": "string"},
|
||||
"description": "Array of label strings (replaces existing labels)",
|
||||
},
|
||||
},
|
||||
"required": ["user_id", "labels"],
|
||||
},
|
||||
"scope": "admin",
|
||||
},
|
||||
# =====================
|
||||
# SESSIONS (3)
|
||||
# =====================
|
||||
{
|
||||
"name": "list_user_sessions",
|
||||
"method_name": "list_user_sessions",
|
||||
"description": "List all active sessions for a user.",
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {"user_id": {"type": "string", "description": "User ID"}},
|
||||
"required": ["user_id"],
|
||||
},
|
||||
"scope": "read",
|
||||
},
|
||||
{
|
||||
"name": "delete_user_sessions",
|
||||
"method_name": "delete_user_sessions",
|
||||
"description": "Delete all sessions for a user (force logout everywhere).",
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {"user_id": {"type": "string", "description": "User ID"}},
|
||||
"required": ["user_id"],
|
||||
},
|
||||
"scope": "admin",
|
||||
},
|
||||
{
|
||||
"name": "delete_user_session",
|
||||
"method_name": "delete_user_session",
|
||||
"description": "Delete a specific user session.",
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"user_id": {"type": "string", "description": "User ID"},
|
||||
"session_id": {"type": "string", "description": "Session ID to delete"},
|
||||
},
|
||||
"required": ["user_id", "session_id"],
|
||||
},
|
||||
"scope": "admin",
|
||||
},
|
||||
]
|
||||
|
||||
# =====================
|
||||
# HANDLER FUNCTIONS
|
||||
# =====================
|
||||
|
||||
async def list_users(
|
||||
client: AppwriteClient, queries: list[str] | None = None, search: str | None = None
|
||||
) -> str:
|
||||
"""List all users."""
|
||||
try:
|
||||
result = await client.list_users(queries=queries, search=search)
|
||||
users = result.get("users", [])
|
||||
|
||||
response = {"success": True, "total": result.get("total", len(users)), "users": users}
|
||||
return json.dumps(response, indent=2, ensure_ascii=False)
|
||||
|
||||
except Exception as e:
|
||||
return json.dumps({"success": False, "error": str(e)}, indent=2)
|
||||
|
||||
async def get_user(client: AppwriteClient, user_id: str) -> str:
|
||||
"""Get user by ID."""
|
||||
try:
|
||||
result = await client.get_user(user_id)
|
||||
return json.dumps({"success": True, "user": result}, indent=2, ensure_ascii=False)
|
||||
|
||||
except Exception as e:
|
||||
return json.dumps({"success": False, "error": str(e)}, indent=2)
|
||||
|
||||
async def create_user(
|
||||
client: AppwriteClient,
|
||||
user_id: str,
|
||||
email: str | None = None,
|
||||
phone: str | None = None,
|
||||
password: str | None = None,
|
||||
name: str | None = None,
|
||||
) -> str:
|
||||
"""Create a new user."""
|
||||
try:
|
||||
result = await client.create_user(
|
||||
user_id=user_id, email=email, phone=phone, password=password, name=name
|
||||
)
|
||||
return json.dumps(
|
||||
{"success": True, "message": "User created successfully", "user": result},
|
||||
indent=2,
|
||||
ensure_ascii=False,
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
return json.dumps({"success": False, "error": str(e)}, indent=2)
|
||||
|
||||
async def update_user_name(client: AppwriteClient, user_id: str, name: str) -> str:
|
||||
"""Update user name."""
|
||||
try:
|
||||
result = await client.update_user_name(user_id=user_id, name=name)
|
||||
return json.dumps(
|
||||
{"success": True, "message": "User name updated successfully", "user": result},
|
||||
indent=2,
|
||||
ensure_ascii=False,
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
return json.dumps({"success": False, "error": str(e)}, indent=2)
|
||||
|
||||
async def delete_user(client: AppwriteClient, user_id: str) -> str:
|
||||
"""Delete user."""
|
||||
try:
|
||||
await client.delete_user(user_id)
|
||||
return json.dumps(
|
||||
{"success": True, "message": f"User '{user_id}' deleted successfully"}, indent=2
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
return json.dumps({"success": False, "error": str(e)}, indent=2)
|
||||
|
||||
async def update_user_email(client: AppwriteClient, user_id: str, email: str) -> str:
|
||||
"""Update user email."""
|
||||
try:
|
||||
result = await client.update_user_email(user_id=user_id, email=email)
|
||||
return json.dumps(
|
||||
{"success": True, "message": "User email updated successfully", "user": result},
|
||||
indent=2,
|
||||
ensure_ascii=False,
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
return json.dumps({"success": False, "error": str(e)}, indent=2)
|
||||
|
||||
async def update_user_phone(client: AppwriteClient, user_id: str, number: str) -> str:
|
||||
"""Update user phone."""
|
||||
try:
|
||||
result = await client.update_user_phone(user_id=user_id, number=number)
|
||||
return json.dumps(
|
||||
{"success": True, "message": "User phone updated successfully", "user": result},
|
||||
indent=2,
|
||||
ensure_ascii=False,
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
return json.dumps({"success": False, "error": str(e)}, indent=2)
|
||||
|
||||
async def update_user_status(client: AppwriteClient, user_id: str, status: bool) -> str:
|
||||
"""Update user status (enable/disable)."""
|
||||
try:
|
||||
result = await client.update_user_status(user_id=user_id, status=status)
|
||||
action = "enabled" if status else "disabled"
|
||||
return json.dumps(
|
||||
{"success": True, "message": f"User {action} successfully", "user": result},
|
||||
indent=2,
|
||||
ensure_ascii=False,
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
return json.dumps({"success": False, "error": str(e)}, indent=2)
|
||||
|
||||
async def update_user_labels(client: AppwriteClient, user_id: str, labels: list[str]) -> str:
|
||||
"""Update user labels."""
|
||||
try:
|
||||
result = await client.update_user_labels(user_id=user_id, labels=labels)
|
||||
return json.dumps(
|
||||
{"success": True, "message": f"User labels updated: {labels}", "user": result},
|
||||
indent=2,
|
||||
ensure_ascii=False,
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
return json.dumps({"success": False, "error": str(e)}, indent=2)
|
||||
|
||||
async def list_user_sessions(client: AppwriteClient, user_id: str) -> str:
|
||||
"""List user sessions."""
|
||||
try:
|
||||
result = await client.list_user_sessions(user_id)
|
||||
sessions = result.get("sessions", [])
|
||||
|
||||
response = {
|
||||
"success": True,
|
||||
"user_id": user_id,
|
||||
"total": result.get("total", len(sessions)),
|
||||
"sessions": sessions,
|
||||
}
|
||||
return json.dumps(response, indent=2, ensure_ascii=False)
|
||||
|
||||
except Exception as e:
|
||||
return json.dumps({"success": False, "error": str(e)}, indent=2)
|
||||
|
||||
async def delete_user_sessions(client: AppwriteClient, user_id: str) -> str:
|
||||
"""Delete all user sessions."""
|
||||
try:
|
||||
await client.delete_user_sessions(user_id)
|
||||
return json.dumps(
|
||||
{"success": True, "message": f"All sessions for user '{user_id}' deleted"}, indent=2
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
return json.dumps({"success": False, "error": str(e)}, indent=2)
|
||||
|
||||
async def delete_user_session(client: AppwriteClient, user_id: str, session_id: str) -> str:
|
||||
"""Delete a specific session."""
|
||||
try:
|
||||
await client.delete_user_session(user_id, session_id)
|
||||
return json.dumps({"success": True, "message": f"Session '{session_id}' deleted"}, indent=2)
|
||||
|
||||
except Exception as e:
|
||||
return json.dumps({"success": False, "error": str(e)}, indent=2)
|
||||
166
plugins/appwrite/plugin.py
Normal file
166
plugins/appwrite/plugin.py
Normal file
@@ -0,0 +1,166 @@
|
||||
"""
|
||||
Appwrite Plugin - Self-Hosted Backend-as-a-Service Management
|
||||
|
||||
Complete Appwrite Self-Hosted management through REST APIs.
|
||||
Provides tools for Databases, Documents, Users, Teams, Storage,
|
||||
Functions, Messaging, and System operations.
|
||||
|
||||
For Self-Hosted instances deployed on Coolify.
|
||||
"""
|
||||
|
||||
from typing import Any
|
||||
|
||||
from plugins.appwrite import handlers
|
||||
from plugins.appwrite.client import AppwriteClient
|
||||
from plugins.base import BasePlugin
|
||||
|
||||
class AppwritePlugin(BasePlugin):
|
||||
"""
|
||||
Appwrite Self-Hosted Plugin - Complete backend management.
|
||||
|
||||
Provides comprehensive Appwrite management capabilities including:
|
||||
- Database operations (Databases, Collections, Attributes, Indexes)
|
||||
- Document operations (CRUD, Bulk ops, Queries, Search)
|
||||
- User management (CRUD, Labels, Sessions, Status)
|
||||
- Team management (Teams, Memberships, Roles)
|
||||
- Storage operations (Buckets, Files, Image transformation)
|
||||
- Functions (Functions, Deployments, Executions)
|
||||
- Messaging (Topics, Subscribers, Email/SMS/Push messages)
|
||||
- System operations (Health checks, Avatars)
|
||||
|
||||
Phase I.1: Database (18) + Documents (12) + System (8) = 38 tools
|
||||
Phase I.2: Users (12) + Teams (10) = 22 tools
|
||||
Phase I.3: Storage (14) = 14 tools
|
||||
Phase I.4: Functions (14) + Messaging (12) = 26 tools
|
||||
|
||||
Total: 100 tools - Complete!
|
||||
"""
|
||||
|
||||
@staticmethod
|
||||
def get_plugin_name() -> str:
|
||||
"""Return plugin type identifier"""
|
||||
return "appwrite"
|
||||
|
||||
@staticmethod
|
||||
def get_required_config_keys() -> list[str]:
|
||||
"""Return required configuration keys"""
|
||||
return ["url", "project_id", "api_key"]
|
||||
|
||||
def __init__(self, config: dict[str, Any], project_id: str | None = None):
|
||||
"""
|
||||
Initialize Appwrite plugin with client.
|
||||
|
||||
Args:
|
||||
config: Configuration dictionary containing:
|
||||
- url: Appwrite instance URL (e.g., https://appwrite.example.com/v1)
|
||||
- project_id: Appwrite project ID
|
||||
- api_key: API key with appropriate scopes
|
||||
project_id: Optional MCP project ID (auto-generated if not provided)
|
||||
"""
|
||||
super().__init__(config, project_id=project_id)
|
||||
|
||||
# Create Appwrite API client
|
||||
self.client = AppwriteClient(
|
||||
base_url=config["url"], project_id=config["project_id"], api_key=config["api_key"]
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def get_tool_specifications() -> list[dict[str, Any]]:
|
||||
"""
|
||||
Return all tool specifications for ToolGenerator.
|
||||
|
||||
This method is called by ToolGenerator to create unified tools
|
||||
with site parameter routing.
|
||||
|
||||
Returns:
|
||||
List of tool specification dictionaries
|
||||
"""
|
||||
specs = []
|
||||
|
||||
# Phase I.1: Core (38 tools)
|
||||
specs.extend(handlers.databases.get_tool_specifications()) # 18 tools
|
||||
specs.extend(handlers.documents.get_tool_specifications()) # 12 tools
|
||||
specs.extend(handlers.system.get_tool_specifications()) # 8 tools
|
||||
|
||||
# Phase I.2: Auth & Teams (22 tools)
|
||||
specs.extend(handlers.users.get_tool_specifications()) # 12 tools
|
||||
specs.extend(handlers.teams.get_tool_specifications()) # 10 tools
|
||||
|
||||
# Phase I.3: Storage (14 tools)
|
||||
specs.extend(handlers.storage.get_tool_specifications()) # 14 tools
|
||||
|
||||
# Phase I.4: Functions & Messaging (26 tools)
|
||||
specs.extend(handlers.functions.get_tool_specifications()) # 14 tools
|
||||
specs.extend(handlers.messaging.get_tool_specifications()) # 12 tools
|
||||
|
||||
return specs
|
||||
|
||||
def __getattr__(self, name: str):
|
||||
"""
|
||||
Dynamically delegate method calls to appropriate handlers.
|
||||
|
||||
This allows ToolGenerator to call methods like plugin.list_databases()
|
||||
without explicitly defining each method.
|
||||
|
||||
Args:
|
||||
name: Method name being called
|
||||
|
||||
Returns:
|
||||
Handler function from the appropriate handler module
|
||||
"""
|
||||
# Try to find the method in handler modules
|
||||
handler_modules = [
|
||||
# Phase I.1: Core
|
||||
handlers.databases,
|
||||
handlers.documents,
|
||||
handlers.system,
|
||||
# Phase I.2: Auth & Teams
|
||||
handlers.users,
|
||||
handlers.teams,
|
||||
# Phase I.3: Storage
|
||||
handlers.storage,
|
||||
# Phase I.4: Functions & Messaging
|
||||
handlers.functions,
|
||||
handlers.messaging,
|
||||
]
|
||||
|
||||
for handler_module in handler_modules:
|
||||
if hasattr(handler_module, name):
|
||||
func = getattr(handler_module, name)
|
||||
|
||||
# Create wrapper that passes self.client
|
||||
async def wrapper(_func=func, **kwargs):
|
||||
return await _func(self.client, **kwargs)
|
||||
|
||||
return wrapper
|
||||
|
||||
# Method not found in any handler
|
||||
raise AttributeError(f"'{self.__class__.__name__}' object has no attribute '{name}'")
|
||||
|
||||
async def check_health(self) -> dict[str, Any]:
|
||||
"""
|
||||
Check if Appwrite instance is accessible (internal use).
|
||||
|
||||
Note: This is named check_health to avoid shadowing the handler's
|
||||
health_check function which is exposed as an MCP tool.
|
||||
|
||||
Returns:
|
||||
Dict containing health check result
|
||||
"""
|
||||
try:
|
||||
result = await self.client.health_check()
|
||||
is_healthy = result.get("status") == "pass"
|
||||
return {
|
||||
"healthy": is_healthy,
|
||||
"message": f"Appwrite instance at {self.client.base_url} is {'accessible' if is_healthy else 'not accessible'}",
|
||||
}
|
||||
except Exception as e:
|
||||
return {"healthy": False, "message": f"Appwrite health check failed: {str(e)}"}
|
||||
|
||||
async def health_check(self, **kwargs) -> str:
|
||||
"""
|
||||
Override BasePlugin.health_check to use handler function.
|
||||
|
||||
This ensures the MCP tool returns a JSON string, not a Dict.
|
||||
"""
|
||||
return await handlers.system.health_check(self.client)
|
||||
290
plugins/base.py
Normal file
290
plugins/base.py
Normal file
@@ -0,0 +1,290 @@
|
||||
"""
|
||||
Base Plugin Interface
|
||||
|
||||
All project plugins must inherit from this base class.
|
||||
This ensures consistency across different project types.
|
||||
"""
|
||||
|
||||
import logging
|
||||
from abc import ABC, abstractmethod
|
||||
from typing import Any
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class BasePlugin(ABC):
|
||||
"""
|
||||
Base class for all project plugins.
|
||||
|
||||
Each plugin represents a specific project type (WordPress, Supabase, etc.)
|
||||
and provides MCP tools for managing that project.
|
||||
"""
|
||||
|
||||
def __init__(self, config: dict[str, Any], project_id: str | None = None):
|
||||
"""
|
||||
Initialize plugin with project configuration.
|
||||
|
||||
**Option B Architecture (New)**:
|
||||
plugin = WordPressPlugin(config)
|
||||
# project_id extracted from config or generated
|
||||
|
||||
**Legacy Architecture**:
|
||||
plugin = WordPressPlugin(config, project_id="wordpress_site1")
|
||||
# Explicit project_id
|
||||
|
||||
Args:
|
||||
config: Project-specific configuration (URLs, credentials, etc.)
|
||||
project_id: Optional unique identifier (auto-generated if not provided)
|
||||
"""
|
||||
# Auto-generate project_id if not provided (Option B)
|
||||
if project_id is None:
|
||||
# Generate from class name + config
|
||||
class_name = self.__class__.__name__.lower().replace("plugin", "")
|
||||
site_id = config.get("site_id", config.get("url", "unknown"))
|
||||
# Simple hash for unique ID
|
||||
import hashlib
|
||||
|
||||
if isinstance(site_id, str):
|
||||
hash_suffix = hashlib.md5(site_id.encode()).hexdigest()[:8]
|
||||
project_id = f"{class_name}_{hash_suffix}"
|
||||
else:
|
||||
import time
|
||||
|
||||
project_id = f"{class_name}_{int(time.time())}"
|
||||
|
||||
self.project_id = project_id
|
||||
self.config = config
|
||||
self.logger = logging.getLogger(f"{self.__class__.__name__}.{project_id}")
|
||||
|
||||
# Validate required configuration
|
||||
self._validate_config()
|
||||
|
||||
self.logger.info(f"Initialized plugin for project: {project_id}")
|
||||
|
||||
@abstractmethod
|
||||
def get_plugin_name(self) -> str:
|
||||
"""
|
||||
Return the plugin type name (e.g., 'wordpress', 'supabase').
|
||||
|
||||
Returns:
|
||||
str: Plugin type identifier
|
||||
"""
|
||||
pass
|
||||
|
||||
@staticmethod
|
||||
def get_tool_specifications() -> list[dict[str, Any]]:
|
||||
"""
|
||||
Return tool specifications for Option B architecture (ToolGenerator).
|
||||
|
||||
This is a STATIC method that returns tool specifications without
|
||||
needing a plugin instance. ToolGenerator uses these specifications
|
||||
to create unified tools with site parameter routing.
|
||||
|
||||
Each specification should contain:
|
||||
- name: Tool name (without site prefix)
|
||||
- method_name: Method to call on plugin instance
|
||||
- description: What the tool does
|
||||
- schema: JSON Schema for input validation (without site parameter)
|
||||
- scope: Required scope (read, write, admin)
|
||||
|
||||
Example:
|
||||
[
|
||||
{
|
||||
"name": "list_posts",
|
||||
"method_name": "list_posts",
|
||||
"description": "List WordPress posts",
|
||||
"schema": {...},
|
||||
"scope": "read"
|
||||
}
|
||||
]
|
||||
|
||||
Returns:
|
||||
List[Dict]: List of tool specifications for ToolGenerator
|
||||
|
||||
Note:
|
||||
Override this in subclasses for Option B architecture.
|
||||
If not implemented, returns empty list (legacy plugins).
|
||||
"""
|
||||
return []
|
||||
|
||||
def get_tools(self) -> list[dict[str, Any]]:
|
||||
"""
|
||||
Return list of MCP tools provided by this plugin (LEGACY).
|
||||
|
||||
**DEPRECATED in Option B Architecture**
|
||||
This method is kept for backward compatibility with legacy per-site
|
||||
tool architecture. New plugins should implement get_tool_specifications()
|
||||
instead, which is used by ToolGenerator for unified tools.
|
||||
|
||||
Legacy per-site tools format:
|
||||
- name: Tool name (prefixed with plugin type and project_id)
|
||||
- description: What the tool does
|
||||
- inputSchema: JSON Schema for input validation
|
||||
- handler: Async function that implements the tool
|
||||
|
||||
Returns:
|
||||
List[Dict]: List of tool definitions (empty for Option B plugins)
|
||||
|
||||
Note:
|
||||
Option B plugins should return [] here as tools are registered
|
||||
via get_tool_specifications() + ToolGenerator instead.
|
||||
"""
|
||||
return []
|
||||
|
||||
def _validate_config(self) -> None:
|
||||
"""
|
||||
Validate that required configuration keys are present.
|
||||
Override in subclasses to add specific validation.
|
||||
"""
|
||||
required_keys = self.get_required_config_keys()
|
||||
missing_keys = [key for key in required_keys if key not in self.config]
|
||||
|
||||
if missing_keys:
|
||||
raise ValueError(
|
||||
f"Missing required configuration keys for {self.project_id}: "
|
||||
f"{', '.join(missing_keys)}"
|
||||
)
|
||||
|
||||
def get_required_config_keys(self) -> list[str]:
|
||||
"""
|
||||
Return list of required configuration keys.
|
||||
Override in subclasses.
|
||||
|
||||
Returns:
|
||||
List[str]: Required config keys
|
||||
"""
|
||||
return []
|
||||
|
||||
async def health_check(self) -> dict[str, Any]:
|
||||
"""
|
||||
Check if the project is accessible and healthy.
|
||||
Override in subclasses to implement specific health checks.
|
||||
|
||||
Returns:
|
||||
Dict with 'healthy' (bool) and 'message' (str) keys
|
||||
"""
|
||||
return {"healthy": True, "message": "Health check not implemented for this plugin"}
|
||||
|
||||
def get_project_info(self) -> dict[str, Any]:
|
||||
"""
|
||||
Return basic information about this project instance.
|
||||
|
||||
Returns:
|
||||
Dict with project metadata
|
||||
"""
|
||||
return {
|
||||
"project_id": self.project_id,
|
||||
"plugin_type": self.get_plugin_name(),
|
||||
"config_keys": list(self.config.keys()),
|
||||
}
|
||||
|
||||
def _create_tool_name(self, action: str) -> str:
|
||||
"""
|
||||
Create a standardized tool name for per-site tools.
|
||||
|
||||
FORMAT: {plugin_type}_{site_id}_{action}
|
||||
Example: wordpress_site1_list_posts
|
||||
|
||||
This is used for backward-compatible per-site tools.
|
||||
Unified tools (wordpress_list_posts) are created separately by UnifiedToolGenerator.
|
||||
|
||||
Args:
|
||||
action: The action this tool performs
|
||||
|
||||
Returns:
|
||||
str: Formatted tool name with project_id for per-site tools
|
||||
"""
|
||||
# Extract just the site_id from project_id (e.g., "site1" from "wordpress_site1")
|
||||
# project_id format is {plugin_type}_{site_id}
|
||||
site_id = self.project_id.replace(f"{self.get_plugin_name()}_", "")
|
||||
return f"{self.get_plugin_name()}_{site_id}_{action}"
|
||||
|
||||
def _format_error_response(self, error: Exception, action: str) -> str:
|
||||
"""
|
||||
Format an error into a user-friendly message.
|
||||
|
||||
Args:
|
||||
error: The exception that occurred
|
||||
action: The action that was being performed
|
||||
|
||||
Returns:
|
||||
str: Formatted error message
|
||||
"""
|
||||
error_msg = f"Error performing {action} on {self.project_id}: {str(error)}"
|
||||
self.logger.error(error_msg, exc_info=True)
|
||||
return error_msg
|
||||
|
||||
def _format_success_response(self, data: Any, action: str) -> str:
|
||||
"""
|
||||
Format a successful response.
|
||||
|
||||
Args:
|
||||
data: The data to return
|
||||
action: The action that was performed
|
||||
|
||||
Returns:
|
||||
str: Formatted success message
|
||||
"""
|
||||
if isinstance(data, (dict, list)):
|
||||
import json
|
||||
|
||||
return json.dumps(data, indent=2, ensure_ascii=False)
|
||||
return str(data)
|
||||
|
||||
|
||||
class PluginRegistry:
|
||||
"""
|
||||
Registry for managing available plugin types.
|
||||
"""
|
||||
|
||||
def __init__(self):
|
||||
self._plugin_classes: dict[str, type] = {}
|
||||
self.logger = logging.getLogger("PluginRegistry")
|
||||
|
||||
def register(self, plugin_type: str, plugin_class: type) -> None:
|
||||
"""
|
||||
Register a plugin class.
|
||||
|
||||
Args:
|
||||
plugin_type: Type identifier (e.g., 'wordpress')
|
||||
plugin_class: Plugin class (must inherit from BasePlugin)
|
||||
"""
|
||||
if not issubclass(plugin_class, BasePlugin):
|
||||
raise TypeError(f"{plugin_class} must inherit from BasePlugin")
|
||||
|
||||
self._plugin_classes[plugin_type] = plugin_class
|
||||
self.logger.info(f"Registered plugin type: {plugin_type}")
|
||||
|
||||
def create_instance(
|
||||
self, plugin_type: str, project_id: str, config: dict[str, Any]
|
||||
) -> BasePlugin:
|
||||
"""
|
||||
Create a plugin instance.
|
||||
|
||||
**Option B Compatible**: Uses new BasePlugin signature (config, project_id)
|
||||
|
||||
Args:
|
||||
plugin_type: Type of plugin to create
|
||||
project_id: Unique project identifier
|
||||
config: Project configuration
|
||||
|
||||
Returns:
|
||||
BasePlugin: Instantiated plugin
|
||||
|
||||
Raises:
|
||||
KeyError: If plugin_type is not registered
|
||||
"""
|
||||
if plugin_type not in self._plugin_classes:
|
||||
raise KeyError(f"Unknown plugin type: {plugin_type}")
|
||||
|
||||
plugin_class = self._plugin_classes[plugin_type]
|
||||
# Option B signature: config first, project_id optional
|
||||
return plugin_class(config, project_id=project_id)
|
||||
|
||||
def get_registered_types(self) -> list[str]:
|
||||
"""Get list of registered plugin types."""
|
||||
return list(self._plugin_classes.keys())
|
||||
|
||||
def is_registered(self, plugin_type: str) -> bool:
|
||||
"""Check if a plugin type is registered."""
|
||||
return plugin_type in self._plugin_classes
|
||||
6
plugins/directus/__init__.py
Normal file
6
plugins/directus/__init__.py
Normal file
@@ -0,0 +1,6 @@
|
||||
"""Directus CMS Plugin - Self-Hosted Headless CMS Management"""
|
||||
|
||||
from plugins.directus.client import DirectusClient
|
||||
from plugins.directus.plugin import DirectusPlugin
|
||||
|
||||
__all__ = ["DirectusPlugin", "DirectusClient"]
|
||||
1335
plugins/directus/client.py
Normal file
1335
plugins/directus/client.py
Normal file
File diff suppressed because it is too large
Load Diff
38
plugins/directus/handlers/__init__.py
Normal file
38
plugins/directus/handlers/__init__.py
Normal file
@@ -0,0 +1,38 @@
|
||||
"""
|
||||
Directus Plugin Handlers
|
||||
|
||||
Each handler module provides:
|
||||
1. Tool specifications (get_tool_specifications)
|
||||
2. Handler functions for each tool
|
||||
|
||||
Phase J.1: items (12) + collections (14) = 26 tools
|
||||
Phase J.2: files (12) + users (10) = 22 tools
|
||||
Phase J.3: access (12) + automation (12) = 24 tools
|
||||
Phase J.4: content (10) + dashboards (8) + system (10) = 28 tools
|
||||
|
||||
Total: 100 tools
|
||||
"""
|
||||
|
||||
from plugins.directus.handlers import (
|
||||
access,
|
||||
automation,
|
||||
collections,
|
||||
content,
|
||||
dashboards,
|
||||
files,
|
||||
items,
|
||||
system,
|
||||
users,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
"items",
|
||||
"collections",
|
||||
"files",
|
||||
"users",
|
||||
"access",
|
||||
"automation",
|
||||
"content",
|
||||
"dashboards",
|
||||
"system",
|
||||
]
|
||||
466
plugins/directus/handlers/access.py
Normal file
466
plugins/directus/handlers/access.py
Normal file
@@ -0,0 +1,466 @@
|
||||
"""
|
||||
Access Control Handler - Roles, Permissions, Policies
|
||||
|
||||
Phase J.3: 12 tools
|
||||
- Roles: list, get, create, update, delete (5)
|
||||
- Permissions: list, get, create, update, delete, get_my (6)
|
||||
- Policies: list (1)
|
||||
|
||||
Note: Directus v10+ uses policies for permissions.
|
||||
The 'policy' parameter is required in create_permission.
|
||||
"""
|
||||
|
||||
import json
|
||||
from typing import Any
|
||||
|
||||
from plugins.directus.client import DirectusClient
|
||||
|
||||
def _parse_json_param(value: Any, param_name: str = "parameter") -> Any:
|
||||
"""Parse a parameter that may be a JSON string or already a native type."""
|
||||
if value is None:
|
||||
return None
|
||||
if isinstance(value, (dict, list)):
|
||||
return value
|
||||
if isinstance(value, str):
|
||||
stripped = value.strip()
|
||||
if stripped.startswith("{") or stripped.startswith("["):
|
||||
try:
|
||||
return json.loads(stripped)
|
||||
except json.JSONDecodeError as e:
|
||||
raise ValueError(f"Invalid JSON in '{param_name}': {e}")
|
||||
return value
|
||||
|
||||
def get_tool_specifications() -> list[dict[str, Any]]:
|
||||
"""Return tool specifications for ToolGenerator (12 tools)"""
|
||||
return [
|
||||
# =====================
|
||||
# ROLES (5)
|
||||
# =====================
|
||||
{
|
||||
"name": "list_roles",
|
||||
"method_name": "list_roles",
|
||||
"description": "List all roles in Directus.",
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"filter": {
|
||||
"anyOf": [{"type": "object"}, {"type": "null"}],
|
||||
"description": "Filter object",
|
||||
},
|
||||
"sort": {
|
||||
"anyOf": [{"type": "array", "items": {"type": "string"}}, {"type": "null"}],
|
||||
"description": "Sort fields",
|
||||
},
|
||||
"limit": {
|
||||
"type": "integer",
|
||||
"description": "Maximum roles to return",
|
||||
"default": 100,
|
||||
},
|
||||
},
|
||||
"required": [],
|
||||
},
|
||||
"scope": "read",
|
||||
},
|
||||
{
|
||||
"name": "get_role",
|
||||
"method_name": "get_role",
|
||||
"description": "Get role details by ID.",
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {"id": {"type": "string", "description": "Role UUID"}},
|
||||
"required": ["id"],
|
||||
},
|
||||
"scope": "read",
|
||||
},
|
||||
{
|
||||
"name": "create_role",
|
||||
"method_name": "create_role",
|
||||
"description": "Create a new role.",
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"name": {"type": "string", "description": "Role name"},
|
||||
"icon": {
|
||||
"type": "string",
|
||||
"description": "Material icon name",
|
||||
"default": "supervised_user_circle",
|
||||
},
|
||||
"description": {
|
||||
"anyOf": [{"type": "string"}, {"type": "null"}],
|
||||
"description": "Role description",
|
||||
},
|
||||
"admin_access": {
|
||||
"type": "boolean",
|
||||
"description": "Full admin access",
|
||||
"default": False,
|
||||
},
|
||||
"app_access": {
|
||||
"type": "boolean",
|
||||
"description": "Access to admin app",
|
||||
"default": True,
|
||||
},
|
||||
},
|
||||
"required": ["name"],
|
||||
},
|
||||
"scope": "admin",
|
||||
},
|
||||
{
|
||||
"name": "update_role",
|
||||
"method_name": "update_role",
|
||||
"description": "Update role settings.",
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"id": {"type": "string", "description": "Role UUID"},
|
||||
"data": {
|
||||
"type": "object",
|
||||
"description": "Fields to update (name, icon, description, admin_access, etc.)",
|
||||
},
|
||||
},
|
||||
"required": ["id", "data"],
|
||||
},
|
||||
"scope": "admin",
|
||||
},
|
||||
{
|
||||
"name": "delete_role",
|
||||
"method_name": "delete_role",
|
||||
"description": "Delete a role. Users with this role will have no role.",
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {"id": {"type": "string", "description": "Role UUID to delete"}},
|
||||
"required": ["id"],
|
||||
},
|
||||
"scope": "admin",
|
||||
},
|
||||
# =====================
|
||||
# PERMISSIONS (6)
|
||||
# =====================
|
||||
{
|
||||
"name": "list_permissions",
|
||||
"method_name": "list_permissions",
|
||||
"description": "List all permissions.",
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"filter": {
|
||||
"anyOf": [{"type": "object"}, {"type": "null"}],
|
||||
"description": 'Filter object (e.g., {"role": {"_eq": "role-uuid"}})',
|
||||
},
|
||||
"limit": {
|
||||
"type": "integer",
|
||||
"description": "Maximum permissions to return",
|
||||
"default": 100,
|
||||
},
|
||||
},
|
||||
"required": [],
|
||||
},
|
||||
"scope": "read",
|
||||
},
|
||||
{
|
||||
"name": "get_permission",
|
||||
"method_name": "get_permission",
|
||||
"description": "Get permission details by ID.",
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {"id": {"type": "string", "description": "Permission ID"}},
|
||||
"required": ["id"],
|
||||
},
|
||||
"scope": "read",
|
||||
},
|
||||
{
|
||||
"name": "create_permission",
|
||||
"method_name": "create_permission",
|
||||
"description": "Create a new permission rule. NOTE: In Directus v10+, 'policy' is required.",
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"collection": {"type": "string", "description": "Collection name"},
|
||||
"action": {
|
||||
"type": "string",
|
||||
"enum": ["create", "read", "update", "delete", "share"],
|
||||
"description": "Permission action",
|
||||
},
|
||||
"policy": {
|
||||
"type": "string",
|
||||
"description": "Policy UUID (REQUIRED in Directus v10+). Use list_policies to get available policies.",
|
||||
},
|
||||
"role": {
|
||||
"anyOf": [{"type": "string"}, {"type": "null"}],
|
||||
"description": "Role UUID (null for public) - deprecated in v10+, use policy instead",
|
||||
},
|
||||
"permissions": {
|
||||
"anyOf": [{"type": "object"}, {"type": "null"}],
|
||||
"description": "Filter rules for this permission",
|
||||
},
|
||||
"validation": {
|
||||
"anyOf": [{"type": "object"}, {"type": "null"}],
|
||||
"description": "Validation rules for create/update",
|
||||
},
|
||||
"presets": {
|
||||
"anyOf": [{"type": "object"}, {"type": "null"}],
|
||||
"description": "Default values for create",
|
||||
},
|
||||
"fields": {
|
||||
"anyOf": [{"type": "array", "items": {"type": "string"}}, {"type": "null"}],
|
||||
"description": "Allowed fields (* for all)",
|
||||
},
|
||||
},
|
||||
"required": ["collection", "action", "policy"],
|
||||
},
|
||||
"scope": "admin",
|
||||
},
|
||||
{
|
||||
"name": "update_permission",
|
||||
"method_name": "update_permission",
|
||||
"description": "Update a permission rule.",
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"id": {"type": "string", "description": "Permission ID"},
|
||||
"data": {"type": "object", "description": "Fields to update"},
|
||||
},
|
||||
"required": ["id", "data"],
|
||||
},
|
||||
"scope": "admin",
|
||||
},
|
||||
{
|
||||
"name": "delete_permission",
|
||||
"method_name": "delete_permission",
|
||||
"description": "Delete a permission rule.",
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {"id": {"type": "string", "description": "Permission ID to delete"}},
|
||||
"required": ["id"],
|
||||
},
|
||||
"scope": "admin",
|
||||
},
|
||||
{
|
||||
"name": "get_my_permissions",
|
||||
"method_name": "get_my_permissions",
|
||||
"description": "Get the current user's effective permissions.",
|
||||
"schema": {"type": "object", "properties": {}, "required": []},
|
||||
"scope": "read",
|
||||
},
|
||||
# =====================
|
||||
# POLICIES (1)
|
||||
# =====================
|
||||
{
|
||||
"name": "list_policies",
|
||||
"method_name": "list_policies",
|
||||
"description": "List all access policies.",
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"filter": {
|
||||
"anyOf": [{"type": "object"}, {"type": "null"}],
|
||||
"description": "Filter object",
|
||||
},
|
||||
"limit": {
|
||||
"type": "integer",
|
||||
"description": "Maximum policies to return",
|
||||
"default": 100,
|
||||
},
|
||||
},
|
||||
"required": [],
|
||||
},
|
||||
"scope": "read",
|
||||
},
|
||||
]
|
||||
|
||||
# =====================
|
||||
# HANDLER FUNCTIONS
|
||||
# =====================
|
||||
|
||||
async def list_roles(
|
||||
client: DirectusClient,
|
||||
filter: dict | None = None,
|
||||
sort: list[str] | None = None,
|
||||
limit: int = 100,
|
||||
) -> str:
|
||||
"""List roles."""
|
||||
try:
|
||||
# Parse JSON string parameters
|
||||
parsed_filter = _parse_json_param(filter, "filter")
|
||||
parsed_sort = _parse_json_param(sort, "sort")
|
||||
|
||||
result = await client.list_roles(filter=parsed_filter, sort=parsed_sort, limit=limit)
|
||||
roles = result.get("data", [])
|
||||
return json.dumps(
|
||||
{"success": True, "total": len(roles), "roles": roles}, indent=2, ensure_ascii=False
|
||||
)
|
||||
except Exception as e:
|
||||
return json.dumps({"success": False, "error": str(e)}, indent=2)
|
||||
|
||||
async def get_role(client: DirectusClient, id: str) -> str:
|
||||
"""Get role by ID."""
|
||||
try:
|
||||
result = await client.get_role(id)
|
||||
return json.dumps(
|
||||
{"success": True, "role": result.get("data")}, indent=2, ensure_ascii=False
|
||||
)
|
||||
except Exception as e:
|
||||
return json.dumps({"success": False, "error": str(e)}, indent=2)
|
||||
|
||||
async def create_role(
|
||||
client: DirectusClient,
|
||||
name: str,
|
||||
icon: str = "supervised_user_circle",
|
||||
description: str | None = None,
|
||||
admin_access: bool = False,
|
||||
app_access: bool = True,
|
||||
) -> str:
|
||||
"""Create a new role."""
|
||||
try:
|
||||
result = await client.create_role(
|
||||
name=name,
|
||||
icon=icon,
|
||||
description=description,
|
||||
admin_access=admin_access,
|
||||
app_access=app_access,
|
||||
)
|
||||
return json.dumps(
|
||||
{"success": True, "message": f"Role '{name}' created", "role": result.get("data")},
|
||||
indent=2,
|
||||
ensure_ascii=False,
|
||||
)
|
||||
except Exception as e:
|
||||
return json.dumps({"success": False, "error": str(e)}, indent=2)
|
||||
|
||||
async def update_role(client: DirectusClient, id: str, data: dict[str, Any]) -> str:
|
||||
"""Update role."""
|
||||
try:
|
||||
# Parse JSON string parameter
|
||||
parsed_data = _parse_json_param(data, "data")
|
||||
result = await client.update_role(id, parsed_data)
|
||||
return json.dumps(
|
||||
{"success": True, "message": "Role updated", "role": result.get("data")},
|
||||
indent=2,
|
||||
ensure_ascii=False,
|
||||
)
|
||||
except Exception as e:
|
||||
return json.dumps({"success": False, "error": str(e)}, indent=2)
|
||||
|
||||
async def delete_role(client: DirectusClient, id: str) -> str:
|
||||
"""Delete a role."""
|
||||
try:
|
||||
await client.delete_role(id)
|
||||
return json.dumps({"success": True, "message": f"Role {id} deleted"}, indent=2)
|
||||
except Exception as e:
|
||||
return json.dumps({"success": False, "error": str(e)}, indent=2)
|
||||
|
||||
async def list_permissions(
|
||||
client: DirectusClient, filter: dict | None = None, limit: int = 100
|
||||
) -> str:
|
||||
"""List permissions."""
|
||||
try:
|
||||
# Parse JSON string parameter
|
||||
parsed_filter = _parse_json_param(filter, "filter")
|
||||
result = await client.list_permissions(filter=parsed_filter, limit=limit)
|
||||
permissions = result.get("data", [])
|
||||
return json.dumps(
|
||||
{"success": True, "total": len(permissions), "permissions": permissions},
|
||||
indent=2,
|
||||
ensure_ascii=False,
|
||||
)
|
||||
except Exception as e:
|
||||
return json.dumps({"success": False, "error": str(e)}, indent=2)
|
||||
|
||||
async def get_permission(client: DirectusClient, id: str) -> str:
|
||||
"""Get permission by ID."""
|
||||
try:
|
||||
result = await client.get_permission(id)
|
||||
return json.dumps(
|
||||
{"success": True, "permission": result.get("data")}, indent=2, ensure_ascii=False
|
||||
)
|
||||
except Exception as e:
|
||||
return json.dumps({"success": False, "error": str(e)}, indent=2)
|
||||
|
||||
async def create_permission(
|
||||
client: DirectusClient,
|
||||
collection: str,
|
||||
action: str,
|
||||
policy: str,
|
||||
role: str | None = None,
|
||||
permissions: dict | None = None,
|
||||
validation: dict | None = None,
|
||||
presets: dict | None = None,
|
||||
fields: list[str] | None = None,
|
||||
) -> str:
|
||||
"""Create a new permission. Requires 'policy' in Directus v10+."""
|
||||
try:
|
||||
# Parse JSON string parameters
|
||||
parsed_permissions = _parse_json_param(permissions, "permissions")
|
||||
parsed_validation = _parse_json_param(validation, "validation")
|
||||
parsed_presets = _parse_json_param(presets, "presets")
|
||||
parsed_fields = _parse_json_param(fields, "fields")
|
||||
|
||||
result = await client.create_permission(
|
||||
collection=collection,
|
||||
action=action,
|
||||
policy=policy,
|
||||
role=role,
|
||||
permissions=parsed_permissions,
|
||||
validation=parsed_validation,
|
||||
presets=parsed_presets,
|
||||
fields=parsed_fields,
|
||||
)
|
||||
return json.dumps(
|
||||
{
|
||||
"success": True,
|
||||
"message": f"Permission created for {collection}.{action}",
|
||||
"permission": result.get("data"),
|
||||
},
|
||||
indent=2,
|
||||
ensure_ascii=False,
|
||||
)
|
||||
except Exception as e:
|
||||
return json.dumps({"success": False, "error": str(e)}, indent=2)
|
||||
|
||||
async def update_permission(client: DirectusClient, id: str, data: dict[str, Any]) -> str:
|
||||
"""Update permission."""
|
||||
try:
|
||||
# Parse JSON string parameter
|
||||
parsed_data = _parse_json_param(data, "data")
|
||||
result = await client.update_permission(id, parsed_data)
|
||||
return json.dumps(
|
||||
{"success": True, "message": "Permission updated", "permission": result.get("data")},
|
||||
indent=2,
|
||||
ensure_ascii=False,
|
||||
)
|
||||
except Exception as e:
|
||||
return json.dumps({"success": False, "error": str(e)}, indent=2)
|
||||
|
||||
async def delete_permission(client: DirectusClient, id: str) -> str:
|
||||
"""Delete a permission."""
|
||||
try:
|
||||
await client.delete_permission(id)
|
||||
return json.dumps({"success": True, "message": f"Permission {id} deleted"}, indent=2)
|
||||
except Exception as e:
|
||||
return json.dumps({"success": False, "error": str(e)}, indent=2)
|
||||
|
||||
async def get_my_permissions(client: DirectusClient) -> str:
|
||||
"""Get current user's permissions."""
|
||||
try:
|
||||
result = await client.get_my_permissions()
|
||||
return json.dumps(
|
||||
{"success": True, "permissions": result.get("data")}, indent=2, ensure_ascii=False
|
||||
)
|
||||
except Exception as e:
|
||||
return json.dumps({"success": False, "error": str(e)}, indent=2)
|
||||
|
||||
async def list_policies(
|
||||
client: DirectusClient, filter: dict | None = None, limit: int = 100
|
||||
) -> str:
|
||||
"""List policies."""
|
||||
try:
|
||||
# Parse JSON string parameter
|
||||
parsed_filter = _parse_json_param(filter, "filter")
|
||||
result = await client.list_policies(filter=parsed_filter, limit=limit)
|
||||
policies = result.get("data", [])
|
||||
return json.dumps(
|
||||
{"success": True, "total": len(policies), "policies": policies},
|
||||
indent=2,
|
||||
ensure_ascii=False,
|
||||
)
|
||||
except Exception as e:
|
||||
return json.dumps({"success": False, "error": str(e)}, indent=2)
|
||||
573
plugins/directus/handlers/automation.py
Normal file
573
plugins/directus/handlers/automation.py
Normal file
@@ -0,0 +1,573 @@
|
||||
"""
|
||||
Automation Handler - Flows, Operations, Webhooks
|
||||
|
||||
Phase J.3: 12 tools
|
||||
- Flows: list, get, create, update, delete, trigger (6)
|
||||
- Operations: list, create (2)
|
||||
- Webhooks: list, create, update, delete (4)
|
||||
"""
|
||||
|
||||
import json
|
||||
from typing import Any
|
||||
|
||||
from plugins.directus.client import DirectusClient
|
||||
|
||||
def _parse_json_param(value: Any, param_name: str = "parameter") -> Any:
|
||||
"""Parse a parameter that may be a JSON string or already a native type."""
|
||||
if value is None:
|
||||
return None
|
||||
if isinstance(value, (dict, list)):
|
||||
return value
|
||||
if isinstance(value, str):
|
||||
stripped = value.strip()
|
||||
if stripped.startswith("{") or stripped.startswith("["):
|
||||
try:
|
||||
return json.loads(stripped)
|
||||
except json.JSONDecodeError as e:
|
||||
raise ValueError(f"Invalid JSON in '{param_name}': {e}")
|
||||
return value
|
||||
|
||||
def get_tool_specifications() -> list[dict[str, Any]]:
|
||||
"""Return tool specifications for ToolGenerator (12 tools)"""
|
||||
return [
|
||||
# =====================
|
||||
# FLOWS (6)
|
||||
# =====================
|
||||
{
|
||||
"name": "list_flows",
|
||||
"method_name": "list_flows",
|
||||
"description": "List all automation flows.",
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"filter": {
|
||||
"anyOf": [{"type": "object"}, {"type": "null"}],
|
||||
"description": "Filter object",
|
||||
},
|
||||
"sort": {
|
||||
"anyOf": [{"type": "array", "items": {"type": "string"}}, {"type": "null"}],
|
||||
"description": "Sort fields",
|
||||
},
|
||||
"limit": {
|
||||
"type": "integer",
|
||||
"description": "Maximum flows to return",
|
||||
"default": 100,
|
||||
},
|
||||
},
|
||||
"required": [],
|
||||
},
|
||||
"scope": "read",
|
||||
},
|
||||
{
|
||||
"name": "get_flow",
|
||||
"method_name": "get_flow",
|
||||
"description": "Get flow details by ID.",
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {"id": {"type": "string", "description": "Flow UUID"}},
|
||||
"required": ["id"],
|
||||
},
|
||||
"scope": "read",
|
||||
},
|
||||
{
|
||||
"name": "create_flow",
|
||||
"method_name": "create_flow",
|
||||
"description": "Create a new automation flow.",
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"name": {"type": "string", "description": "Flow name"},
|
||||
"trigger": {
|
||||
"type": "string",
|
||||
"enum": ["event", "schedule", "operation", "webhook", "manual"],
|
||||
"description": "Trigger type",
|
||||
},
|
||||
"status": {
|
||||
"type": "string",
|
||||
"enum": ["active", "inactive"],
|
||||
"default": "active",
|
||||
"description": "Flow status",
|
||||
},
|
||||
"icon": {"type": "string", "description": "Material icon", "default": "bolt"},
|
||||
"options": {
|
||||
"anyOf": [{"type": "object"}, {"type": "null"}],
|
||||
"description": "Trigger-specific options",
|
||||
},
|
||||
"accountability": {
|
||||
"anyOf": [{"type": "string"}, {"type": "null"}],
|
||||
"enum": ["all", "activity", None],
|
||||
"description": "Accountability tracking",
|
||||
},
|
||||
"description": {
|
||||
"anyOf": [{"type": "string"}, {"type": "null"}],
|
||||
"description": "Flow description",
|
||||
},
|
||||
},
|
||||
"required": ["name", "trigger"],
|
||||
},
|
||||
"scope": "admin",
|
||||
},
|
||||
{
|
||||
"name": "update_flow",
|
||||
"method_name": "update_flow",
|
||||
"description": "Update flow settings.",
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"id": {"type": "string", "description": "Flow UUID"},
|
||||
"data": {"type": "object", "description": "Fields to update"},
|
||||
},
|
||||
"required": ["id", "data"],
|
||||
},
|
||||
"scope": "admin",
|
||||
},
|
||||
{
|
||||
"name": "delete_flow",
|
||||
"method_name": "delete_flow",
|
||||
"description": "Delete a flow and its operations.",
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {"id": {"type": "string", "description": "Flow UUID to delete"}},
|
||||
"required": ["id"],
|
||||
},
|
||||
"scope": "admin",
|
||||
},
|
||||
{
|
||||
"name": "trigger_flow",
|
||||
"method_name": "trigger_flow",
|
||||
"description": "Manually trigger a flow.",
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"id": {"type": "string", "description": "Flow UUID"},
|
||||
"data": {
|
||||
"anyOf": [{"type": "object"}, {"type": "null"}],
|
||||
"description": "Data to pass to the flow",
|
||||
},
|
||||
},
|
||||
"required": ["id"],
|
||||
},
|
||||
"scope": "write",
|
||||
},
|
||||
# =====================
|
||||
# OPERATIONS (2)
|
||||
# =====================
|
||||
{
|
||||
"name": "list_operations",
|
||||
"method_name": "list_operations",
|
||||
"description": "List all operations in flows.",
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"filter": {
|
||||
"anyOf": [{"type": "object"}, {"type": "null"}],
|
||||
"description": 'Filter object (e.g., {"flow": {"_eq": "flow-uuid"}})',
|
||||
},
|
||||
"limit": {
|
||||
"type": "integer",
|
||||
"description": "Maximum operations to return",
|
||||
"default": 100,
|
||||
},
|
||||
},
|
||||
"required": [],
|
||||
},
|
||||
"scope": "read",
|
||||
},
|
||||
{
|
||||
"name": "create_operation",
|
||||
"method_name": "create_operation",
|
||||
"description": "Create a new operation in a flow.",
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"flow": {"type": "string", "description": "Flow UUID"},
|
||||
"type": {
|
||||
"type": "string",
|
||||
"description": "Operation type (e.g., 'log', 'mail', 'request', 'item-create')",
|
||||
},
|
||||
"name": {
|
||||
"anyOf": [{"type": "string"}, {"type": "null"}],
|
||||
"description": "Operation name",
|
||||
},
|
||||
"key": {
|
||||
"anyOf": [{"type": "string"}, {"type": "null"}],
|
||||
"description": "Operation key (for referencing in other operations)",
|
||||
},
|
||||
"options": {
|
||||
"anyOf": [{"type": "object"}, {"type": "null"}],
|
||||
"description": "Operation-specific options",
|
||||
},
|
||||
"position_x": {
|
||||
"type": "integer",
|
||||
"description": "X position in flow editor",
|
||||
"default": 0,
|
||||
},
|
||||
"position_y": {
|
||||
"type": "integer",
|
||||
"description": "Y position in flow editor",
|
||||
"default": 0,
|
||||
},
|
||||
},
|
||||
"required": ["flow", "type"],
|
||||
},
|
||||
"scope": "admin",
|
||||
},
|
||||
# =====================
|
||||
# WEBHOOKS (4)
|
||||
# =====================
|
||||
{
|
||||
"name": "list_webhooks",
|
||||
"method_name": "list_webhooks",
|
||||
"description": "[DEPRECATED] List all webhooks. Webhooks are deprecated in Directus 10+, use Flows instead.",
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"filter": {
|
||||
"anyOf": [{"type": "object"}, {"type": "null"}],
|
||||
"description": "Filter object",
|
||||
},
|
||||
"limit": {
|
||||
"type": "integer",
|
||||
"description": "Maximum webhooks to return",
|
||||
"default": 100,
|
||||
},
|
||||
},
|
||||
"required": [],
|
||||
},
|
||||
"scope": "read",
|
||||
},
|
||||
{
|
||||
"name": "create_webhook",
|
||||
"method_name": "create_webhook",
|
||||
"description": "[DEPRECATED] Create a new webhook. Use Flows instead in Directus 10+. See create_flow and create_operation for the recommended alternative.",
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"name": {"type": "string", "description": "Webhook name"},
|
||||
"url": {"type": "string", "description": "Target URL"},
|
||||
"actions": {
|
||||
"type": "array",
|
||||
"items": {"type": "string"},
|
||||
"description": "Actions to trigger (create, update, delete)",
|
||||
},
|
||||
"collections": {
|
||||
"type": "array",
|
||||
"items": {"type": "string"},
|
||||
"description": "Collections to watch",
|
||||
},
|
||||
"method": {
|
||||
"type": "string",
|
||||
"enum": ["GET", "POST"],
|
||||
"default": "POST",
|
||||
"description": "HTTP method",
|
||||
},
|
||||
"status": {
|
||||
"type": "string",
|
||||
"enum": ["active", "inactive"],
|
||||
"default": "active",
|
||||
"description": "Webhook status",
|
||||
},
|
||||
"headers": {
|
||||
"anyOf": [{"type": "object"}, {"type": "null"}],
|
||||
"description": "Custom headers",
|
||||
},
|
||||
},
|
||||
"required": ["name", "url", "actions", "collections"],
|
||||
},
|
||||
"scope": "admin",
|
||||
},
|
||||
{
|
||||
"name": "update_webhook",
|
||||
"method_name": "update_webhook",
|
||||
"description": "[DEPRECATED] Update webhook settings. Use Flows instead in Directus 10+.",
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"id": {"type": "string", "description": "Webhook ID"},
|
||||
"data": {"type": "object", "description": "Fields to update"},
|
||||
},
|
||||
"required": ["id", "data"],
|
||||
},
|
||||
"scope": "admin",
|
||||
},
|
||||
{
|
||||
"name": "delete_webhook",
|
||||
"method_name": "delete_webhook",
|
||||
"description": "[DEPRECATED] Delete a webhook. Use Flows instead in Directus 10+.",
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {"id": {"type": "string", "description": "Webhook ID to delete"}},
|
||||
"required": ["id"],
|
||||
},
|
||||
"scope": "admin",
|
||||
},
|
||||
]
|
||||
|
||||
# =====================
|
||||
# HANDLER FUNCTIONS
|
||||
# =====================
|
||||
|
||||
async def list_flows(
|
||||
client: DirectusClient,
|
||||
filter: dict | None = None,
|
||||
sort: list[str] | None = None,
|
||||
limit: int = 100,
|
||||
) -> str:
|
||||
"""List flows."""
|
||||
try:
|
||||
# Parse JSON string parameters
|
||||
parsed_filter = _parse_json_param(filter, "filter")
|
||||
parsed_sort = _parse_json_param(sort, "sort")
|
||||
|
||||
result = await client.list_flows(filter=parsed_filter, sort=parsed_sort, limit=limit)
|
||||
flows = result.get("data", [])
|
||||
return json.dumps(
|
||||
{"success": True, "total": len(flows), "flows": flows}, indent=2, ensure_ascii=False
|
||||
)
|
||||
except Exception as e:
|
||||
return json.dumps({"success": False, "error": str(e)}, indent=2)
|
||||
|
||||
async def get_flow(client: DirectusClient, id: str) -> str:
|
||||
"""Get flow by ID."""
|
||||
try:
|
||||
result = await client.get_flow(id)
|
||||
return json.dumps(
|
||||
{"success": True, "flow": result.get("data")}, indent=2, ensure_ascii=False
|
||||
)
|
||||
except Exception as e:
|
||||
return json.dumps({"success": False, "error": str(e)}, indent=2)
|
||||
|
||||
async def create_flow(
|
||||
client: DirectusClient,
|
||||
name: str,
|
||||
trigger: str,
|
||||
status: str = "active",
|
||||
icon: str = "bolt",
|
||||
options: dict | None = None,
|
||||
accountability: str | None = None,
|
||||
description: str | None = None,
|
||||
) -> str:
|
||||
"""Create a new flow."""
|
||||
try:
|
||||
# Parse JSON string parameter
|
||||
parsed_options = _parse_json_param(options, "options")
|
||||
|
||||
result = await client.create_flow(
|
||||
name=name,
|
||||
trigger=trigger,
|
||||
status=status,
|
||||
icon=icon,
|
||||
options=parsed_options,
|
||||
accountability=accountability,
|
||||
description=description,
|
||||
)
|
||||
return json.dumps(
|
||||
{"success": True, "message": f"Flow '{name}' created", "flow": result.get("data")},
|
||||
indent=2,
|
||||
ensure_ascii=False,
|
||||
)
|
||||
except Exception as e:
|
||||
return json.dumps({"success": False, "error": str(e)}, indent=2)
|
||||
|
||||
async def update_flow(client: DirectusClient, id: str, data: dict[str, Any]) -> str:
|
||||
"""Update flow."""
|
||||
try:
|
||||
# Parse JSON string parameter
|
||||
parsed_data = _parse_json_param(data, "data")
|
||||
result = await client.update_flow(id, parsed_data)
|
||||
return json.dumps(
|
||||
{"success": True, "message": "Flow updated", "flow": result.get("data")},
|
||||
indent=2,
|
||||
ensure_ascii=False,
|
||||
)
|
||||
except Exception as e:
|
||||
return json.dumps({"success": False, "error": str(e)}, indent=2)
|
||||
|
||||
async def delete_flow(client: DirectusClient, id: str) -> str:
|
||||
"""Delete a flow."""
|
||||
try:
|
||||
await client.delete_flow(id)
|
||||
return json.dumps({"success": True, "message": f"Flow {id} deleted"}, indent=2)
|
||||
except Exception as e:
|
||||
return json.dumps({"success": False, "error": str(e)}, indent=2)
|
||||
|
||||
async def trigger_flow(client: DirectusClient, id: str, data: dict | None = None) -> str:
|
||||
"""Trigger a flow manually."""
|
||||
try:
|
||||
# Parse JSON string parameter
|
||||
parsed_data = _parse_json_param(data, "data")
|
||||
result = await client.trigger_flow(id, parsed_data)
|
||||
return json.dumps(
|
||||
{
|
||||
"success": True,
|
||||
"message": f"Flow {id} triggered",
|
||||
"result": result.get("data") if result else None,
|
||||
},
|
||||
indent=2,
|
||||
ensure_ascii=False,
|
||||
)
|
||||
except Exception as e:
|
||||
return json.dumps({"success": False, "error": str(e)}, indent=2)
|
||||
|
||||
async def list_operations(
|
||||
client: DirectusClient, filter: dict | None = None, limit: int = 100
|
||||
) -> str:
|
||||
"""List operations."""
|
||||
try:
|
||||
# Parse JSON string parameter
|
||||
parsed_filter = _parse_json_param(filter, "filter")
|
||||
result = await client.list_operations(filter=parsed_filter, limit=limit)
|
||||
operations = result.get("data", [])
|
||||
return json.dumps(
|
||||
{"success": True, "total": len(operations), "operations": operations},
|
||||
indent=2,
|
||||
ensure_ascii=False,
|
||||
)
|
||||
except Exception as e:
|
||||
return json.dumps({"success": False, "error": str(e)}, indent=2)
|
||||
|
||||
async def create_operation(
|
||||
client: DirectusClient,
|
||||
flow: str,
|
||||
type: str,
|
||||
name: str | None = None,
|
||||
key: str | None = None,
|
||||
options: dict | None = None,
|
||||
position_x: int = 0,
|
||||
position_y: int = 0,
|
||||
) -> str:
|
||||
"""Create a new operation."""
|
||||
try:
|
||||
# Parse JSON string parameter
|
||||
parsed_options = _parse_json_param(options, "options")
|
||||
|
||||
result = await client.create_operation(
|
||||
flow=flow,
|
||||
type=type,
|
||||
name=name,
|
||||
key=key,
|
||||
options=parsed_options,
|
||||
position_x=position_x,
|
||||
position_y=position_y,
|
||||
)
|
||||
return json.dumps(
|
||||
{
|
||||
"success": True,
|
||||
"message": f"Operation '{type}' created in flow",
|
||||
"operation": result.get("data"),
|
||||
},
|
||||
indent=2,
|
||||
ensure_ascii=False,
|
||||
)
|
||||
except Exception as e:
|
||||
return json.dumps({"success": False, "error": str(e)}, indent=2)
|
||||
|
||||
async def list_webhooks(
|
||||
client: DirectusClient, filter: dict | None = None, limit: int = 100
|
||||
) -> str:
|
||||
"""List webhooks. DEPRECATED: Use Flows instead in Directus 10+."""
|
||||
try:
|
||||
# Parse JSON string parameter
|
||||
parsed_filter = _parse_json_param(filter, "filter")
|
||||
result = await client.list_webhooks(filter=parsed_filter, limit=limit)
|
||||
webhooks = result.get("data", [])
|
||||
return json.dumps(
|
||||
{
|
||||
"success": True,
|
||||
"total": len(webhooks),
|
||||
"webhooks": webhooks,
|
||||
"warning": "DEPRECATED: Webhooks are deprecated in Directus 10+. Use Flows instead.",
|
||||
},
|
||||
indent=2,
|
||||
ensure_ascii=False,
|
||||
)
|
||||
except Exception as e:
|
||||
return json.dumps({"success": False, "error": str(e)}, indent=2)
|
||||
|
||||
async def create_webhook(
|
||||
client: DirectusClient,
|
||||
name: str,
|
||||
url: str,
|
||||
actions: list[str],
|
||||
collections: list[str],
|
||||
method: str = "POST",
|
||||
status: str = "active",
|
||||
headers: dict | None = None,
|
||||
) -> str:
|
||||
"""Create a new webhook. DEPRECATED: Use Flows instead in Directus 10+."""
|
||||
try:
|
||||
# Parse JSON string parameters
|
||||
parsed_actions = _parse_json_param(actions, "actions")
|
||||
parsed_collections = _parse_json_param(collections, "collections")
|
||||
parsed_headers = _parse_json_param(headers, "headers")
|
||||
|
||||
result = await client.create_webhook(
|
||||
name=name,
|
||||
url=url,
|
||||
actions=parsed_actions,
|
||||
collections=parsed_collections,
|
||||
method=method,
|
||||
status=status,
|
||||
headers=parsed_headers,
|
||||
)
|
||||
return json.dumps(
|
||||
{
|
||||
"success": True,
|
||||
"message": f"Webhook '{name}' created",
|
||||
"warning": "DEPRECATED: Webhooks are deprecated in Directus 10+. Use Flows instead for better reliability. See create_flow and create_operation tools.",
|
||||
"webhook": result.get("data"),
|
||||
},
|
||||
indent=2,
|
||||
ensure_ascii=False,
|
||||
)
|
||||
except Exception as e:
|
||||
error_msg = str(e)
|
||||
return json.dumps(
|
||||
{
|
||||
"success": False,
|
||||
"error": error_msg,
|
||||
"hint": "Webhooks are deprecated in Directus 10+. Consider using Flows instead: 1) create_flow with trigger='event', 2) create_operation with type='request' for HTTP calls.",
|
||||
},
|
||||
indent=2,
|
||||
)
|
||||
|
||||
async def update_webhook(client: DirectusClient, id: str, data: dict[str, Any]) -> str:
|
||||
"""Update webhook. DEPRECATED: Use Flows instead in Directus 10+."""
|
||||
try:
|
||||
# Parse JSON string parameter
|
||||
parsed_data = _parse_json_param(data, "data")
|
||||
result = await client.update_webhook(id, parsed_data)
|
||||
return json.dumps(
|
||||
{
|
||||
"success": True,
|
||||
"message": "Webhook updated",
|
||||
"warning": "DEPRECATED: Webhooks are deprecated in Directus 10+. Use Flows instead.",
|
||||
"webhook": result.get("data"),
|
||||
},
|
||||
indent=2,
|
||||
ensure_ascii=False,
|
||||
)
|
||||
except Exception as e:
|
||||
return json.dumps(
|
||||
{
|
||||
"success": False,
|
||||
"error": str(e),
|
||||
"hint": "Webhooks are deprecated in Directus 10+. Consider migrating to Flows.",
|
||||
},
|
||||
indent=2,
|
||||
)
|
||||
|
||||
async def delete_webhook(client: DirectusClient, id: str) -> str:
|
||||
"""Delete a webhook. DEPRECATED: Use Flows instead in Directus 10+."""
|
||||
try:
|
||||
await client.delete_webhook(id)
|
||||
return json.dumps(
|
||||
{
|
||||
"success": True,
|
||||
"message": f"Webhook {id} deleted",
|
||||
"note": "Consider using Flows instead of Webhooks in Directus 10+.",
|
||||
},
|
||||
indent=2,
|
||||
)
|
||||
except Exception as e:
|
||||
return json.dumps({"success": False, "error": str(e)}, indent=2)
|
||||
544
plugins/directus/handlers/collections.py
Normal file
544
plugins/directus/handlers/collections.py
Normal file
@@ -0,0 +1,544 @@
|
||||
"""
|
||||
Collections & Fields Handler - Schema management
|
||||
|
||||
Phase J.1: 14 tools
|
||||
- Collections: list, get, create, update, delete (5)
|
||||
- Fields: list, get, create, update, delete (5)
|
||||
- Relations: list, get, create, delete (4)
|
||||
"""
|
||||
|
||||
import json
|
||||
from typing import Any
|
||||
|
||||
from plugins.directus.client import DirectusClient
|
||||
|
||||
def _parse_json_param(value: Any, param_name: str = "parameter") -> Any:
|
||||
"""
|
||||
Parse a parameter that may be a JSON string or already a native type.
|
||||
|
||||
MCP tools may receive object/array parameters as JSON strings.
|
||||
This function safely converts them to proper Python types.
|
||||
|
||||
Args:
|
||||
value: The value to parse (may be string, dict, list, or None)
|
||||
param_name: Name of parameter for error messages
|
||||
|
||||
Returns:
|
||||
Parsed value (dict, list, or original value if not JSON)
|
||||
"""
|
||||
if value is None:
|
||||
return None
|
||||
|
||||
# Already the correct type
|
||||
if isinstance(value, (dict, list)):
|
||||
return value
|
||||
|
||||
# Try to parse JSON string
|
||||
if isinstance(value, str):
|
||||
stripped = value.strip()
|
||||
# Check if it looks like JSON (starts with { or [)
|
||||
if stripped.startswith("{") or stripped.startswith("["):
|
||||
try:
|
||||
return json.loads(stripped)
|
||||
except json.JSONDecodeError as e:
|
||||
raise ValueError(f"Invalid JSON in '{param_name}': {e}")
|
||||
|
||||
return value
|
||||
|
||||
def get_tool_specifications() -> list[dict[str, Any]]:
|
||||
"""Return tool specifications for ToolGenerator (14 tools)"""
|
||||
return [
|
||||
# =====================
|
||||
# COLLECTIONS (5)
|
||||
# =====================
|
||||
{
|
||||
"name": "list_collections",
|
||||
"method_name": "list_collections",
|
||||
"description": "List all collections (tables) in Directus.",
|
||||
"schema": {"type": "object", "properties": {}, "required": []},
|
||||
"scope": "read",
|
||||
},
|
||||
{
|
||||
"name": "get_collection",
|
||||
"method_name": "get_collection",
|
||||
"description": "Get collection details including schema and meta information.",
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {"collection": {"type": "string", "description": "Collection name"}},
|
||||
"required": ["collection"],
|
||||
},
|
||||
"scope": "read",
|
||||
},
|
||||
{
|
||||
"name": "create_collection",
|
||||
"method_name": "create_collection",
|
||||
"description": "Create a new collection (table) in Directus.",
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"collection": {"type": "string", "description": "Collection name (table name)"},
|
||||
"meta": {
|
||||
"anyOf": [{"type": "object"}, {"type": "null"}],
|
||||
"description": "Collection meta (icon, note, hidden, singleton, etc.)",
|
||||
},
|
||||
"schema": {
|
||||
"anyOf": [{"type": "object"}, {"type": "null"}],
|
||||
"description": "Schema options (name, comment)",
|
||||
},
|
||||
"fields": {
|
||||
"anyOf": [{"type": "array", "items": {"type": "object"}}, {"type": "null"}],
|
||||
"description": "Initial fields to create with collection",
|
||||
},
|
||||
},
|
||||
"required": ["collection"],
|
||||
},
|
||||
"scope": "admin",
|
||||
},
|
||||
{
|
||||
"name": "update_collection",
|
||||
"method_name": "update_collection",
|
||||
"description": "Update collection meta information.",
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"collection": {"type": "string", "description": "Collection name"},
|
||||
"meta": {
|
||||
"type": "object",
|
||||
"description": "Meta fields to update (icon, note, hidden, etc.)",
|
||||
},
|
||||
},
|
||||
"required": ["collection", "meta"],
|
||||
},
|
||||
"scope": "admin",
|
||||
},
|
||||
{
|
||||
"name": "delete_collection",
|
||||
"method_name": "delete_collection",
|
||||
"description": "Delete a collection and all its data. This action is irreversible.",
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"collection": {"type": "string", "description": "Collection name to delete"}
|
||||
},
|
||||
"required": ["collection"],
|
||||
},
|
||||
"scope": "admin",
|
||||
},
|
||||
# =====================
|
||||
# FIELDS (5)
|
||||
# =====================
|
||||
{
|
||||
"name": "list_fields",
|
||||
"method_name": "list_fields",
|
||||
"description": "List all fields, optionally filtered by collection.",
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"collection": {
|
||||
"anyOf": [{"type": "string"}, {"type": "null"}],
|
||||
"description": "Collection name (optional, lists all fields if not provided)",
|
||||
}
|
||||
},
|
||||
"required": [],
|
||||
},
|
||||
"scope": "read",
|
||||
},
|
||||
{
|
||||
"name": "get_field",
|
||||
"method_name": "get_field",
|
||||
"description": "Get field details including schema, meta, and type information.",
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"collection": {"type": "string", "description": "Collection name"},
|
||||
"field": {"type": "string", "description": "Field name"},
|
||||
},
|
||||
"required": ["collection", "field"],
|
||||
},
|
||||
"scope": "read",
|
||||
},
|
||||
{
|
||||
"name": "create_field",
|
||||
"method_name": "create_field",
|
||||
"description": "Create a new field in a collection.",
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"collection": {"type": "string", "description": "Collection name"},
|
||||
"field": {"type": "string", "description": "Field name"},
|
||||
"type": {
|
||||
"type": "string",
|
||||
"description": "Field type (string, integer, boolean, uuid, datetime, json, etc.)",
|
||||
},
|
||||
"meta": {
|
||||
"anyOf": [{"type": "object"}, {"type": "null"}],
|
||||
"description": "Field meta (interface, display, options, etc.)",
|
||||
},
|
||||
"schema": {
|
||||
"anyOf": [{"type": "object"}, {"type": "null"}],
|
||||
"description": "Schema options (is_nullable, default_value, etc.)",
|
||||
},
|
||||
},
|
||||
"required": ["collection", "field", "type"],
|
||||
},
|
||||
"scope": "admin",
|
||||
},
|
||||
{
|
||||
"name": "update_field",
|
||||
"method_name": "update_field",
|
||||
"description": "Update field configuration.",
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"collection": {"type": "string", "description": "Collection name"},
|
||||
"field": {"type": "string", "description": "Field name"},
|
||||
"meta": {
|
||||
"anyOf": [{"type": "object"}, {"type": "null"}],
|
||||
"description": "Meta fields to update",
|
||||
},
|
||||
"schema": {
|
||||
"anyOf": [{"type": "object"}, {"type": "null"}],
|
||||
"description": "Schema fields to update",
|
||||
},
|
||||
},
|
||||
"required": ["collection", "field"],
|
||||
},
|
||||
"scope": "admin",
|
||||
},
|
||||
{
|
||||
"name": "delete_field",
|
||||
"method_name": "delete_field",
|
||||
"description": "Delete a field from a collection. This removes the column and all data.",
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"collection": {"type": "string", "description": "Collection name"},
|
||||
"field": {"type": "string", "description": "Field name to delete"},
|
||||
},
|
||||
"required": ["collection", "field"],
|
||||
},
|
||||
"scope": "admin",
|
||||
},
|
||||
# =====================
|
||||
# RELATIONS (4)
|
||||
# =====================
|
||||
{
|
||||
"name": "list_relations",
|
||||
"method_name": "list_relations",
|
||||
"description": "List all relations (foreign keys), optionally filtered by collection.",
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"collection": {
|
||||
"anyOf": [{"type": "string"}, {"type": "null"}],
|
||||
"description": "Collection name (optional)",
|
||||
}
|
||||
},
|
||||
"required": [],
|
||||
},
|
||||
"scope": "read",
|
||||
},
|
||||
{
|
||||
"name": "get_relation",
|
||||
"method_name": "get_relation",
|
||||
"description": "Get relation details.",
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"collection": {"type": "string", "description": "Collection name"},
|
||||
"field": {"type": "string", "description": "Field name"},
|
||||
},
|
||||
"required": ["collection", "field"],
|
||||
},
|
||||
"scope": "read",
|
||||
},
|
||||
{
|
||||
"name": "create_relation",
|
||||
"method_name": "create_relation",
|
||||
"description": "Create a new relation (foreign key) between collections.",
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"collection": {"type": "string", "description": "Collection name (many side)"},
|
||||
"field": {"type": "string", "description": "Field name for the relation"},
|
||||
"related_collection": {
|
||||
"type": "string",
|
||||
"description": "Related collection name (one side)",
|
||||
},
|
||||
"meta": {
|
||||
"anyOf": [{"type": "object"}, {"type": "null"}],
|
||||
"description": "Relation meta (one_field, junction_field, etc.)",
|
||||
},
|
||||
"schema": {
|
||||
"anyOf": [{"type": "object"}, {"type": "null"}],
|
||||
"description": "Schema options (on_delete, etc.)",
|
||||
},
|
||||
},
|
||||
"required": ["collection", "field", "related_collection"],
|
||||
},
|
||||
"scope": "admin",
|
||||
},
|
||||
{
|
||||
"name": "delete_relation",
|
||||
"method_name": "delete_relation",
|
||||
"description": "Delete a relation.",
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"collection": {"type": "string", "description": "Collection name"},
|
||||
"field": {"type": "string", "description": "Field name"},
|
||||
},
|
||||
"required": ["collection", "field"],
|
||||
},
|
||||
"scope": "admin",
|
||||
},
|
||||
]
|
||||
|
||||
# =====================
|
||||
# HANDLER FUNCTIONS
|
||||
# =====================
|
||||
|
||||
async def list_collections(client: DirectusClient) -> str:
|
||||
"""List all collections."""
|
||||
try:
|
||||
result = await client.list_collections()
|
||||
collections = result.get("data", [])
|
||||
return json.dumps(
|
||||
{"success": True, "total": len(collections), "collections": collections},
|
||||
indent=2,
|
||||
ensure_ascii=False,
|
||||
)
|
||||
except Exception as e:
|
||||
return json.dumps({"success": False, "error": str(e)}, indent=2)
|
||||
|
||||
async def get_collection(client: DirectusClient, collection: str) -> str:
|
||||
"""Get collection details."""
|
||||
try:
|
||||
result = await client.get_collection(collection)
|
||||
return json.dumps(
|
||||
{"success": True, "collection": result.get("data")}, indent=2, ensure_ascii=False
|
||||
)
|
||||
except Exception as e:
|
||||
return json.dumps({"success": False, "error": str(e)}, indent=2)
|
||||
|
||||
async def create_collection(
|
||||
client: DirectusClient,
|
||||
collection: str,
|
||||
meta: dict | None = None,
|
||||
schema: dict | None = None,
|
||||
fields: list[dict] | None = None,
|
||||
) -> str:
|
||||
"""Create a new collection."""
|
||||
try:
|
||||
# Parse JSON string parameters
|
||||
parsed_meta = _parse_json_param(meta, "meta")
|
||||
parsed_schema = _parse_json_param(schema, "schema")
|
||||
parsed_fields = _parse_json_param(fields, "fields")
|
||||
|
||||
# Auto-fill schema.name if schema is provided but missing name
|
||||
# This ensures a real database table is created, not just a folder collection
|
||||
if parsed_schema is not None:
|
||||
if not isinstance(parsed_schema, dict):
|
||||
parsed_schema = {}
|
||||
if "name" not in parsed_schema:
|
||||
parsed_schema["name"] = collection
|
||||
|
||||
result = await client.create_collection(
|
||||
collection=collection, meta=parsed_meta, schema=parsed_schema, fields=parsed_fields
|
||||
)
|
||||
return json.dumps(
|
||||
{
|
||||
"success": True,
|
||||
"message": f"Collection '{collection}' created",
|
||||
"collection": result.get("data"),
|
||||
},
|
||||
indent=2,
|
||||
ensure_ascii=False,
|
||||
)
|
||||
except Exception as e:
|
||||
return json.dumps({"success": False, "error": str(e)}, indent=2)
|
||||
|
||||
async def update_collection(client: DirectusClient, collection: str, meta: dict) -> str:
|
||||
"""Update collection meta."""
|
||||
try:
|
||||
# Parse JSON string parameter
|
||||
parsed_meta = _parse_json_param(meta, "meta")
|
||||
result = await client.update_collection(collection, parsed_meta)
|
||||
return json.dumps(
|
||||
{
|
||||
"success": True,
|
||||
"message": f"Collection '{collection}' updated",
|
||||
"collection": result.get("data"),
|
||||
},
|
||||
indent=2,
|
||||
ensure_ascii=False,
|
||||
)
|
||||
except Exception as e:
|
||||
return json.dumps({"success": False, "error": str(e)}, indent=2)
|
||||
|
||||
async def delete_collection(client: DirectusClient, collection: str) -> str:
|
||||
"""Delete a collection."""
|
||||
try:
|
||||
await client.delete_collection(collection)
|
||||
return json.dumps(
|
||||
{"success": True, "message": f"Collection '{collection}' deleted"}, indent=2
|
||||
)
|
||||
except Exception as e:
|
||||
return json.dumps({"success": False, "error": str(e)}, indent=2)
|
||||
|
||||
async def list_fields(client: DirectusClient, collection: str | None = None) -> str:
|
||||
"""List fields."""
|
||||
try:
|
||||
result = await client.list_fields(collection)
|
||||
fields = result.get("data", [])
|
||||
return json.dumps(
|
||||
{"success": True, "collection": collection, "total": len(fields), "fields": fields},
|
||||
indent=2,
|
||||
ensure_ascii=False,
|
||||
)
|
||||
except Exception as e:
|
||||
return json.dumps({"success": False, "error": str(e)}, indent=2)
|
||||
|
||||
async def get_field(client: DirectusClient, collection: str, field: str) -> str:
|
||||
"""Get field details."""
|
||||
try:
|
||||
result = await client.get_field(collection, field)
|
||||
return json.dumps(
|
||||
{"success": True, "field": result.get("data")}, indent=2, ensure_ascii=False
|
||||
)
|
||||
except Exception as e:
|
||||
return json.dumps({"success": False, "error": str(e)}, indent=2)
|
||||
|
||||
async def create_field(
|
||||
client: DirectusClient,
|
||||
collection: str,
|
||||
field: str,
|
||||
type: str,
|
||||
meta: dict | None = None,
|
||||
schema: dict | None = None,
|
||||
) -> str:
|
||||
"""Create a new field."""
|
||||
try:
|
||||
# Parse JSON string parameters
|
||||
parsed_meta = _parse_json_param(meta, "meta")
|
||||
parsed_schema = _parse_json_param(schema, "schema")
|
||||
|
||||
result = await client.create_field(
|
||||
collection=collection, field=field, type=type, meta=parsed_meta, schema=parsed_schema
|
||||
)
|
||||
return json.dumps(
|
||||
{
|
||||
"success": True,
|
||||
"message": f"Field '{field}' created in {collection}",
|
||||
"field": result.get("data"),
|
||||
},
|
||||
indent=2,
|
||||
ensure_ascii=False,
|
||||
)
|
||||
except Exception as e:
|
||||
return json.dumps({"success": False, "error": str(e)}, indent=2)
|
||||
|
||||
async def update_field(
|
||||
client: DirectusClient,
|
||||
collection: str,
|
||||
field: str,
|
||||
meta: dict | None = None,
|
||||
schema: dict | None = None,
|
||||
) -> str:
|
||||
"""Update field configuration."""
|
||||
try:
|
||||
# Parse JSON string parameters
|
||||
parsed_meta = _parse_json_param(meta, "meta")
|
||||
parsed_schema = _parse_json_param(schema, "schema")
|
||||
|
||||
result = await client.update_field(
|
||||
collection=collection, field=field, meta=parsed_meta, schema=parsed_schema
|
||||
)
|
||||
return json.dumps(
|
||||
{"success": True, "message": f"Field '{field}' updated", "field": result.get("data")},
|
||||
indent=2,
|
||||
ensure_ascii=False,
|
||||
)
|
||||
except Exception as e:
|
||||
return json.dumps({"success": False, "error": str(e)}, indent=2)
|
||||
|
||||
async def delete_field(client: DirectusClient, collection: str, field: str) -> str:
|
||||
"""Delete a field."""
|
||||
try:
|
||||
await client.delete_field(collection, field)
|
||||
return json.dumps(
|
||||
{"success": True, "message": f"Field '{field}' deleted from {collection}"}, indent=2
|
||||
)
|
||||
except Exception as e:
|
||||
return json.dumps({"success": False, "error": str(e)}, indent=2)
|
||||
|
||||
async def list_relations(client: DirectusClient, collection: str | None = None) -> str:
|
||||
"""List relations."""
|
||||
try:
|
||||
result = await client.list_relations(collection)
|
||||
relations = result.get("data", [])
|
||||
return json.dumps(
|
||||
{
|
||||
"success": True,
|
||||
"collection": collection,
|
||||
"total": len(relations),
|
||||
"relations": relations,
|
||||
},
|
||||
indent=2,
|
||||
ensure_ascii=False,
|
||||
)
|
||||
except Exception as e:
|
||||
return json.dumps({"success": False, "error": str(e)}, indent=2)
|
||||
|
||||
async def get_relation(client: DirectusClient, collection: str, field: str) -> str:
|
||||
"""Get relation details."""
|
||||
try:
|
||||
result = await client.get_relation(collection, field)
|
||||
return json.dumps(
|
||||
{"success": True, "relation": result.get("data")}, indent=2, ensure_ascii=False
|
||||
)
|
||||
except Exception as e:
|
||||
return json.dumps({"success": False, "error": str(e)}, indent=2)
|
||||
|
||||
async def create_relation(
|
||||
client: DirectusClient,
|
||||
collection: str,
|
||||
field: str,
|
||||
related_collection: str,
|
||||
meta: dict | None = None,
|
||||
schema: dict | None = None,
|
||||
) -> str:
|
||||
"""Create a new relation."""
|
||||
try:
|
||||
# Parse JSON string parameters
|
||||
parsed_meta = _parse_json_param(meta, "meta")
|
||||
parsed_schema = _parse_json_param(schema, "schema")
|
||||
|
||||
result = await client.create_relation(
|
||||
collection=collection,
|
||||
field=field,
|
||||
related_collection=related_collection,
|
||||
meta=parsed_meta,
|
||||
schema=parsed_schema,
|
||||
)
|
||||
return json.dumps(
|
||||
{
|
||||
"success": True,
|
||||
"message": f"Relation created: {collection}.{field} -> {related_collection}",
|
||||
"relation": result.get("data"),
|
||||
},
|
||||
indent=2,
|
||||
ensure_ascii=False,
|
||||
)
|
||||
except Exception as e:
|
||||
return json.dumps({"success": False, "error": str(e)}, indent=2)
|
||||
|
||||
async def delete_relation(client: DirectusClient, collection: str, field: str) -> str:
|
||||
"""Delete a relation."""
|
||||
try:
|
||||
await client.delete_relation(collection, field)
|
||||
return json.dumps(
|
||||
{"success": True, "message": f"Relation {collection}.{field} deleted"}, indent=2
|
||||
)
|
||||
except Exception as e:
|
||||
return json.dumps({"success": False, "error": str(e)}, indent=2)
|
||||
370
plugins/directus/handlers/content.py
Normal file
370
plugins/directus/handlers/content.py
Normal file
@@ -0,0 +1,370 @@
|
||||
"""
|
||||
Content Handler - Revisions, Versions, Comments
|
||||
|
||||
Phase J.4: 10 tools
|
||||
- Revisions: list, get (2)
|
||||
- Versions: list, get, create, update, delete, promote (6)
|
||||
- Comments: list, create (2)
|
||||
"""
|
||||
|
||||
import json
|
||||
from typing import Any
|
||||
|
||||
from plugins.directus.client import DirectusClient
|
||||
|
||||
def _parse_json_param(value: Any, param_name: str = "parameter") -> Any:
|
||||
"""Parse a parameter that may be a JSON string or already a native type."""
|
||||
if value is None:
|
||||
return None
|
||||
if isinstance(value, (dict, list)):
|
||||
return value
|
||||
if isinstance(value, str):
|
||||
stripped = value.strip()
|
||||
if stripped.startswith("{") or stripped.startswith("["):
|
||||
try:
|
||||
return json.loads(stripped)
|
||||
except json.JSONDecodeError as e:
|
||||
raise ValueError(f"Invalid JSON in '{param_name}': {e}")
|
||||
return value
|
||||
|
||||
def get_tool_specifications() -> list[dict[str, Any]]:
|
||||
"""Return tool specifications for ToolGenerator (10 tools)"""
|
||||
return [
|
||||
# =====================
|
||||
# REVISIONS (2)
|
||||
# =====================
|
||||
{
|
||||
"name": "list_revisions",
|
||||
"method_name": "list_revisions",
|
||||
"description": "List revisions (history) of items.",
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"filter": {
|
||||
"anyOf": [{"type": "object"}, {"type": "null"}],
|
||||
"description": 'Filter object (e.g., {"collection": {"_eq": "posts"}})',
|
||||
},
|
||||
"sort": {
|
||||
"anyOf": [{"type": "array", "items": {"type": "string"}}, {"type": "null"}],
|
||||
"description": "Sort fields (default: ['-activity'])",
|
||||
},
|
||||
"limit": {
|
||||
"type": "integer",
|
||||
"description": "Maximum revisions to return",
|
||||
"default": 100,
|
||||
},
|
||||
},
|
||||
"required": [],
|
||||
},
|
||||
"scope": "read",
|
||||
},
|
||||
{
|
||||
"name": "get_revision",
|
||||
"method_name": "get_revision",
|
||||
"description": "Get revision details by ID.",
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {"id": {"type": "string", "description": "Revision ID"}},
|
||||
"required": ["id"],
|
||||
},
|
||||
"scope": "read",
|
||||
},
|
||||
# =====================
|
||||
# VERSIONS (6)
|
||||
# =====================
|
||||
{
|
||||
"name": "list_versions",
|
||||
"method_name": "list_versions",
|
||||
"description": "List content versions (drafts).",
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"filter": {
|
||||
"anyOf": [{"type": "object"}, {"type": "null"}],
|
||||
"description": "Filter object",
|
||||
},
|
||||
"sort": {
|
||||
"anyOf": [{"type": "array", "items": {"type": "string"}}, {"type": "null"}],
|
||||
"description": "Sort fields",
|
||||
},
|
||||
"limit": {
|
||||
"type": "integer",
|
||||
"description": "Maximum versions to return",
|
||||
"default": 100,
|
||||
},
|
||||
},
|
||||
"required": [],
|
||||
},
|
||||
"scope": "read",
|
||||
},
|
||||
{
|
||||
"name": "get_version",
|
||||
"method_name": "get_version",
|
||||
"description": "Get version details by ID.",
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {"id": {"type": "string", "description": "Version UUID"}},
|
||||
"required": ["id"],
|
||||
},
|
||||
"scope": "read",
|
||||
},
|
||||
{
|
||||
"name": "create_version",
|
||||
"method_name": "create_version",
|
||||
"description": "Create a new content version (draft).",
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"name": {"type": "string", "description": "Version name"},
|
||||
"collection": {"type": "string", "description": "Collection name"},
|
||||
"item": {"type": "string", "description": "Item ID"},
|
||||
"key": {"type": "string", "description": "Version key (unique identifier)"},
|
||||
},
|
||||
"required": ["name", "collection", "item", "key"],
|
||||
},
|
||||
"scope": "write",
|
||||
},
|
||||
{
|
||||
"name": "update_version",
|
||||
"method_name": "update_version",
|
||||
"description": "Update a version.",
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"id": {"type": "string", "description": "Version UUID"},
|
||||
"data": {"type": "object", "description": "Fields to update (name, delta)"},
|
||||
},
|
||||
"required": ["id", "data"],
|
||||
},
|
||||
"scope": "write",
|
||||
},
|
||||
{
|
||||
"name": "delete_version",
|
||||
"method_name": "delete_version",
|
||||
"description": "Delete a version.",
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {"id": {"type": "string", "description": "Version UUID to delete"}},
|
||||
"required": ["id"],
|
||||
},
|
||||
"scope": "write",
|
||||
},
|
||||
{
|
||||
"name": "promote_version",
|
||||
"method_name": "promote_version",
|
||||
"description": "Promote a version to main (apply changes to item).",
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"id": {"type": "string", "description": "Version UUID to promote"},
|
||||
"mainHash": {
|
||||
"anyOf": [{"type": "string"}, {"type": "null"}],
|
||||
"description": "Expected main content hash (for conflict detection)",
|
||||
},
|
||||
},
|
||||
"required": ["id"],
|
||||
},
|
||||
"scope": "write",
|
||||
},
|
||||
# =====================
|
||||
# COMMENTS (2)
|
||||
# =====================
|
||||
{
|
||||
"name": "list_comments",
|
||||
"method_name": "list_comments",
|
||||
"description": "List comments on items.",
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"filter": {
|
||||
"anyOf": [{"type": "object"}, {"type": "null"}],
|
||||
"description": 'Filter object (e.g., {"collection": {"_eq": "posts"}})',
|
||||
},
|
||||
"sort": {
|
||||
"anyOf": [{"type": "array", "items": {"type": "string"}}, {"type": "null"}],
|
||||
"description": "Sort fields",
|
||||
},
|
||||
"limit": {
|
||||
"type": "integer",
|
||||
"description": "Maximum comments to return",
|
||||
"default": 100,
|
||||
},
|
||||
},
|
||||
"required": [],
|
||||
},
|
||||
"scope": "read",
|
||||
},
|
||||
{
|
||||
"name": "create_comment",
|
||||
"method_name": "create_comment",
|
||||
"description": "Create a comment on an item.",
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"collection": {"type": "string", "description": "Collection name"},
|
||||
"item": {"type": "string", "description": "Item ID"},
|
||||
"comment": {"type": "string", "description": "Comment text"},
|
||||
},
|
||||
"required": ["collection", "item", "comment"],
|
||||
},
|
||||
"scope": "write",
|
||||
},
|
||||
]
|
||||
|
||||
# =====================
|
||||
# HANDLER FUNCTIONS
|
||||
# =====================
|
||||
|
||||
async def list_revisions(
|
||||
client: DirectusClient,
|
||||
filter: dict | None = None,
|
||||
sort: list[str] | None = None,
|
||||
limit: int = 100,
|
||||
) -> str:
|
||||
"""List revisions."""
|
||||
try:
|
||||
# Parse JSON string parameters
|
||||
parsed_filter = _parse_json_param(filter, "filter")
|
||||
parsed_sort = _parse_json_param(sort, "sort")
|
||||
|
||||
result = await client.list_revisions(filter=parsed_filter, sort=parsed_sort, limit=limit)
|
||||
revisions = result.get("data", [])
|
||||
return json.dumps(
|
||||
{"success": True, "total": len(revisions), "revisions": revisions},
|
||||
indent=2,
|
||||
ensure_ascii=False,
|
||||
)
|
||||
except Exception as e:
|
||||
return json.dumps({"success": False, "error": str(e)}, indent=2)
|
||||
|
||||
async def get_revision(client: DirectusClient, id: str) -> str:
|
||||
"""Get revision by ID."""
|
||||
try:
|
||||
result = await client.get_revision(id)
|
||||
return json.dumps(
|
||||
{"success": True, "revision": result.get("data")}, indent=2, ensure_ascii=False
|
||||
)
|
||||
except Exception as e:
|
||||
return json.dumps({"success": False, "error": str(e)}, indent=2)
|
||||
|
||||
async def list_versions(
|
||||
client: DirectusClient,
|
||||
filter: dict | None = None,
|
||||
sort: list[str] | None = None,
|
||||
limit: int = 100,
|
||||
) -> str:
|
||||
"""List versions."""
|
||||
try:
|
||||
# Parse JSON string parameters
|
||||
parsed_filter = _parse_json_param(filter, "filter")
|
||||
parsed_sort = _parse_json_param(sort, "sort")
|
||||
|
||||
result = await client.list_versions(filter=parsed_filter, sort=parsed_sort, limit=limit)
|
||||
versions = result.get("data", [])
|
||||
return json.dumps(
|
||||
{"success": True, "total": len(versions), "versions": versions},
|
||||
indent=2,
|
||||
ensure_ascii=False,
|
||||
)
|
||||
except Exception as e:
|
||||
return json.dumps({"success": False, "error": str(e)}, indent=2)
|
||||
|
||||
async def get_version(client: DirectusClient, id: str) -> str:
|
||||
"""Get version by ID."""
|
||||
try:
|
||||
result = await client.get_version(id)
|
||||
return json.dumps(
|
||||
{"success": True, "version": result.get("data")}, indent=2, ensure_ascii=False
|
||||
)
|
||||
except Exception as e:
|
||||
return json.dumps({"success": False, "error": str(e)}, indent=2)
|
||||
|
||||
async def create_version(
|
||||
client: DirectusClient, name: str, collection: str, item: str, key: str
|
||||
) -> str:
|
||||
"""Create a new version."""
|
||||
try:
|
||||
result = await client.create_version(name=name, collection=collection, item=item, key=key)
|
||||
return json.dumps(
|
||||
{
|
||||
"success": True,
|
||||
"message": f"Version '{name}' created",
|
||||
"version": result.get("data"),
|
||||
},
|
||||
indent=2,
|
||||
ensure_ascii=False,
|
||||
)
|
||||
except Exception as e:
|
||||
return json.dumps({"success": False, "error": str(e)}, indent=2)
|
||||
|
||||
async def update_version(client: DirectusClient, id: str, data: dict[str, Any]) -> str:
|
||||
"""Update version."""
|
||||
try:
|
||||
# Parse JSON string parameter
|
||||
parsed_data = _parse_json_param(data, "data")
|
||||
result = await client.update_version(id, parsed_data)
|
||||
return json.dumps(
|
||||
{"success": True, "message": "Version updated", "version": result.get("data")},
|
||||
indent=2,
|
||||
ensure_ascii=False,
|
||||
)
|
||||
except Exception as e:
|
||||
return json.dumps({"success": False, "error": str(e)}, indent=2)
|
||||
|
||||
async def delete_version(client: DirectusClient, id: str) -> str:
|
||||
"""Delete a version."""
|
||||
try:
|
||||
await client.delete_version(id)
|
||||
return json.dumps({"success": True, "message": f"Version {id} deleted"}, indent=2)
|
||||
except Exception as e:
|
||||
return json.dumps({"success": False, "error": str(e)}, indent=2)
|
||||
|
||||
async def promote_version(client: DirectusClient, id: str, mainHash: str | None = None) -> str:
|
||||
"""Promote version to main."""
|
||||
try:
|
||||
result = await client.promote_version(id, mainHash)
|
||||
return json.dumps(
|
||||
{
|
||||
"success": True,
|
||||
"message": f"Version {id} promoted to main",
|
||||
"result": result.get("data") if result else None,
|
||||
},
|
||||
indent=2,
|
||||
ensure_ascii=False,
|
||||
)
|
||||
except Exception as e:
|
||||
return json.dumps({"success": False, "error": str(e)}, indent=2)
|
||||
|
||||
async def list_comments(
|
||||
client: DirectusClient,
|
||||
filter: dict | None = None,
|
||||
sort: list[str] | None = None,
|
||||
limit: int = 100,
|
||||
) -> str:
|
||||
"""List comments."""
|
||||
try:
|
||||
# Parse JSON string parameters
|
||||
parsed_filter = _parse_json_param(filter, "filter")
|
||||
parsed_sort = _parse_json_param(sort, "sort")
|
||||
|
||||
result = await client.list_comments(filter=parsed_filter, sort=parsed_sort, limit=limit)
|
||||
comments = result.get("data", [])
|
||||
return json.dumps(
|
||||
{"success": True, "total": len(comments), "comments": comments},
|
||||
indent=2,
|
||||
ensure_ascii=False,
|
||||
)
|
||||
except Exception as e:
|
||||
return json.dumps({"success": False, "error": str(e)}, indent=2)
|
||||
|
||||
async def create_comment(client: DirectusClient, collection: str, item: str, comment: str) -> str:
|
||||
"""Create a comment."""
|
||||
try:
|
||||
result = await client.create_comment(collection, item, comment)
|
||||
return json.dumps(
|
||||
{"success": True, "message": "Comment created", "comment": result.get("data")},
|
||||
indent=2,
|
||||
ensure_ascii=False,
|
||||
)
|
||||
except Exception as e:
|
||||
return json.dumps({"success": False, "error": str(e)}, indent=2)
|
||||
332
plugins/directus/handlers/dashboards.py
Normal file
332
plugins/directus/handlers/dashboards.py
Normal file
@@ -0,0 +1,332 @@
|
||||
"""
|
||||
Dashboards Handler - Dashboards & Panels
|
||||
|
||||
Phase J.4: 8 tools
|
||||
- Dashboards: list, get, create, update, delete (5)
|
||||
- Panels: list, create, delete (3)
|
||||
"""
|
||||
|
||||
import json
|
||||
from typing import Any
|
||||
|
||||
from plugins.directus.client import DirectusClient
|
||||
|
||||
def get_tool_specifications() -> list[dict[str, Any]]:
|
||||
"""Return tool specifications for ToolGenerator (8 tools)"""
|
||||
return [
|
||||
# =====================
|
||||
# DASHBOARDS (5)
|
||||
# =====================
|
||||
{
|
||||
"name": "list_dashboards",
|
||||
"method_name": "list_dashboards",
|
||||
"description": "List all insights dashboards.",
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"filter": {
|
||||
"anyOf": [{"type": "object"}, {"type": "null"}],
|
||||
"description": "Filter object",
|
||||
},
|
||||
"sort": {
|
||||
"anyOf": [{"type": "array", "items": {"type": "string"}}, {"type": "null"}],
|
||||
"description": "Sort fields",
|
||||
},
|
||||
"limit": {
|
||||
"type": "integer",
|
||||
"description": "Maximum dashboards to return",
|
||||
"default": 100,
|
||||
},
|
||||
},
|
||||
"required": [],
|
||||
},
|
||||
"scope": "read",
|
||||
},
|
||||
{
|
||||
"name": "get_dashboard",
|
||||
"method_name": "get_dashboard",
|
||||
"description": "Get dashboard details by ID.",
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {"id": {"type": "string", "description": "Dashboard UUID"}},
|
||||
"required": ["id"],
|
||||
},
|
||||
"scope": "read",
|
||||
},
|
||||
{
|
||||
"name": "create_dashboard",
|
||||
"method_name": "create_dashboard",
|
||||
"description": "Create a new insights dashboard.",
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"name": {"type": "string", "description": "Dashboard name"},
|
||||
"icon": {
|
||||
"type": "string",
|
||||
"description": "Material icon",
|
||||
"default": "dashboard",
|
||||
},
|
||||
"note": {
|
||||
"anyOf": [{"type": "string"}, {"type": "null"}],
|
||||
"description": "Dashboard description",
|
||||
},
|
||||
"color": {
|
||||
"anyOf": [{"type": "string"}, {"type": "null"}],
|
||||
"description": "Accent color",
|
||||
},
|
||||
},
|
||||
"required": ["name"],
|
||||
},
|
||||
"scope": "write",
|
||||
},
|
||||
{
|
||||
"name": "update_dashboard",
|
||||
"method_name": "update_dashboard",
|
||||
"description": "Update dashboard settings.",
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"id": {"type": "string", "description": "Dashboard UUID"},
|
||||
"data": {
|
||||
"type": "object",
|
||||
"description": "Fields to update (name, icon, note, color)",
|
||||
},
|
||||
},
|
||||
"required": ["id", "data"],
|
||||
},
|
||||
"scope": "write",
|
||||
},
|
||||
{
|
||||
"name": "delete_dashboard",
|
||||
"method_name": "delete_dashboard",
|
||||
"description": "Delete a dashboard and its panels.",
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {"id": {"type": "string", "description": "Dashboard UUID to delete"}},
|
||||
"required": ["id"],
|
||||
},
|
||||
"scope": "write",
|
||||
},
|
||||
# =====================
|
||||
# PANELS (3)
|
||||
# =====================
|
||||
{
|
||||
"name": "list_panels",
|
||||
"method_name": "list_panels",
|
||||
"description": "List all panels, optionally filtered by dashboard.",
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"filter": {
|
||||
"anyOf": [{"type": "object"}, {"type": "null"}],
|
||||
"description": 'Filter object (e.g., {"dashboard": {"_eq": "dashboard-uuid"}})',
|
||||
},
|
||||
"limit": {
|
||||
"type": "integer",
|
||||
"description": "Maximum panels to return",
|
||||
"default": 100,
|
||||
},
|
||||
},
|
||||
"required": [],
|
||||
},
|
||||
"scope": "read",
|
||||
},
|
||||
{
|
||||
"name": "create_panel",
|
||||
"method_name": "create_panel",
|
||||
"description": "Create a new panel in a dashboard.",
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"dashboard": {"type": "string", "description": "Dashboard UUID"},
|
||||
"type": {
|
||||
"type": "string",
|
||||
"description": "Panel type (label, list, metric, time-series, etc.)",
|
||||
},
|
||||
"name": {
|
||||
"anyOf": [{"type": "string"}, {"type": "null"}],
|
||||
"description": "Panel name",
|
||||
},
|
||||
"icon": {
|
||||
"anyOf": [{"type": "string"}, {"type": "null"}],
|
||||
"description": "Material icon",
|
||||
},
|
||||
"color": {
|
||||
"anyOf": [{"type": "string"}, {"type": "null"}],
|
||||
"description": "Accent color",
|
||||
},
|
||||
"note": {
|
||||
"anyOf": [{"type": "string"}, {"type": "null"}],
|
||||
"description": "Panel description",
|
||||
},
|
||||
"width": {
|
||||
"type": "integer",
|
||||
"description": "Panel width (1-12 grid units)",
|
||||
"default": 12,
|
||||
},
|
||||
"height": {
|
||||
"type": "integer",
|
||||
"description": "Panel height (grid units)",
|
||||
"default": 6,
|
||||
},
|
||||
"position_x": {
|
||||
"type": "integer",
|
||||
"description": "X position in dashboard",
|
||||
"default": 0,
|
||||
},
|
||||
"position_y": {
|
||||
"type": "integer",
|
||||
"description": "Y position in dashboard",
|
||||
"default": 0,
|
||||
},
|
||||
"options": {
|
||||
"anyOf": [{"type": "object"}, {"type": "null"}],
|
||||
"description": "Panel-specific options",
|
||||
},
|
||||
},
|
||||
"required": ["dashboard", "type"],
|
||||
},
|
||||
"scope": "write",
|
||||
},
|
||||
{
|
||||
"name": "delete_panel",
|
||||
"method_name": "delete_panel",
|
||||
"description": "Delete a panel from a dashboard.",
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {"id": {"type": "string", "description": "Panel UUID to delete"}},
|
||||
"required": ["id"],
|
||||
},
|
||||
"scope": "write",
|
||||
},
|
||||
]
|
||||
|
||||
# =====================
|
||||
# HANDLER FUNCTIONS
|
||||
# =====================
|
||||
|
||||
async def list_dashboards(
|
||||
client: DirectusClient,
|
||||
filter: dict | None = None,
|
||||
sort: list[str] | None = None,
|
||||
limit: int = 100,
|
||||
) -> str:
|
||||
"""List dashboards."""
|
||||
try:
|
||||
result = await client.list_dashboards(filter=filter, sort=sort, limit=limit)
|
||||
dashboards = result.get("data", [])
|
||||
return json.dumps(
|
||||
{"success": True, "total": len(dashboards), "dashboards": dashboards},
|
||||
indent=2,
|
||||
ensure_ascii=False,
|
||||
)
|
||||
except Exception as e:
|
||||
return json.dumps({"success": False, "error": str(e)}, indent=2)
|
||||
|
||||
async def get_dashboard(client: DirectusClient, id: str) -> str:
|
||||
"""Get dashboard by ID."""
|
||||
try:
|
||||
result = await client.get_dashboard(id)
|
||||
return json.dumps(
|
||||
{"success": True, "dashboard": result.get("data")}, indent=2, ensure_ascii=False
|
||||
)
|
||||
except Exception as e:
|
||||
return json.dumps({"success": False, "error": str(e)}, indent=2)
|
||||
|
||||
async def create_dashboard(
|
||||
client: DirectusClient,
|
||||
name: str,
|
||||
icon: str = "dashboard",
|
||||
note: str | None = None,
|
||||
color: str | None = None,
|
||||
) -> str:
|
||||
"""Create a new dashboard."""
|
||||
try:
|
||||
result = await client.create_dashboard(name=name, icon=icon, note=note, color=color)
|
||||
return json.dumps(
|
||||
{
|
||||
"success": True,
|
||||
"message": f"Dashboard '{name}' created",
|
||||
"dashboard": result.get("data"),
|
||||
},
|
||||
indent=2,
|
||||
ensure_ascii=False,
|
||||
)
|
||||
except Exception as e:
|
||||
return json.dumps({"success": False, "error": str(e)}, indent=2)
|
||||
|
||||
async def update_dashboard(client: DirectusClient, id: str, data: dict[str, Any]) -> str:
|
||||
"""Update dashboard."""
|
||||
try:
|
||||
result = await client.update_dashboard(id, data)
|
||||
return json.dumps(
|
||||
{"success": True, "message": "Dashboard updated", "dashboard": result.get("data")},
|
||||
indent=2,
|
||||
ensure_ascii=False,
|
||||
)
|
||||
except Exception as e:
|
||||
return json.dumps({"success": False, "error": str(e)}, indent=2)
|
||||
|
||||
async def delete_dashboard(client: DirectusClient, id: str) -> str:
|
||||
"""Delete a dashboard."""
|
||||
try:
|
||||
await client.delete_dashboard(id)
|
||||
return json.dumps({"success": True, "message": f"Dashboard {id} deleted"}, indent=2)
|
||||
except Exception as e:
|
||||
return json.dumps({"success": False, "error": str(e)}, indent=2)
|
||||
|
||||
async def list_panels(client: DirectusClient, filter: dict | None = None, limit: int = 100) -> str:
|
||||
"""List panels."""
|
||||
try:
|
||||
result = await client.list_panels(filter=filter, limit=limit)
|
||||
panels = result.get("data", [])
|
||||
return json.dumps(
|
||||
{"success": True, "total": len(panels), "panels": panels}, indent=2, ensure_ascii=False
|
||||
)
|
||||
except Exception as e:
|
||||
return json.dumps({"success": False, "error": str(e)}, indent=2)
|
||||
|
||||
async def create_panel(
|
||||
client: DirectusClient,
|
||||
dashboard: str,
|
||||
type: str,
|
||||
name: str | None = None,
|
||||
icon: str | None = None,
|
||||
color: str | None = None,
|
||||
note: str | None = None,
|
||||
width: int = 12,
|
||||
height: int = 6,
|
||||
position_x: int = 0,
|
||||
position_y: int = 0,
|
||||
options: dict | None = None,
|
||||
) -> str:
|
||||
"""Create a new panel."""
|
||||
try:
|
||||
result = await client.create_panel(
|
||||
dashboard=dashboard,
|
||||
type=type,
|
||||
name=name,
|
||||
icon=icon,
|
||||
color=color,
|
||||
note=note,
|
||||
width=width,
|
||||
height=height,
|
||||
position_x=position_x,
|
||||
position_y=position_y,
|
||||
options=options,
|
||||
)
|
||||
return json.dumps(
|
||||
{"success": True, "message": f"Panel '{type}' created", "panel": result.get("data")},
|
||||
indent=2,
|
||||
ensure_ascii=False,
|
||||
)
|
||||
except Exception as e:
|
||||
return json.dumps({"success": False, "error": str(e)}, indent=2)
|
||||
|
||||
async def delete_panel(client: DirectusClient, id: str) -> str:
|
||||
"""Delete a panel."""
|
||||
try:
|
||||
await client.delete_panel(id)
|
||||
return json.dumps({"success": True, "message": f"Panel {id} deleted"}, indent=2)
|
||||
except Exception as e:
|
||||
return json.dumps({"success": False, "error": str(e)}, indent=2)
|
||||
406
plugins/directus/handlers/files.py
Normal file
406
plugins/directus/handlers/files.py
Normal file
@@ -0,0 +1,406 @@
|
||||
"""
|
||||
Files & Folders Handler - Asset management
|
||||
|
||||
Phase J.2: 12 tools
|
||||
- Files: list, get, update, delete, delete_files, import_url (6)
|
||||
- Folders: list, get, create, update, delete (5)
|
||||
- Note: File upload requires multipart form - import_url is the alternative
|
||||
"""
|
||||
|
||||
import json
|
||||
from typing import Any
|
||||
|
||||
from plugins.directus.client import DirectusClient
|
||||
|
||||
def _parse_json_param(value: Any, param_name: str = "parameter") -> Any:
|
||||
"""Parse a parameter that may be a JSON string or already a native type."""
|
||||
if value is None:
|
||||
return None
|
||||
if isinstance(value, (dict, list)):
|
||||
return value
|
||||
if isinstance(value, str):
|
||||
stripped = value.strip()
|
||||
if stripped.startswith("{") or stripped.startswith("["):
|
||||
try:
|
||||
return json.loads(stripped)
|
||||
except json.JSONDecodeError as e:
|
||||
raise ValueError(f"Invalid JSON in '{param_name}': {e}")
|
||||
return value
|
||||
|
||||
def get_tool_specifications() -> list[dict[str, Any]]:
|
||||
"""Return tool specifications for ToolGenerator (12 tools)"""
|
||||
return [
|
||||
# =====================
|
||||
# FILES (7)
|
||||
# =====================
|
||||
{
|
||||
"name": "list_files",
|
||||
"method_name": "list_files",
|
||||
"description": "List all files in Directus storage with filtering options.",
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"filter": {
|
||||
"anyOf": [{"type": "object"}, {"type": "null"}],
|
||||
"description": 'Filter object (e.g., {"type": {"_contains": "image"}})',
|
||||
},
|
||||
"sort": {
|
||||
"anyOf": [{"type": "array", "items": {"type": "string"}}, {"type": "null"}],
|
||||
"description": "Sort fields (e.g., ['-uploaded_on'])",
|
||||
},
|
||||
"limit": {
|
||||
"type": "integer",
|
||||
"description": "Maximum files to return",
|
||||
"default": 100,
|
||||
},
|
||||
"offset": {"type": "integer", "description": "Files to skip", "default": 0},
|
||||
"search": {
|
||||
"anyOf": [{"type": "string"}, {"type": "null"}],
|
||||
"description": "Search term",
|
||||
},
|
||||
},
|
||||
"required": [],
|
||||
},
|
||||
"scope": "read",
|
||||
},
|
||||
{
|
||||
"name": "get_file",
|
||||
"method_name": "get_file",
|
||||
"description": "Get file metadata by ID.",
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {"id": {"type": "string", "description": "File UUID"}},
|
||||
"required": ["id"],
|
||||
},
|
||||
"scope": "read",
|
||||
},
|
||||
{
|
||||
"name": "update_file",
|
||||
"method_name": "update_file",
|
||||
"description": "Update file metadata (title, description, tags, folder).",
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"id": {"type": "string", "description": "File UUID"},
|
||||
"data": {
|
||||
"type": "object",
|
||||
"description": "Fields to update (title, description, tags, folder, etc.)",
|
||||
},
|
||||
},
|
||||
"required": ["id", "data"],
|
||||
},
|
||||
"scope": "write",
|
||||
},
|
||||
{
|
||||
"name": "delete_file",
|
||||
"method_name": "delete_file",
|
||||
"description": "Delete a file. This action is irreversible.",
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {"id": {"type": "string", "description": "File UUID to delete"}},
|
||||
"required": ["id"],
|
||||
},
|
||||
"scope": "admin",
|
||||
},
|
||||
{
|
||||
"name": "delete_files",
|
||||
"method_name": "delete_files",
|
||||
"description": "Delete multiple files. This action is irreversible.",
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"ids": {
|
||||
"type": "array",
|
||||
"items": {"type": "string"},
|
||||
"description": "Array of file UUIDs to delete",
|
||||
}
|
||||
},
|
||||
"required": ["ids"],
|
||||
},
|
||||
"scope": "admin",
|
||||
},
|
||||
{
|
||||
"name": "import_file_url",
|
||||
"method_name": "import_file_url",
|
||||
"description": "Import a file from a URL into Directus storage.",
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"url": {"type": "string", "description": "URL of the file to import"},
|
||||
"data": {
|
||||
"anyOf": [{"type": "object"}, {"type": "null"}],
|
||||
"description": "Additional file data (title, description, folder, etc.)",
|
||||
},
|
||||
},
|
||||
"required": ["url"],
|
||||
},
|
||||
"scope": "write",
|
||||
},
|
||||
{
|
||||
"name": "get_file_url",
|
||||
"method_name": "get_file_url",
|
||||
"description": "Get the direct URL to access a file.",
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"id": {"type": "string", "description": "File UUID"},
|
||||
"download": {
|
||||
"type": "boolean",
|
||||
"description": "Whether to force download instead of display",
|
||||
"default": False,
|
||||
},
|
||||
},
|
||||
"required": ["id"],
|
||||
},
|
||||
"scope": "read",
|
||||
},
|
||||
# =====================
|
||||
# FOLDERS (5)
|
||||
# =====================
|
||||
{
|
||||
"name": "list_folders",
|
||||
"method_name": "list_folders",
|
||||
"description": "List all folders in Directus storage.",
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"filter": {
|
||||
"anyOf": [{"type": "object"}, {"type": "null"}],
|
||||
"description": "Filter object",
|
||||
},
|
||||
"sort": {
|
||||
"anyOf": [{"type": "array", "items": {"type": "string"}}, {"type": "null"}],
|
||||
"description": "Sort fields",
|
||||
},
|
||||
"limit": {
|
||||
"type": "integer",
|
||||
"description": "Maximum folders to return",
|
||||
"default": 100,
|
||||
},
|
||||
"search": {
|
||||
"anyOf": [{"type": "string"}, {"type": "null"}],
|
||||
"description": "Search term",
|
||||
},
|
||||
},
|
||||
"required": [],
|
||||
},
|
||||
"scope": "read",
|
||||
},
|
||||
{
|
||||
"name": "get_folder",
|
||||
"method_name": "get_folder",
|
||||
"description": "Get folder details by ID.",
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {"id": {"type": "string", "description": "Folder UUID"}},
|
||||
"required": ["id"],
|
||||
},
|
||||
"scope": "read",
|
||||
},
|
||||
{
|
||||
"name": "create_folder",
|
||||
"method_name": "create_folder",
|
||||
"description": "Create a new folder.",
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"name": {"type": "string", "description": "Folder name"},
|
||||
"parent": {
|
||||
"anyOf": [{"type": "string"}, {"type": "null"}],
|
||||
"description": "Parent folder UUID (null for root)",
|
||||
},
|
||||
},
|
||||
"required": ["name"],
|
||||
},
|
||||
"scope": "write",
|
||||
},
|
||||
{
|
||||
"name": "update_folder",
|
||||
"method_name": "update_folder",
|
||||
"description": "Update folder name or parent.",
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"id": {"type": "string", "description": "Folder UUID"},
|
||||
"data": {"type": "object", "description": "Fields to update (name, parent)"},
|
||||
},
|
||||
"required": ["id", "data"],
|
||||
},
|
||||
"scope": "write",
|
||||
},
|
||||
{
|
||||
"name": "delete_folder",
|
||||
"method_name": "delete_folder",
|
||||
"description": "Delete a folder. This action is irreversible.",
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {"id": {"type": "string", "description": "Folder UUID to delete"}},
|
||||
"required": ["id"],
|
||||
},
|
||||
"scope": "admin",
|
||||
},
|
||||
]
|
||||
|
||||
# =====================
|
||||
# HANDLER FUNCTIONS
|
||||
# =====================
|
||||
|
||||
async def list_files(
|
||||
client: DirectusClient,
|
||||
filter: dict | None = None,
|
||||
sort: list[str] | None = None,
|
||||
limit: int = 100,
|
||||
offset: int = 0,
|
||||
search: str | None = None,
|
||||
) -> str:
|
||||
"""List files."""
|
||||
try:
|
||||
# Parse JSON string parameters
|
||||
parsed_filter = _parse_json_param(filter, "filter")
|
||||
parsed_sort = _parse_json_param(sort, "sort")
|
||||
|
||||
result = await client.list_files(
|
||||
filter=parsed_filter, sort=parsed_sort, limit=limit, offset=offset, search=search
|
||||
)
|
||||
files = result.get("data", [])
|
||||
return json.dumps(
|
||||
{"success": True, "total": len(files), "files": files}, indent=2, ensure_ascii=False
|
||||
)
|
||||
except Exception as e:
|
||||
return json.dumps({"success": False, "error": str(e)}, indent=2)
|
||||
|
||||
async def get_file(client: DirectusClient, id: str) -> str:
|
||||
"""Get file metadata."""
|
||||
try:
|
||||
result = await client.get_file(id)
|
||||
return json.dumps(
|
||||
{"success": True, "file": result.get("data")}, indent=2, ensure_ascii=False
|
||||
)
|
||||
except Exception as e:
|
||||
return json.dumps({"success": False, "error": str(e)}, indent=2)
|
||||
|
||||
async def update_file(client: DirectusClient, id: str, data: dict[str, Any]) -> str:
|
||||
"""Update file metadata."""
|
||||
try:
|
||||
# Parse JSON string parameter
|
||||
parsed_data = _parse_json_param(data, "data")
|
||||
result = await client.update_file(id, parsed_data)
|
||||
return json.dumps(
|
||||
{"success": True, "message": "File updated", "file": result.get("data")},
|
||||
indent=2,
|
||||
ensure_ascii=False,
|
||||
)
|
||||
except Exception as e:
|
||||
return json.dumps({"success": False, "error": str(e)}, indent=2)
|
||||
|
||||
async def delete_file(client: DirectusClient, id: str) -> str:
|
||||
"""Delete a file."""
|
||||
try:
|
||||
await client.delete_file(id)
|
||||
return json.dumps({"success": True, "message": f"File {id} deleted"}, indent=2)
|
||||
except Exception as e:
|
||||
return json.dumps({"success": False, "error": str(e)}, indent=2)
|
||||
|
||||
async def delete_files(client: DirectusClient, ids: list[str]) -> str:
|
||||
"""Delete multiple files."""
|
||||
try:
|
||||
# Parse JSON string parameter
|
||||
parsed_ids = _parse_json_param(ids, "ids")
|
||||
await client.delete_files(parsed_ids)
|
||||
return json.dumps({"success": True, "message": f"Deleted {len(ids)} files"}, indent=2)
|
||||
except Exception as e:
|
||||
return json.dumps({"success": False, "error": str(e)}, indent=2)
|
||||
|
||||
async def import_file_url(client: DirectusClient, url: str, data: dict | None = None) -> str:
|
||||
"""Import file from URL."""
|
||||
try:
|
||||
# Parse JSON string parameter
|
||||
parsed_data = _parse_json_param(data, "data")
|
||||
result = await client.import_file_url(url, parsed_data)
|
||||
return json.dumps(
|
||||
{"success": True, "message": "File imported", "file": result.get("data")},
|
||||
indent=2,
|
||||
ensure_ascii=False,
|
||||
)
|
||||
except Exception as e:
|
||||
return json.dumps({"success": False, "error": str(e)}, indent=2)
|
||||
|
||||
async def get_file_url(client: DirectusClient, id: str, download: bool = False) -> str:
|
||||
"""Get file URL."""
|
||||
try:
|
||||
base_url = client.base_url
|
||||
url = f"{base_url}/assets/{id}"
|
||||
if download:
|
||||
url += "?download=true"
|
||||
return json.dumps({"success": True, "id": id, "url": url, "download": download}, indent=2)
|
||||
except Exception as e:
|
||||
return json.dumps({"success": False, "error": str(e)}, indent=2)
|
||||
|
||||
async def list_folders(
|
||||
client: DirectusClient,
|
||||
filter: dict | None = None,
|
||||
sort: list[str] | None = None,
|
||||
limit: int = 100,
|
||||
search: str | None = None,
|
||||
) -> str:
|
||||
"""List folders."""
|
||||
try:
|
||||
# Parse JSON string parameters
|
||||
parsed_filter = _parse_json_param(filter, "filter")
|
||||
parsed_sort = _parse_json_param(sort, "sort")
|
||||
|
||||
result = await client.list_folders(
|
||||
filter=parsed_filter, sort=parsed_sort, limit=limit, search=search
|
||||
)
|
||||
folders = result.get("data", [])
|
||||
return json.dumps(
|
||||
{"success": True, "total": len(folders), "folders": folders},
|
||||
indent=2,
|
||||
ensure_ascii=False,
|
||||
)
|
||||
except Exception as e:
|
||||
return json.dumps({"success": False, "error": str(e)}, indent=2)
|
||||
|
||||
async def get_folder(client: DirectusClient, id: str) -> str:
|
||||
"""Get folder details."""
|
||||
try:
|
||||
result = await client.get_folder(id)
|
||||
return json.dumps(
|
||||
{"success": True, "folder": result.get("data")}, indent=2, ensure_ascii=False
|
||||
)
|
||||
except Exception as e:
|
||||
return json.dumps({"success": False, "error": str(e)}, indent=2)
|
||||
|
||||
async def create_folder(client: DirectusClient, name: str, parent: str | None = None) -> str:
|
||||
"""Create a folder."""
|
||||
try:
|
||||
result = await client.create_folder(name, parent)
|
||||
return json.dumps(
|
||||
{"success": True, "message": f"Folder '{name}' created", "folder": result.get("data")},
|
||||
indent=2,
|
||||
ensure_ascii=False,
|
||||
)
|
||||
except Exception as e:
|
||||
return json.dumps({"success": False, "error": str(e)}, indent=2)
|
||||
|
||||
async def update_folder(client: DirectusClient, id: str, data: dict[str, Any]) -> str:
|
||||
"""Update folder."""
|
||||
try:
|
||||
# Parse JSON string parameter
|
||||
parsed_data = _parse_json_param(data, "data")
|
||||
result = await client.update_folder(id, parsed_data)
|
||||
return json.dumps(
|
||||
{"success": True, "message": "Folder updated", "folder": result.get("data")},
|
||||
indent=2,
|
||||
ensure_ascii=False,
|
||||
)
|
||||
except Exception as e:
|
||||
return json.dumps({"success": False, "error": str(e)}, indent=2)
|
||||
|
||||
async def delete_folder(client: DirectusClient, id: str) -> str:
|
||||
"""Delete a folder."""
|
||||
try:
|
||||
await client.delete_folder(id)
|
||||
return json.dumps({"success": True, "message": f"Folder {id} deleted"}, indent=2)
|
||||
except Exception as e:
|
||||
return json.dumps({"success": False, "error": str(e)}, indent=2)
|
||||
576
plugins/directus/handlers/items.py
Normal file
576
plugins/directus/handlers/items.py
Normal file
@@ -0,0 +1,576 @@
|
||||
"""
|
||||
Items Handler - CRUD operations for any collection
|
||||
|
||||
Phase J.1: 12 tools
|
||||
- list_items, get_item, create_item, create_items
|
||||
- update_item, update_items, delete_item, delete_items
|
||||
- search_items, aggregate_items, export_items, import_items
|
||||
"""
|
||||
|
||||
import json
|
||||
from typing import Any
|
||||
|
||||
from plugins.directus.client import DirectusClient
|
||||
|
||||
def _parse_json_param(value: Any, param_name: str = "parameter") -> Any:
|
||||
"""Parse a parameter that may be a JSON string or already a native type."""
|
||||
if value is None:
|
||||
return None
|
||||
if isinstance(value, (dict, list)):
|
||||
return value
|
||||
if isinstance(value, str):
|
||||
stripped = value.strip()
|
||||
if stripped.startswith("{") or stripped.startswith("["):
|
||||
try:
|
||||
return json.loads(stripped)
|
||||
except json.JSONDecodeError as e:
|
||||
raise ValueError(f"Invalid JSON in '{param_name}': {e}")
|
||||
return value
|
||||
|
||||
def get_tool_specifications() -> list[dict[str, Any]]:
|
||||
"""Return tool specifications for ToolGenerator (12 tools)"""
|
||||
return [
|
||||
{
|
||||
"name": "list_items",
|
||||
"method_name": "list_items",
|
||||
"description": "List items from any Directus collection with filtering, sorting, and pagination.",
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"collection": {
|
||||
"type": "string",
|
||||
"description": "Collection name (e.g., 'posts', 'products')",
|
||||
},
|
||||
"fields": {
|
||||
"anyOf": [{"type": "array", "items": {"type": "string"}}, {"type": "null"}],
|
||||
"description": "Fields to return (e.g., ['id', 'title', 'author.*'])",
|
||||
},
|
||||
"filter": {
|
||||
"anyOf": [{"type": "object"}, {"type": "null"}],
|
||||
"description": 'Filter object (e.g., {"status": {"_eq": "published"}})',
|
||||
},
|
||||
"sort": {
|
||||
"anyOf": [{"type": "array", "items": {"type": "string"}}, {"type": "null"}],
|
||||
"description": "Sort fields (e.g., ['-date_created', 'title'])",
|
||||
},
|
||||
"limit": {
|
||||
"type": "integer",
|
||||
"description": "Maximum items to return",
|
||||
"default": 100,
|
||||
},
|
||||
"offset": {"type": "integer", "description": "Items to skip", "default": 0},
|
||||
"search": {
|
||||
"anyOf": [{"type": "string"}, {"type": "null"}],
|
||||
"description": "Full-text search query",
|
||||
},
|
||||
},
|
||||
"required": ["collection"],
|
||||
},
|
||||
"scope": "read",
|
||||
},
|
||||
{
|
||||
"name": "get_item",
|
||||
"method_name": "get_item",
|
||||
"description": "Get a single item by ID from any collection.",
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"collection": {"type": "string", "description": "Collection name"},
|
||||
"id": {"type": "string", "description": "Item ID"},
|
||||
"fields": {
|
||||
"anyOf": [{"type": "array", "items": {"type": "string"}}, {"type": "null"}],
|
||||
"description": "Fields to return",
|
||||
},
|
||||
},
|
||||
"required": ["collection", "id"],
|
||||
},
|
||||
"scope": "read",
|
||||
},
|
||||
{
|
||||
"name": "create_item",
|
||||
"method_name": "create_item",
|
||||
"description": "Create a new item in any collection.",
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"collection": {"type": "string", "description": "Collection name"},
|
||||
"data": {"type": "object", "description": "Item data (field values)"},
|
||||
"fields": {
|
||||
"anyOf": [{"type": "array", "items": {"type": "string"}}, {"type": "null"}],
|
||||
"description": "Fields to return in response",
|
||||
},
|
||||
},
|
||||
"required": ["collection", "data"],
|
||||
},
|
||||
"scope": "write",
|
||||
},
|
||||
{
|
||||
"name": "create_items",
|
||||
"method_name": "create_items",
|
||||
"description": "Create multiple items in a collection at once.",
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"collection": {"type": "string", "description": "Collection name"},
|
||||
"data": {
|
||||
"type": "array",
|
||||
"items": {"type": "object"},
|
||||
"description": "Array of item data objects",
|
||||
},
|
||||
"fields": {
|
||||
"anyOf": [{"type": "array", "items": {"type": "string"}}, {"type": "null"}],
|
||||
"description": "Fields to return in response",
|
||||
},
|
||||
},
|
||||
"required": ["collection", "data"],
|
||||
},
|
||||
"scope": "write",
|
||||
},
|
||||
{
|
||||
"name": "update_item",
|
||||
"method_name": "update_item",
|
||||
"description": "Update an existing item by ID.",
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"collection": {"type": "string", "description": "Collection name"},
|
||||
"id": {"type": "string", "description": "Item ID"},
|
||||
"data": {"type": "object", "description": "Fields to update"},
|
||||
"fields": {
|
||||
"anyOf": [{"type": "array", "items": {"type": "string"}}, {"type": "null"}],
|
||||
"description": "Fields to return in response",
|
||||
},
|
||||
},
|
||||
"required": ["collection", "id", "data"],
|
||||
},
|
||||
"scope": "write",
|
||||
},
|
||||
{
|
||||
"name": "update_items",
|
||||
"method_name": "update_items",
|
||||
"description": "Update multiple items by their IDs.",
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"collection": {"type": "string", "description": "Collection name"},
|
||||
"keys": {
|
||||
"type": "array",
|
||||
"items": {"type": "string"},
|
||||
"description": "Array of item IDs to update",
|
||||
},
|
||||
"data": {
|
||||
"type": "object",
|
||||
"description": "Fields to update (applied to all items)",
|
||||
},
|
||||
"fields": {
|
||||
"anyOf": [{"type": "array", "items": {"type": "string"}}, {"type": "null"}],
|
||||
"description": "Fields to return in response",
|
||||
},
|
||||
},
|
||||
"required": ["collection", "keys", "data"],
|
||||
},
|
||||
"scope": "write",
|
||||
},
|
||||
{
|
||||
"name": "delete_item",
|
||||
"method_name": "delete_item",
|
||||
"description": "Delete an item by ID. This action is irreversible.",
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"collection": {"type": "string", "description": "Collection name"},
|
||||
"id": {"type": "string", "description": "Item ID to delete"},
|
||||
},
|
||||
"required": ["collection", "id"],
|
||||
},
|
||||
"scope": "admin",
|
||||
},
|
||||
{
|
||||
"name": "delete_items",
|
||||
"method_name": "delete_items",
|
||||
"description": "Delete multiple items by their IDs. This action is irreversible.",
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"collection": {"type": "string", "description": "Collection name"},
|
||||
"keys": {
|
||||
"type": "array",
|
||||
"items": {"type": "string"},
|
||||
"description": "Array of item IDs to delete",
|
||||
},
|
||||
},
|
||||
"required": ["collection", "keys"],
|
||||
},
|
||||
"scope": "admin",
|
||||
},
|
||||
{
|
||||
"name": "search_items",
|
||||
"method_name": "search_items",
|
||||
"description": "Full-text search across items in a collection.",
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"collection": {"type": "string", "description": "Collection name"},
|
||||
"query": {"type": "string", "description": "Search query"},
|
||||
"fields": {
|
||||
"anyOf": [{"type": "array", "items": {"type": "string"}}, {"type": "null"}],
|
||||
"description": "Fields to return",
|
||||
},
|
||||
"limit": {
|
||||
"type": "integer",
|
||||
"description": "Maximum items to return",
|
||||
"default": 25,
|
||||
},
|
||||
},
|
||||
"required": ["collection", "query"],
|
||||
},
|
||||
"scope": "read",
|
||||
},
|
||||
{
|
||||
"name": "aggregate_items",
|
||||
"method_name": "aggregate_items",
|
||||
"description": "Perform aggregate operations (count, sum, avg, min, max) on items.",
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"collection": {"type": "string", "description": "Collection name"},
|
||||
"aggregate": {
|
||||
"type": "object",
|
||||
"description": 'Aggregate functions (e.g., {"count": "*", "sum": "price"})',
|
||||
},
|
||||
"filter": {
|
||||
"anyOf": [{"type": "object"}, {"type": "null"}],
|
||||
"description": "Filter to apply before aggregation",
|
||||
},
|
||||
"groupBy": {
|
||||
"anyOf": [{"type": "array", "items": {"type": "string"}}, {"type": "null"}],
|
||||
"description": "Fields to group by",
|
||||
},
|
||||
},
|
||||
"required": ["collection", "aggregate"],
|
||||
},
|
||||
"scope": "read",
|
||||
},
|
||||
{
|
||||
"name": "export_items",
|
||||
"method_name": "export_items",
|
||||
"description": "Export items from a collection with filtering options.",
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"collection": {"type": "string", "description": "Collection name"},
|
||||
"fields": {
|
||||
"anyOf": [{"type": "array", "items": {"type": "string"}}, {"type": "null"}],
|
||||
"description": "Fields to export",
|
||||
},
|
||||
"filter": {
|
||||
"anyOf": [{"type": "object"}, {"type": "null"}],
|
||||
"description": "Filter to apply",
|
||||
},
|
||||
"sort": {
|
||||
"anyOf": [{"type": "array", "items": {"type": "string"}}, {"type": "null"}],
|
||||
"description": "Sort fields",
|
||||
},
|
||||
"limit": {
|
||||
"type": "integer",
|
||||
"description": "Maximum items to export",
|
||||
"default": 1000,
|
||||
},
|
||||
},
|
||||
"required": ["collection"],
|
||||
},
|
||||
"scope": "read",
|
||||
},
|
||||
{
|
||||
"name": "import_items",
|
||||
"method_name": "import_items",
|
||||
"description": "Import multiple items into a collection.",
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"collection": {"type": "string", "description": "Collection name"},
|
||||
"data": {
|
||||
"type": "array",
|
||||
"items": {"type": "object"},
|
||||
"description": "Array of items to import",
|
||||
},
|
||||
},
|
||||
"required": ["collection", "data"],
|
||||
},
|
||||
"scope": "write",
|
||||
},
|
||||
]
|
||||
|
||||
# =====================
|
||||
# HANDLER FUNCTIONS
|
||||
# =====================
|
||||
|
||||
async def list_items(
|
||||
client: DirectusClient,
|
||||
collection: str,
|
||||
fields: list[str] | None = None,
|
||||
filter: dict | None = None,
|
||||
sort: list[str] | None = None,
|
||||
limit: int = 100,
|
||||
offset: int = 0,
|
||||
search: str | None = None,
|
||||
) -> str:
|
||||
"""List items from a collection."""
|
||||
try:
|
||||
# Parse JSON string parameters
|
||||
parsed_fields = _parse_json_param(fields, "fields")
|
||||
parsed_filter = _parse_json_param(filter, "filter")
|
||||
parsed_sort = _parse_json_param(sort, "sort")
|
||||
|
||||
result = await client.list_items(
|
||||
collection=collection,
|
||||
fields=parsed_fields,
|
||||
filter=parsed_filter,
|
||||
sort=parsed_sort,
|
||||
limit=limit,
|
||||
offset=offset,
|
||||
search=search,
|
||||
)
|
||||
items = result.get("data", [])
|
||||
return json.dumps(
|
||||
{"success": True, "collection": collection, "total": len(items), "items": items},
|
||||
indent=2,
|
||||
ensure_ascii=False,
|
||||
)
|
||||
except Exception as e:
|
||||
return json.dumps({"success": False, "error": str(e)}, indent=2)
|
||||
|
||||
async def get_item(
|
||||
client: DirectusClient, collection: str, id: str, fields: list[str] | None = None
|
||||
) -> str:
|
||||
"""Get item by ID."""
|
||||
try:
|
||||
# Parse JSON string parameter
|
||||
parsed_fields = _parse_json_param(fields, "fields")
|
||||
result = await client.get_item(collection, id, fields=parsed_fields)
|
||||
return json.dumps(
|
||||
{"success": True, "item": result.get("data")}, indent=2, ensure_ascii=False
|
||||
)
|
||||
except Exception as e:
|
||||
return json.dumps({"success": False, "error": str(e)}, indent=2)
|
||||
|
||||
async def create_item(
|
||||
client: DirectusClient, collection: str, data: dict[str, Any], fields: list[str] | None = None
|
||||
) -> str:
|
||||
"""Create a new item."""
|
||||
try:
|
||||
# Parse JSON string parameters
|
||||
parsed_data = _parse_json_param(data, "data")
|
||||
parsed_fields = _parse_json_param(fields, "fields")
|
||||
result = await client.create_item(collection, parsed_data, fields=parsed_fields)
|
||||
return json.dumps(
|
||||
{
|
||||
"success": True,
|
||||
"message": f"Item created in {collection}",
|
||||
"item": result.get("data"),
|
||||
},
|
||||
indent=2,
|
||||
ensure_ascii=False,
|
||||
)
|
||||
except Exception as e:
|
||||
return json.dumps({"success": False, "error": str(e)}, indent=2)
|
||||
|
||||
async def create_items(
|
||||
client: DirectusClient,
|
||||
collection: str,
|
||||
data: list[dict[str, Any]],
|
||||
fields: list[str] | None = None,
|
||||
) -> str:
|
||||
"""Create multiple items."""
|
||||
try:
|
||||
# Parse JSON string parameters
|
||||
parsed_data = _parse_json_param(data, "data")
|
||||
parsed_fields = _parse_json_param(fields, "fields")
|
||||
result = await client.create_items(collection, parsed_data, fields=parsed_fields)
|
||||
items = result.get("data", [])
|
||||
return json.dumps(
|
||||
{
|
||||
"success": True,
|
||||
"message": f"Created {len(items)} items in {collection}",
|
||||
"items": items,
|
||||
},
|
||||
indent=2,
|
||||
ensure_ascii=False,
|
||||
)
|
||||
except Exception as e:
|
||||
return json.dumps({"success": False, "error": str(e)}, indent=2)
|
||||
|
||||
async def update_item(
|
||||
client: DirectusClient,
|
||||
collection: str,
|
||||
id: str,
|
||||
data: dict[str, Any],
|
||||
fields: list[str] | None = None,
|
||||
) -> str:
|
||||
"""Update an item."""
|
||||
try:
|
||||
# Parse JSON string parameters
|
||||
parsed_data = _parse_json_param(data, "data")
|
||||
parsed_fields = _parse_json_param(fields, "fields")
|
||||
result = await client.update_item(collection, id, parsed_data, fields=parsed_fields)
|
||||
return json.dumps(
|
||||
{"success": True, "message": f"Item {id} updated", "item": result.get("data")},
|
||||
indent=2,
|
||||
ensure_ascii=False,
|
||||
)
|
||||
except Exception as e:
|
||||
return json.dumps({"success": False, "error": str(e)}, indent=2)
|
||||
|
||||
async def update_items(
|
||||
client: DirectusClient,
|
||||
collection: str,
|
||||
keys: list[str],
|
||||
data: dict[str, Any],
|
||||
fields: list[str] | None = None,
|
||||
) -> str:
|
||||
"""Update multiple items."""
|
||||
try:
|
||||
# Parse JSON string parameters
|
||||
parsed_keys = _parse_json_param(keys, "keys")
|
||||
parsed_data = _parse_json_param(data, "data")
|
||||
parsed_fields = _parse_json_param(fields, "fields")
|
||||
result = await client.update_items(
|
||||
collection, parsed_keys, parsed_data, fields=parsed_fields
|
||||
)
|
||||
items = result.get("data", [])
|
||||
return json.dumps(
|
||||
{
|
||||
"success": True,
|
||||
"message": f"Updated {len(items)} items in {collection}",
|
||||
"items": items,
|
||||
},
|
||||
indent=2,
|
||||
ensure_ascii=False,
|
||||
)
|
||||
except Exception as e:
|
||||
return json.dumps({"success": False, "error": str(e)}, indent=2)
|
||||
|
||||
async def delete_item(client: DirectusClient, collection: str, id: str) -> str:
|
||||
"""Delete an item."""
|
||||
try:
|
||||
await client.delete_item(collection, id)
|
||||
return json.dumps(
|
||||
{"success": True, "message": f"Item {id} deleted from {collection}"}, indent=2
|
||||
)
|
||||
except Exception as e:
|
||||
return json.dumps({"success": False, "error": str(e)}, indent=2)
|
||||
|
||||
async def delete_items(client: DirectusClient, collection: str, keys: list[str]) -> str:
|
||||
"""Delete multiple items."""
|
||||
try:
|
||||
# Parse JSON string parameter
|
||||
parsed_keys = _parse_json_param(keys, "keys")
|
||||
await client.delete_items(collection, parsed_keys)
|
||||
return json.dumps(
|
||||
{"success": True, "message": f"Deleted {len(keys)} items from {collection}"}, indent=2
|
||||
)
|
||||
except Exception as e:
|
||||
return json.dumps({"success": False, "error": str(e)}, indent=2)
|
||||
|
||||
async def search_items(
|
||||
client: DirectusClient,
|
||||
collection: str,
|
||||
query: str,
|
||||
fields: list[str] | None = None,
|
||||
limit: int = 25,
|
||||
) -> str:
|
||||
"""Full-text search items."""
|
||||
try:
|
||||
# Parse JSON string parameter
|
||||
parsed_fields = _parse_json_param(fields, "fields")
|
||||
result = await client.list_items(
|
||||
collection=collection, fields=parsed_fields, search=query, limit=limit
|
||||
)
|
||||
items = result.get("data", [])
|
||||
return json.dumps(
|
||||
{"success": True, "query": query, "total": len(items), "items": items},
|
||||
indent=2,
|
||||
ensure_ascii=False,
|
||||
)
|
||||
except Exception as e:
|
||||
return json.dumps({"success": False, "error": str(e)}, indent=2)
|
||||
|
||||
async def aggregate_items(
|
||||
client: DirectusClient,
|
||||
collection: str,
|
||||
aggregate: dict[str, Any],
|
||||
filter: dict | None = None,
|
||||
groupBy: list[str] | None = None,
|
||||
) -> str:
|
||||
"""Aggregate items."""
|
||||
try:
|
||||
# Parse JSON string parameters
|
||||
parsed_aggregate = _parse_json_param(aggregate, "aggregate")
|
||||
parsed_filter = _parse_json_param(filter, "filter")
|
||||
_parse_json_param(groupBy, "groupBy")
|
||||
|
||||
result = await client.list_items(
|
||||
collection=collection, filter=parsed_filter, aggregate=parsed_aggregate
|
||||
)
|
||||
return json.dumps(
|
||||
{"success": True, "collection": collection, "result": result.get("data")},
|
||||
indent=2,
|
||||
ensure_ascii=False,
|
||||
)
|
||||
except Exception as e:
|
||||
return json.dumps({"success": False, "error": str(e)}, indent=2)
|
||||
|
||||
async def export_items(
|
||||
client: DirectusClient,
|
||||
collection: str,
|
||||
fields: list[str] | None = None,
|
||||
filter: dict | None = None,
|
||||
sort: list[str] | None = None,
|
||||
limit: int = 1000,
|
||||
) -> str:
|
||||
"""Export items from a collection."""
|
||||
try:
|
||||
# Parse JSON string parameters
|
||||
parsed_fields = _parse_json_param(fields, "fields")
|
||||
parsed_filter = _parse_json_param(filter, "filter")
|
||||
parsed_sort = _parse_json_param(sort, "sort")
|
||||
|
||||
result = await client.list_items(
|
||||
collection=collection,
|
||||
fields=parsed_fields,
|
||||
filter=parsed_filter,
|
||||
sort=parsed_sort,
|
||||
limit=limit,
|
||||
)
|
||||
items = result.get("data", [])
|
||||
return json.dumps(
|
||||
{
|
||||
"success": True,
|
||||
"collection": collection,
|
||||
"exported_count": len(items),
|
||||
"data": items,
|
||||
},
|
||||
indent=2,
|
||||
ensure_ascii=False,
|
||||
)
|
||||
except Exception as e:
|
||||
return json.dumps({"success": False, "error": str(e)}, indent=2)
|
||||
|
||||
async def import_items(client: DirectusClient, collection: str, data: list[dict[str, Any]]) -> str:
|
||||
"""Import items into a collection."""
|
||||
try:
|
||||
# Parse JSON string parameter
|
||||
parsed_data = _parse_json_param(data, "data")
|
||||
result = await client.create_items(collection, parsed_data)
|
||||
items = result.get("data", [])
|
||||
return json.dumps(
|
||||
{
|
||||
"success": True,
|
||||
"message": f"Imported {len(items)} items into {collection}",
|
||||
"imported_count": len(items),
|
||||
},
|
||||
indent=2,
|
||||
ensure_ascii=False,
|
||||
)
|
||||
except Exception as e:
|
||||
return json.dumps({"success": False, "error": str(e)}, indent=2)
|
||||
259
plugins/directus/handlers/system.py
Normal file
259
plugins/directus/handlers/system.py
Normal file
@@ -0,0 +1,259 @@
|
||||
"""
|
||||
System Handler - Settings, Server, Schema, Activity, Presets, Notifications
|
||||
|
||||
Phase J.4: 10 tools
|
||||
- Settings: get, update (2)
|
||||
- Server: info, health, graphql_sdl, openapi_spec (4)
|
||||
- Schema: snapshot, diff, apply (3)
|
||||
- Activity: list (1)
|
||||
"""
|
||||
|
||||
import json
|
||||
from typing import Any
|
||||
|
||||
from plugins.directus.client import DirectusClient
|
||||
|
||||
def get_tool_specifications() -> list[dict[str, Any]]:
|
||||
"""Return tool specifications for ToolGenerator (10 tools)"""
|
||||
return [
|
||||
# =====================
|
||||
# SETTINGS (2)
|
||||
# =====================
|
||||
{
|
||||
"name": "get_settings",
|
||||
"method_name": "get_settings",
|
||||
"description": "Get system settings.",
|
||||
"schema": {"type": "object", "properties": {}, "required": []},
|
||||
"scope": "read",
|
||||
},
|
||||
{
|
||||
"name": "update_settings",
|
||||
"method_name": "update_settings",
|
||||
"description": "Update system settings (project name, logo, colors, etc.).",
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {"data": {"type": "object", "description": "Settings to update"}},
|
||||
"required": ["data"],
|
||||
},
|
||||
"scope": "admin",
|
||||
},
|
||||
# =====================
|
||||
# SERVER (4)
|
||||
# =====================
|
||||
{
|
||||
"name": "get_server_info",
|
||||
"method_name": "get_server_info",
|
||||
"description": "Get Directus server information (version, extensions, etc.).",
|
||||
"schema": {"type": "object", "properties": {}, "required": []},
|
||||
"scope": "read",
|
||||
},
|
||||
{
|
||||
"name": "health_check",
|
||||
"method_name": "health_check",
|
||||
"description": "Check Directus server health status.",
|
||||
"schema": {"type": "object", "properties": {}, "required": []},
|
||||
"scope": "read",
|
||||
},
|
||||
{
|
||||
"name": "get_graphql_sdl",
|
||||
"method_name": "get_graphql_sdl",
|
||||
"description": "Get the GraphQL SDL schema.",
|
||||
"schema": {"type": "object", "properties": {}, "required": []},
|
||||
"scope": "read",
|
||||
},
|
||||
{
|
||||
"name": "get_openapi_spec",
|
||||
"method_name": "get_openapi_spec",
|
||||
"description": "Get the OpenAPI specification.",
|
||||
"schema": {"type": "object", "properties": {}, "required": []},
|
||||
"scope": "read",
|
||||
},
|
||||
# =====================
|
||||
# SCHEMA (3)
|
||||
# =====================
|
||||
{
|
||||
"name": "get_schema_snapshot",
|
||||
"method_name": "get_schema_snapshot",
|
||||
"description": "Get complete schema snapshot for migration/backup.",
|
||||
"schema": {"type": "object", "properties": {}, "required": []},
|
||||
"scope": "admin",
|
||||
},
|
||||
{
|
||||
"name": "schema_diff",
|
||||
"method_name": "schema_diff",
|
||||
"description": "Get diff between current schema and provided snapshot.",
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"snapshot": {
|
||||
"type": "object",
|
||||
"description": "Schema snapshot to compare against",
|
||||
}
|
||||
},
|
||||
"required": ["snapshot"],
|
||||
},
|
||||
"scope": "admin",
|
||||
},
|
||||
{
|
||||
"name": "schema_apply",
|
||||
"method_name": "schema_apply",
|
||||
"description": "Apply schema diff to database.",
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {"diff": {"type": "object", "description": "Schema diff to apply"}},
|
||||
"required": ["diff"],
|
||||
},
|
||||
"scope": "admin",
|
||||
},
|
||||
# =====================
|
||||
# ACTIVITY (1)
|
||||
# =====================
|
||||
{
|
||||
"name": "list_activity",
|
||||
"method_name": "list_activity",
|
||||
"description": "List activity log (all actions performed in Directus).",
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"filter": {
|
||||
"anyOf": [{"type": "object"}, {"type": "null"}],
|
||||
"description": 'Filter object (e.g., {"action": {"_eq": "create"}})',
|
||||
},
|
||||
"sort": {
|
||||
"anyOf": [{"type": "array", "items": {"type": "string"}}, {"type": "null"}],
|
||||
"description": "Sort fields (default: ['-timestamp'])",
|
||||
},
|
||||
"limit": {
|
||||
"type": "integer",
|
||||
"description": "Maximum activities to return",
|
||||
"default": 100,
|
||||
},
|
||||
},
|
||||
"required": [],
|
||||
},
|
||||
"scope": "read",
|
||||
},
|
||||
]
|
||||
|
||||
# =====================
|
||||
# HANDLER FUNCTIONS
|
||||
# =====================
|
||||
|
||||
async def get_settings(client: DirectusClient) -> str:
|
||||
"""Get system settings."""
|
||||
try:
|
||||
result = await client.get_settings()
|
||||
return json.dumps(
|
||||
{"success": True, "settings": result.get("data")}, indent=2, ensure_ascii=False
|
||||
)
|
||||
except Exception as e:
|
||||
return json.dumps({"success": False, "error": str(e)}, indent=2)
|
||||
|
||||
async def update_settings(client: DirectusClient, data: dict[str, Any]) -> str:
|
||||
"""Update system settings."""
|
||||
try:
|
||||
result = await client.update_settings(data)
|
||||
return json.dumps(
|
||||
{"success": True, "message": "Settings updated", "settings": result.get("data")},
|
||||
indent=2,
|
||||
ensure_ascii=False,
|
||||
)
|
||||
except Exception as e:
|
||||
return json.dumps({"success": False, "error": str(e)}, indent=2)
|
||||
|
||||
async def get_server_info(client: DirectusClient) -> str:
|
||||
"""Get server info."""
|
||||
try:
|
||||
result = await client.get_server_info()
|
||||
return json.dumps(
|
||||
{"success": True, "server": result.get("data")}, indent=2, ensure_ascii=False
|
||||
)
|
||||
except Exception as e:
|
||||
return json.dumps({"success": False, "error": str(e)}, indent=2)
|
||||
|
||||
async def health_check(client: DirectusClient) -> str:
|
||||
"""Check server health."""
|
||||
try:
|
||||
result = await client.health_check()
|
||||
return json.dumps(
|
||||
{"success": True, "status": result.get("status", "unknown"), "health": result},
|
||||
indent=2,
|
||||
ensure_ascii=False,
|
||||
)
|
||||
except Exception as e:
|
||||
return json.dumps({"success": False, "error": str(e)}, indent=2)
|
||||
|
||||
async def get_graphql_sdl(client: DirectusClient) -> str:
|
||||
"""Get GraphQL SDL."""
|
||||
try:
|
||||
result = await client.get_graphql_sdl()
|
||||
# GraphQL SDL is usually returned as text
|
||||
if isinstance(result, dict):
|
||||
sdl = result.get("data", result)
|
||||
else:
|
||||
sdl = result
|
||||
return json.dumps({"success": True, "sdl": sdl}, indent=2, ensure_ascii=False)
|
||||
except Exception as e:
|
||||
return json.dumps({"success": False, "error": str(e)}, indent=2)
|
||||
|
||||
async def get_openapi_spec(client: DirectusClient) -> str:
|
||||
"""Get OpenAPI specification."""
|
||||
try:
|
||||
result = await client.get_openapi_spec()
|
||||
return json.dumps({"success": True, "spec": result}, indent=2, ensure_ascii=False)
|
||||
except Exception as e:
|
||||
return json.dumps({"success": False, "error": str(e)}, indent=2)
|
||||
|
||||
async def get_schema_snapshot(client: DirectusClient) -> str:
|
||||
"""Get schema snapshot."""
|
||||
try:
|
||||
result = await client.get_schema_snapshot()
|
||||
return json.dumps(
|
||||
{"success": True, "snapshot": result.get("data")}, indent=2, ensure_ascii=False
|
||||
)
|
||||
except Exception as e:
|
||||
return json.dumps({"success": False, "error": str(e)}, indent=2)
|
||||
|
||||
async def schema_diff(client: DirectusClient, snapshot: dict[str, Any]) -> str:
|
||||
"""Get schema diff."""
|
||||
try:
|
||||
result = await client.schema_diff(snapshot)
|
||||
return json.dumps(
|
||||
{"success": True, "diff": result.get("data")}, indent=2, ensure_ascii=False
|
||||
)
|
||||
except Exception as e:
|
||||
return json.dumps({"success": False, "error": str(e)}, indent=2)
|
||||
|
||||
async def schema_apply(client: DirectusClient, diff: dict[str, Any]) -> str:
|
||||
"""Apply schema diff."""
|
||||
try:
|
||||
result = await client.schema_apply(diff)
|
||||
return json.dumps(
|
||||
{
|
||||
"success": True,
|
||||
"message": "Schema applied",
|
||||
"result": result.get("data") if result else None,
|
||||
},
|
||||
indent=2,
|
||||
ensure_ascii=False,
|
||||
)
|
||||
except Exception as e:
|
||||
return json.dumps({"success": False, "error": str(e)}, indent=2)
|
||||
|
||||
async def list_activity(
|
||||
client: DirectusClient,
|
||||
filter: dict | None = None,
|
||||
sort: list[str] | None = None,
|
||||
limit: int = 100,
|
||||
) -> str:
|
||||
"""List activity log."""
|
||||
try:
|
||||
result = await client.list_activity(filter=filter, sort=sort, limit=limit)
|
||||
activities = result.get("data", [])
|
||||
return json.dumps(
|
||||
{"success": True, "total": len(activities), "activities": activities},
|
||||
indent=2,
|
||||
ensure_ascii=False,
|
||||
)
|
||||
except Exception as e:
|
||||
return json.dumps({"success": False, "error": str(e)}, indent=2)
|
||||
357
plugins/directus/handlers/users.py
Normal file
357
plugins/directus/handlers/users.py
Normal file
@@ -0,0 +1,357 @@
|
||||
"""
|
||||
Users Handler - User management
|
||||
|
||||
Phase J.2: 10 tools
|
||||
- list_users, get_user, get_current_user
|
||||
- create_user, update_user, delete_user, delete_users
|
||||
- invite_user, accept_invite, update_current_user
|
||||
"""
|
||||
|
||||
import json
|
||||
from typing import Any
|
||||
|
||||
from plugins.directus.client import DirectusClient
|
||||
|
||||
def get_tool_specifications() -> list[dict[str, Any]]:
|
||||
"""Return tool specifications for ToolGenerator (10 tools)"""
|
||||
return [
|
||||
{
|
||||
"name": "list_users",
|
||||
"method_name": "list_users",
|
||||
"description": "List all users in Directus with filtering options.",
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"filter": {
|
||||
"anyOf": [{"type": "object"}, {"type": "null"}],
|
||||
"description": 'Filter object (e.g., {"status": {"_eq": "active"}})',
|
||||
},
|
||||
"sort": {
|
||||
"anyOf": [{"type": "array", "items": {"type": "string"}}, {"type": "null"}],
|
||||
"description": "Sort fields (e.g., ['email'])",
|
||||
},
|
||||
"limit": {
|
||||
"type": "integer",
|
||||
"description": "Maximum users to return",
|
||||
"default": 100,
|
||||
},
|
||||
"offset": {"type": "integer", "description": "Users to skip", "default": 0},
|
||||
"search": {
|
||||
"anyOf": [{"type": "string"}, {"type": "null"}],
|
||||
"description": "Search term",
|
||||
},
|
||||
},
|
||||
"required": [],
|
||||
},
|
||||
"scope": "read",
|
||||
},
|
||||
{
|
||||
"name": "get_user",
|
||||
"method_name": "get_user",
|
||||
"description": "Get user details by ID.",
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {"id": {"type": "string", "description": "User UUID"}},
|
||||
"required": ["id"],
|
||||
},
|
||||
"scope": "read",
|
||||
},
|
||||
{
|
||||
"name": "get_current_user",
|
||||
"method_name": "get_current_user",
|
||||
"description": "Get the currently authenticated user.",
|
||||
"schema": {"type": "object", "properties": {}, "required": []},
|
||||
"scope": "read",
|
||||
},
|
||||
{
|
||||
"name": "create_user",
|
||||
"method_name": "create_user",
|
||||
"description": "Create a new user.",
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"email": {
|
||||
"type": "string",
|
||||
"format": "email",
|
||||
"description": "User email address",
|
||||
},
|
||||
"password": {"type": "string", "minLength": 8, "description": "User password"},
|
||||
"role": {"type": "string", "description": "Role UUID"},
|
||||
"first_name": {
|
||||
"anyOf": [{"type": "string"}, {"type": "null"}],
|
||||
"description": "First name",
|
||||
},
|
||||
"last_name": {
|
||||
"anyOf": [{"type": "string"}, {"type": "null"}],
|
||||
"description": "Last name",
|
||||
},
|
||||
"status": {
|
||||
"type": "string",
|
||||
"enum": ["draft", "invited", "active", "suspended", "archived"],
|
||||
"default": "active",
|
||||
"description": "User status",
|
||||
},
|
||||
},
|
||||
"required": ["email", "password", "role"],
|
||||
},
|
||||
"scope": "admin",
|
||||
},
|
||||
{
|
||||
"name": "update_user",
|
||||
"method_name": "update_user",
|
||||
"description": "Update user details.",
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"id": {"type": "string", "description": "User UUID"},
|
||||
"data": {
|
||||
"type": "object",
|
||||
"description": "Fields to update (email, first_name, last_name, role, status, etc.)",
|
||||
},
|
||||
},
|
||||
"required": ["id", "data"],
|
||||
},
|
||||
"scope": "admin",
|
||||
},
|
||||
{
|
||||
"name": "delete_user",
|
||||
"method_name": "delete_user",
|
||||
"description": "Delete a user. This action is irreversible.",
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {"id": {"type": "string", "description": "User UUID to delete"}},
|
||||
"required": ["id"],
|
||||
},
|
||||
"scope": "admin",
|
||||
},
|
||||
{
|
||||
"name": "delete_users",
|
||||
"method_name": "delete_users",
|
||||
"description": "Delete multiple users. This action is irreversible.",
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"ids": {
|
||||
"type": "array",
|
||||
"items": {"type": "string"},
|
||||
"description": "Array of user UUIDs to delete",
|
||||
}
|
||||
},
|
||||
"required": ["ids"],
|
||||
},
|
||||
"scope": "admin",
|
||||
},
|
||||
{
|
||||
"name": "invite_user",
|
||||
"method_name": "invite_user",
|
||||
"description": "Invite a user by email.",
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"email": {
|
||||
"type": "string",
|
||||
"format": "email",
|
||||
"description": "Email to invite",
|
||||
},
|
||||
"role": {"type": "string", "description": "Role UUID for the invited user"},
|
||||
"invite_url": {
|
||||
"anyOf": [{"type": "string"}, {"type": "null"}],
|
||||
"description": "Custom invite URL",
|
||||
},
|
||||
},
|
||||
"required": ["email", "role"],
|
||||
},
|
||||
"scope": "admin",
|
||||
},
|
||||
{
|
||||
"name": "update_current_user",
|
||||
"method_name": "update_current_user",
|
||||
"description": "Update the currently authenticated user's profile.",
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"data": {
|
||||
"type": "object",
|
||||
"description": "Fields to update (first_name, last_name, language, theme, etc.)",
|
||||
}
|
||||
},
|
||||
"required": ["data"],
|
||||
},
|
||||
"scope": "write",
|
||||
},
|
||||
{
|
||||
"name": "get_user_role",
|
||||
"method_name": "get_user_role",
|
||||
"description": "Get the role of a specific user.",
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {"id": {"type": "string", "description": "User UUID"}},
|
||||
"required": ["id"],
|
||||
},
|
||||
"scope": "read",
|
||||
},
|
||||
]
|
||||
|
||||
# =====================
|
||||
# HANDLER FUNCTIONS
|
||||
# =====================
|
||||
|
||||
async def list_users(
|
||||
client: DirectusClient,
|
||||
filter: dict | None = None,
|
||||
sort: list[str] | None = None,
|
||||
limit: int = 100,
|
||||
offset: int = 0,
|
||||
search: str | None = None,
|
||||
) -> str:
|
||||
"""List users."""
|
||||
try:
|
||||
result = await client.list_users(
|
||||
filter=filter, sort=sort, limit=limit, offset=offset, search=search
|
||||
)
|
||||
users = result.get("data", [])
|
||||
return json.dumps(
|
||||
{"success": True, "total": len(users), "users": users}, indent=2, ensure_ascii=False
|
||||
)
|
||||
except Exception as e:
|
||||
return json.dumps({"success": False, "error": str(e)}, indent=2)
|
||||
|
||||
async def get_user(client: DirectusClient, id: str) -> str:
|
||||
"""Get user by ID."""
|
||||
try:
|
||||
result = await client.get_user(id)
|
||||
return json.dumps(
|
||||
{"success": True, "user": result.get("data")}, indent=2, ensure_ascii=False
|
||||
)
|
||||
except Exception as e:
|
||||
return json.dumps({"success": False, "error": str(e)}, indent=2)
|
||||
|
||||
async def get_current_user(client: DirectusClient) -> str:
|
||||
"""Get current user."""
|
||||
try:
|
||||
result = await client.get_current_user()
|
||||
return json.dumps(
|
||||
{"success": True, "user": result.get("data")}, indent=2, ensure_ascii=False
|
||||
)
|
||||
except Exception as e:
|
||||
return json.dumps({"success": False, "error": str(e)}, indent=2)
|
||||
|
||||
async def create_user(
|
||||
client: DirectusClient,
|
||||
email: str,
|
||||
password: str,
|
||||
role: str,
|
||||
first_name: str | None = None,
|
||||
last_name: str | None = None,
|
||||
status: str = "active",
|
||||
) -> str:
|
||||
"""Create a new user."""
|
||||
try:
|
||||
result = await client.create_user(
|
||||
email=email,
|
||||
password=password,
|
||||
role=role,
|
||||
first_name=first_name,
|
||||
last_name=last_name,
|
||||
status=status,
|
||||
)
|
||||
return json.dumps(
|
||||
{"success": True, "message": f"User {email} created", "user": result.get("data")},
|
||||
indent=2,
|
||||
ensure_ascii=False,
|
||||
)
|
||||
except Exception as e:
|
||||
return json.dumps({"success": False, "error": str(e)}, indent=2)
|
||||
|
||||
async def update_user(client: DirectusClient, id: str, data: dict[str, Any]) -> str:
|
||||
"""Update user."""
|
||||
try:
|
||||
result = await client.update_user(id, data)
|
||||
return json.dumps(
|
||||
{"success": True, "message": "User updated", "user": result.get("data")},
|
||||
indent=2,
|
||||
ensure_ascii=False,
|
||||
)
|
||||
except Exception as e:
|
||||
return json.dumps({"success": False, "error": str(e)}, indent=2)
|
||||
|
||||
async def delete_user(client: DirectusClient, id: str) -> str:
|
||||
"""Delete a user."""
|
||||
try:
|
||||
await client.delete_user(id)
|
||||
return json.dumps({"success": True, "message": f"User {id} deleted"}, indent=2)
|
||||
except Exception as e:
|
||||
return json.dumps({"success": False, "error": str(e)}, indent=2)
|
||||
|
||||
async def delete_users(client: DirectusClient, ids: list[str]) -> str:
|
||||
"""Delete multiple users."""
|
||||
try:
|
||||
await client.delete_users(ids)
|
||||
return json.dumps({"success": True, "message": f"Deleted {len(ids)} users"}, indent=2)
|
||||
except Exception as e:
|
||||
return json.dumps({"success": False, "error": str(e)}, indent=2)
|
||||
|
||||
async def invite_user(
|
||||
client: DirectusClient, email: str, role: str, invite_url: str | None = None
|
||||
) -> str:
|
||||
"""Invite a user."""
|
||||
try:
|
||||
result = await client.invite_user(email, role, invite_url)
|
||||
return json.dumps(
|
||||
{
|
||||
"success": True,
|
||||
"message": f"Invitation sent to {email}",
|
||||
"result": result.get("data"),
|
||||
},
|
||||
indent=2,
|
||||
ensure_ascii=False,
|
||||
)
|
||||
except Exception as e:
|
||||
return json.dumps({"success": False, "error": str(e)}, indent=2)
|
||||
|
||||
async def update_current_user(client: DirectusClient, data: dict[str, Any]) -> str:
|
||||
"""Update current user profile."""
|
||||
try:
|
||||
# Get current user first to get ID
|
||||
current = await client.get_current_user()
|
||||
user_id = current.get("data", {}).get("id")
|
||||
if not user_id:
|
||||
return json.dumps(
|
||||
{"success": False, "error": "Cannot determine current user"}, indent=2
|
||||
)
|
||||
|
||||
result = await client.update_user(user_id, data)
|
||||
return json.dumps(
|
||||
{"success": True, "message": "Profile updated", "user": result.get("data")},
|
||||
indent=2,
|
||||
ensure_ascii=False,
|
||||
)
|
||||
except Exception as e:
|
||||
return json.dumps({"success": False, "error": str(e)}, indent=2)
|
||||
|
||||
async def get_user_role(client: DirectusClient, id: str) -> str:
|
||||
"""Get user's role."""
|
||||
try:
|
||||
result = await client.get_user(id)
|
||||
user = result.get("data", {})
|
||||
role_id = user.get("role")
|
||||
|
||||
if role_id:
|
||||
role_result = await client.get_role(role_id)
|
||||
return json.dumps(
|
||||
{"success": True, "user_id": id, "role": role_result.get("data")},
|
||||
indent=2,
|
||||
ensure_ascii=False,
|
||||
)
|
||||
else:
|
||||
return json.dumps(
|
||||
{
|
||||
"success": True,
|
||||
"user_id": id,
|
||||
"role": None,
|
||||
"message": "User has no role assigned",
|
||||
},
|
||||
indent=2,
|
||||
)
|
||||
except Exception as e:
|
||||
return json.dumps({"success": False, "error": str(e)}, indent=2)
|
||||
166
plugins/directus/plugin.py
Normal file
166
plugins/directus/plugin.py
Normal file
@@ -0,0 +1,166 @@
|
||||
"""
|
||||
Directus Plugin - Self-Hosted Headless CMS Management
|
||||
|
||||
Complete Directus Self-Hosted management through REST APIs.
|
||||
Provides tools for Items, Collections, Fields, Files, Users,
|
||||
Roles, Permissions, Flows, Versions, Dashboards, and System.
|
||||
|
||||
For Self-Hosted instances deployed on Coolify.
|
||||
"""
|
||||
|
||||
from typing import Any
|
||||
|
||||
from plugins.base import BasePlugin
|
||||
from plugins.directus import handlers
|
||||
from plugins.directus.client import DirectusClient
|
||||
|
||||
class DirectusPlugin(BasePlugin):
|
||||
"""
|
||||
Directus Self-Hosted Plugin - Complete CMS management.
|
||||
|
||||
Provides comprehensive Directus management capabilities including:
|
||||
- Items operations (CRUD for any collection)
|
||||
- Collections & Fields (schema management)
|
||||
- Files & Folders (asset management)
|
||||
- Users management (CRUD, invite)
|
||||
- Access Control (Roles, Permissions, Policies)
|
||||
- Automation (Flows, Operations, Webhooks)
|
||||
- Content Management (Revisions, Versions, Comments)
|
||||
- Dashboards (Dashboards, Panels)
|
||||
- System operations (Settings, Server, Schema, Activity)
|
||||
|
||||
Phase J.1: Items (12) + Collections (14) = 26 tools
|
||||
Phase J.2: Files (12) + Users (10) = 22 tools
|
||||
Phase J.3: Access (12) + Automation (12) = 24 tools
|
||||
Phase J.4: Content (10) + Dashboards (8) + System (10) = 28 tools
|
||||
|
||||
Total: 100 tools - Complete!
|
||||
"""
|
||||
|
||||
@staticmethod
|
||||
def get_plugin_name() -> str:
|
||||
"""Return plugin type identifier"""
|
||||
return "directus"
|
||||
|
||||
@staticmethod
|
||||
def get_required_config_keys() -> list[str]:
|
||||
"""Return required configuration keys"""
|
||||
return ["url", "token"]
|
||||
|
||||
def __init__(self, config: dict[str, Any], project_id: str | None = None):
|
||||
"""
|
||||
Initialize Directus plugin with client.
|
||||
|
||||
Args:
|
||||
config: Configuration dictionary containing:
|
||||
- url: Directus instance URL (e.g., https://directus.example.com)
|
||||
- token: Static admin token
|
||||
project_id: Optional MCP project ID (auto-generated if not provided)
|
||||
"""
|
||||
super().__init__(config, project_id=project_id)
|
||||
|
||||
# Create Directus API client
|
||||
self.client = DirectusClient(base_url=config["url"], token=config["token"])
|
||||
|
||||
@staticmethod
|
||||
def get_tool_specifications() -> list[dict[str, Any]]:
|
||||
"""
|
||||
Return all tool specifications for ToolGenerator.
|
||||
|
||||
This method is called by ToolGenerator to create unified tools
|
||||
with site parameter routing.
|
||||
|
||||
Returns:
|
||||
List of tool specification dictionaries
|
||||
"""
|
||||
specs = []
|
||||
|
||||
# Phase J.1: Core (26 tools)
|
||||
specs.extend(handlers.items.get_tool_specifications()) # 12 tools
|
||||
specs.extend(handlers.collections.get_tool_specifications()) # 14 tools
|
||||
|
||||
# Phase J.2: Assets & Users (22 tools)
|
||||
specs.extend(handlers.files.get_tool_specifications()) # 12 tools
|
||||
specs.extend(handlers.users.get_tool_specifications()) # 10 tools
|
||||
|
||||
# Phase J.3: Access & Automation (24 tools)
|
||||
specs.extend(handlers.access.get_tool_specifications()) # 12 tools
|
||||
specs.extend(handlers.automation.get_tool_specifications()) # 12 tools
|
||||
|
||||
# Phase J.4: Advanced (28 tools)
|
||||
specs.extend(handlers.content.get_tool_specifications()) # 10 tools
|
||||
specs.extend(handlers.dashboards.get_tool_specifications()) # 8 tools
|
||||
specs.extend(handlers.system.get_tool_specifications()) # 10 tools
|
||||
|
||||
return specs
|
||||
|
||||
def __getattr__(self, name: str):
|
||||
"""
|
||||
Dynamically delegate method calls to appropriate handlers.
|
||||
|
||||
This allows ToolGenerator to call methods like plugin.list_items()
|
||||
without explicitly defining each method.
|
||||
|
||||
Args:
|
||||
name: Method name being called
|
||||
|
||||
Returns:
|
||||
Handler function from the appropriate handler module
|
||||
"""
|
||||
# Try to find the method in handler modules
|
||||
handler_modules = [
|
||||
# Phase J.1: Core
|
||||
handlers.items,
|
||||
handlers.collections,
|
||||
# Phase J.2: Assets & Users
|
||||
handlers.files,
|
||||
handlers.users,
|
||||
# Phase J.3: Access & Automation
|
||||
handlers.access,
|
||||
handlers.automation,
|
||||
# Phase J.4: Advanced
|
||||
handlers.content,
|
||||
handlers.dashboards,
|
||||
handlers.system,
|
||||
]
|
||||
|
||||
for handler_module in handler_modules:
|
||||
if hasattr(handler_module, name):
|
||||
func = getattr(handler_module, name)
|
||||
|
||||
# Create wrapper that passes self.client
|
||||
async def wrapper(_func=func, **kwargs):
|
||||
return await _func(self.client, **kwargs)
|
||||
|
||||
return wrapper
|
||||
|
||||
# Method not found in any handler
|
||||
raise AttributeError(f"'{self.__class__.__name__}' object has no attribute '{name}'")
|
||||
|
||||
async def check_health(self) -> dict[str, Any]:
|
||||
"""
|
||||
Check if Directus instance is accessible (internal use).
|
||||
|
||||
Note: This is named check_health to avoid shadowing the handler's
|
||||
health_check function which is exposed as an MCP tool.
|
||||
|
||||
Returns:
|
||||
Dict containing health check result
|
||||
"""
|
||||
try:
|
||||
result = await self.client.health_check()
|
||||
is_healthy = result.get("status") == "ok"
|
||||
return {
|
||||
"healthy": is_healthy,
|
||||
"message": f"Directus instance at {self.client.base_url} is {'accessible' if is_healthy else 'not accessible'}",
|
||||
}
|
||||
except Exception as e:
|
||||
return {"healthy": False, "message": f"Directus health check failed: {str(e)}"}
|
||||
|
||||
async def health_check(self, **kwargs) -> str:
|
||||
"""
|
||||
Override BasePlugin.health_check to use handler function.
|
||||
|
||||
This ensures the MCP tool returns a JSON string, not a Dict.
|
||||
"""
|
||||
return await handlers.system.health_check(self.client)
|
||||
9
plugins/gitea/__init__.py
Normal file
9
plugins/gitea/__init__.py
Normal file
@@ -0,0 +1,9 @@
|
||||
"""
|
||||
Gitea Plugin
|
||||
|
||||
Complete Gitea management through REST API.
|
||||
"""
|
||||
|
||||
from .plugin import GiteaPlugin
|
||||
|
||||
__all__ = ["GiteaPlugin"]
|
||||
451
plugins/gitea/client.py
Normal file
451
plugins/gitea/client.py
Normal file
@@ -0,0 +1,451 @@
|
||||
"""
|
||||
Gitea REST API Client
|
||||
|
||||
Handles all HTTP communication with Gitea REST API.
|
||||
Separates API communication from business logic.
|
||||
"""
|
||||
|
||||
import base64
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
import aiohttp
|
||||
|
||||
class GiteaClient:
|
||||
"""
|
||||
Gitea REST API client for HTTP communication.
|
||||
|
||||
Handles authentication, request formatting, and error handling
|
||||
for all Gitea API endpoints.
|
||||
"""
|
||||
|
||||
def __init__(self, site_url: str, token: str | None = None, oauth_enabled: bool = False):
|
||||
"""
|
||||
Initialize Gitea API client.
|
||||
|
||||
Args:
|
||||
site_url: Gitea instance URL (e.g., https://gitea.example.com)
|
||||
token: Personal access token for authentication
|
||||
oauth_enabled: Whether OAuth is enabled for this site
|
||||
"""
|
||||
self.site_url = site_url.rstrip("/")
|
||||
self.api_base = f"{self.site_url}/api/v1"
|
||||
self.token = token
|
||||
self.oauth_enabled = oauth_enabled
|
||||
|
||||
# Initialize logger
|
||||
self.logger = logging.getLogger(f"GiteaClient.{site_url}")
|
||||
|
||||
def _get_headers(self, additional_headers: dict | None = None) -> dict[str, str]:
|
||||
"""
|
||||
Get request headers with authentication.
|
||||
|
||||
Args:
|
||||
additional_headers: Additional headers to include
|
||||
|
||||
Returns:
|
||||
Dict: Headers with authentication
|
||||
"""
|
||||
headers = {"Content-Type": "application/json", "accept": "application/json"}
|
||||
|
||||
# Add token authentication if available
|
||||
if self.token:
|
||||
headers["Authorization"] = f"token {self.token}"
|
||||
|
||||
# Merge additional headers
|
||||
if additional_headers:
|
||||
headers.update(additional_headers)
|
||||
|
||||
return headers
|
||||
|
||||
async def request(
|
||||
self,
|
||||
method: str,
|
||||
endpoint: str,
|
||||
params: dict | None = None,
|
||||
json_data: dict | None = None,
|
||||
headers_override: dict | None = None,
|
||||
) -> Any:
|
||||
"""
|
||||
Make authenticated request to Gitea REST API.
|
||||
|
||||
Args:
|
||||
method: HTTP method (GET, POST, PUT, DELETE, PATCH)
|
||||
endpoint: API endpoint (without base URL)
|
||||
params: Query parameters
|
||||
json_data: JSON body data
|
||||
headers_override: Override default headers
|
||||
|
||||
Returns:
|
||||
API response (dict, list, or None)
|
||||
|
||||
Raises:
|
||||
Exception: On API errors with status code and message
|
||||
"""
|
||||
# Build full URL
|
||||
url = f"{self.api_base}/{endpoint.lstrip('/')}"
|
||||
|
||||
# Setup headers
|
||||
headers = self._get_headers(headers_override)
|
||||
|
||||
# Filter out None values from params
|
||||
if params:
|
||||
params = {k: v for k, v in params.items() if v is not None}
|
||||
|
||||
# Filter None values from JSON data
|
||||
if json_data:
|
||||
json_data = {k: v for k, v in json_data.items() if v is not None}
|
||||
|
||||
# Make request
|
||||
self.logger.debug(f"{method} {url}")
|
||||
self.logger.debug(f"Params: {params}")
|
||||
self.logger.debug(f"Data: {json_data}")
|
||||
|
||||
async with (
|
||||
aiohttp.ClientSession() as session,
|
||||
session.request(
|
||||
method=method, url=url, params=params, json=json_data, headers=headers
|
||||
) as response,
|
||||
):
|
||||
# Log response
|
||||
self.logger.debug(f"Response status: {response.status}")
|
||||
|
||||
# Handle empty responses (e.g., 204 No Content)
|
||||
if response.status == 204:
|
||||
return {"success": True, "message": "Operation completed successfully"}
|
||||
|
||||
# Try to parse JSON response
|
||||
try:
|
||||
response_data = await response.json()
|
||||
except Exception:
|
||||
response_text = await response.text()
|
||||
if response.status >= 400:
|
||||
raise Exception(f"Gitea API error (status {response.status}): {response_text}")
|
||||
return {"success": True, "message": response_text}
|
||||
|
||||
# Check for errors
|
||||
if response.status >= 400:
|
||||
error_msg = response_data.get("message", "Unknown error")
|
||||
raise Exception(f"Gitea API error (status {response.status}): {error_msg}")
|
||||
|
||||
return response_data
|
||||
|
||||
# Repository endpoints
|
||||
async def list_repositories(
|
||||
self, owner: str | None = None, page: int = 1, limit: int = 30
|
||||
) -> list[dict]:
|
||||
"""List repositories for a user/org or current user"""
|
||||
if owner:
|
||||
endpoint = f"users/{owner}/repos"
|
||||
else:
|
||||
endpoint = "user/repos"
|
||||
|
||||
params = {"page": page, "limit": limit}
|
||||
return await self.request("GET", endpoint, params=params)
|
||||
|
||||
async def get_repository(self, owner: str, repo: str) -> dict:
|
||||
"""Get repository details"""
|
||||
return await self.request("GET", f"repos/{owner}/{repo}")
|
||||
|
||||
async def create_repository(self, data: dict, org: str | None = None) -> dict:
|
||||
"""Create a new repository"""
|
||||
if org:
|
||||
endpoint = f"orgs/{org}/repos"
|
||||
else:
|
||||
endpoint = "user/repos"
|
||||
return await self.request("POST", endpoint, json_data=data)
|
||||
|
||||
async def update_repository(self, owner: str, repo: str, data: dict) -> dict:
|
||||
"""Update repository settings"""
|
||||
return await self.request("PATCH", f"repos/{owner}/{repo}", json_data=data)
|
||||
|
||||
async def delete_repository(self, owner: str, repo: str) -> dict:
|
||||
"""Delete a repository"""
|
||||
return await self.request("DELETE", f"repos/{owner}/{repo}")
|
||||
|
||||
# Branch endpoints
|
||||
async def list_branches(
|
||||
self, owner: str, repo: str, page: int = 1, limit: int = 30
|
||||
) -> list[dict]:
|
||||
"""List repository branches"""
|
||||
params = {"page": page, "limit": limit}
|
||||
return await self.request("GET", f"repos/{owner}/{repo}/branches", params=params)
|
||||
|
||||
async def get_branch(self, owner: str, repo: str, branch: str) -> dict:
|
||||
"""Get branch details"""
|
||||
return await self.request("GET", f"repos/{owner}/{repo}/branches/{branch}")
|
||||
|
||||
async def create_branch(self, owner: str, repo: str, data: dict) -> dict:
|
||||
"""Create a new branch"""
|
||||
return await self.request("POST", f"repos/{owner}/{repo}/branches", json_data=data)
|
||||
|
||||
async def delete_branch(self, owner: str, repo: str, branch: str) -> dict:
|
||||
"""Delete a branch"""
|
||||
return await self.request("DELETE", f"repos/{owner}/{repo}/branches/{branch}")
|
||||
|
||||
# Tag endpoints
|
||||
async def list_tags(self, owner: str, repo: str, page: int = 1, limit: int = 30) -> list[dict]:
|
||||
"""List repository tags"""
|
||||
params = {"page": page, "limit": limit}
|
||||
return await self.request("GET", f"repos/{owner}/{repo}/tags", params=params)
|
||||
|
||||
async def create_tag(self, owner: str, repo: str, data: dict) -> dict:
|
||||
"""Create a new tag"""
|
||||
return await self.request("POST", f"repos/{owner}/{repo}/tags", json_data=data)
|
||||
|
||||
async def delete_tag(self, owner: str, repo: str, tag: str) -> dict:
|
||||
"""Delete a tag"""
|
||||
return await self.request("DELETE", f"repos/{owner}/{repo}/tags/{tag}")
|
||||
|
||||
# File endpoints
|
||||
async def get_file(self, owner: str, repo: str, filepath: str, ref: str | None = None) -> dict:
|
||||
"""Get file contents"""
|
||||
params = {"ref": ref} if ref else {}
|
||||
return await self.request("GET", f"repos/{owner}/{repo}/contents/{filepath}", params=params)
|
||||
|
||||
async def create_file(self, owner: str, repo: str, filepath: str, data: dict) -> dict:
|
||||
"""Create a file"""
|
||||
# Encode content to base64 if not already encoded
|
||||
if "content" in data:
|
||||
content = data["content"]
|
||||
|
||||
# Check if content is already base64 encoded
|
||||
is_already_base64 = data.get("content_is_base64", False)
|
||||
|
||||
if not is_already_base64:
|
||||
# Plain text content - encode to base64
|
||||
try:
|
||||
data["content"] = base64.b64encode(content.encode()).decode()
|
||||
except (AttributeError, UnicodeDecodeError):
|
||||
# If content is already bytes or has encoding issues, try direct encoding
|
||||
if isinstance(content, bytes):
|
||||
data["content"] = base64.b64encode(content).decode()
|
||||
else:
|
||||
raise ValueError("Content must be a string or bytes")
|
||||
|
||||
# Remove the flag before sending to API
|
||||
data.pop("content_is_base64", None)
|
||||
|
||||
return await self.request(
|
||||
"POST", f"repos/{owner}/{repo}/contents/{filepath}", json_data=data
|
||||
)
|
||||
|
||||
async def update_file(self, owner: str, repo: str, filepath: str, data: dict) -> dict:
|
||||
"""Update a file"""
|
||||
# Encode content to base64 if not already encoded
|
||||
if "content" in data:
|
||||
content = data["content"]
|
||||
|
||||
# Check if content is already base64 encoded
|
||||
is_already_base64 = data.get("content_is_base64", False)
|
||||
|
||||
if not is_already_base64:
|
||||
# Plain text content - encode to base64
|
||||
try:
|
||||
data["content"] = base64.b64encode(content.encode()).decode()
|
||||
except (AttributeError, UnicodeDecodeError):
|
||||
# If content is already bytes or has encoding issues, try direct encoding
|
||||
if isinstance(content, bytes):
|
||||
data["content"] = base64.b64encode(content).decode()
|
||||
else:
|
||||
raise ValueError("Content must be a string or bytes")
|
||||
|
||||
# Remove the flag before sending to API
|
||||
data.pop("content_is_base64", None)
|
||||
|
||||
return await self.request(
|
||||
"PUT", f"repos/{owner}/{repo}/contents/{filepath}", json_data=data
|
||||
)
|
||||
|
||||
async def delete_file(
|
||||
self,
|
||||
owner: str,
|
||||
repo: str,
|
||||
filepath: str,
|
||||
sha: str,
|
||||
message: str,
|
||||
branch: str | None = None,
|
||||
) -> dict:
|
||||
"""Delete a file"""
|
||||
data = {"sha": sha, "message": message}
|
||||
if branch:
|
||||
data["branch"] = branch
|
||||
return await self.request(
|
||||
"DELETE", f"repos/{owner}/{repo}/contents/{filepath}", json_data=data
|
||||
)
|
||||
|
||||
# Issue endpoints
|
||||
async def list_issues(self, owner: str, repo: str, params: dict) -> list[dict]:
|
||||
"""List repository issues"""
|
||||
return await self.request("GET", f"repos/{owner}/{repo}/issues", params=params)
|
||||
|
||||
async def get_issue(self, owner: str, repo: str, index: int) -> dict:
|
||||
"""Get issue details"""
|
||||
return await self.request("GET", f"repos/{owner}/{repo}/issues/{index}")
|
||||
|
||||
async def create_issue(self, owner: str, repo: str, data: dict) -> dict:
|
||||
"""Create a new issue"""
|
||||
return await self.request("POST", f"repos/{owner}/{repo}/issues", json_data=data)
|
||||
|
||||
async def update_issue(self, owner: str, repo: str, index: int, data: dict) -> dict:
|
||||
"""Update an issue"""
|
||||
return await self.request("PATCH", f"repos/{owner}/{repo}/issues/{index}", json_data=data)
|
||||
|
||||
async def list_issue_comments(self, owner: str, repo: str, index: int) -> list[dict]:
|
||||
"""List issue comments"""
|
||||
return await self.request("GET", f"repos/{owner}/{repo}/issues/{index}/comments")
|
||||
|
||||
async def create_issue_comment(self, owner: str, repo: str, index: int, data: dict) -> dict:
|
||||
"""Create issue comment"""
|
||||
return await self.request(
|
||||
"POST", f"repos/{owner}/{repo}/issues/{index}/comments", json_data=data
|
||||
)
|
||||
|
||||
# Label endpoints
|
||||
async def list_labels(self, owner: str, repo: str) -> list[dict]:
|
||||
"""List repository labels"""
|
||||
return await self.request("GET", f"repos/{owner}/{repo}/labels")
|
||||
|
||||
async def create_label(self, owner: str, repo: str, data: dict) -> dict:
|
||||
"""Create a label"""
|
||||
return await self.request("POST", f"repos/{owner}/{repo}/labels", json_data=data)
|
||||
|
||||
async def delete_label(self, owner: str, repo: str, label_id: int) -> dict:
|
||||
"""Delete a label"""
|
||||
return await self.request("DELETE", f"repos/{owner}/{repo}/labels/{label_id}")
|
||||
|
||||
# Milestone endpoints
|
||||
async def list_milestones(self, owner: str, repo: str, state: str | None = None) -> list[dict]:
|
||||
"""List repository milestones"""
|
||||
params = {"state": state} if state else {}
|
||||
return await self.request("GET", f"repos/{owner}/{repo}/milestones", params=params)
|
||||
|
||||
async def create_milestone(self, owner: str, repo: str, data: dict) -> dict:
|
||||
"""Create a milestone"""
|
||||
return await self.request("POST", f"repos/{owner}/{repo}/milestones", json_data=data)
|
||||
|
||||
# Pull Request endpoints
|
||||
async def list_pull_requests(self, owner: str, repo: str, params: dict) -> list[dict]:
|
||||
"""List repository pull requests"""
|
||||
return await self.request("GET", f"repos/{owner}/{repo}/pulls", params=params)
|
||||
|
||||
async def get_pull_request(self, owner: str, repo: str, index: int) -> dict:
|
||||
"""Get pull request details"""
|
||||
return await self.request("GET", f"repos/{owner}/{repo}/pulls/{index}")
|
||||
|
||||
async def create_pull_request(self, owner: str, repo: str, data: dict) -> dict:
|
||||
"""Create a new pull request"""
|
||||
return await self.request("POST", f"repos/{owner}/{repo}/pulls", json_data=data)
|
||||
|
||||
async def update_pull_request(self, owner: str, repo: str, index: int, data: dict) -> dict:
|
||||
"""Update a pull request"""
|
||||
return await self.request("PATCH", f"repos/{owner}/{repo}/pulls/{index}", json_data=data)
|
||||
|
||||
async def merge_pull_request(self, owner: str, repo: str, index: int, data: dict) -> dict:
|
||||
"""Merge a pull request"""
|
||||
return await self.request(
|
||||
"POST", f"repos/{owner}/{repo}/pulls/{index}/merge", json_data=data
|
||||
)
|
||||
|
||||
async def list_pr_commits(self, owner: str, repo: str, index: int) -> list[dict]:
|
||||
"""List pull request commits"""
|
||||
return await self.request("GET", f"repos/{owner}/{repo}/pulls/{index}/commits")
|
||||
|
||||
async def list_pr_files(self, owner: str, repo: str, index: int) -> list[dict]:
|
||||
"""List pull request files"""
|
||||
return await self.request("GET", f"repos/{owner}/{repo}/pulls/{index}/files")
|
||||
|
||||
async def get_pr_diff(self, owner: str, repo: str, index: int) -> str:
|
||||
"""Get pull request diff"""
|
||||
# Override accept header for diff
|
||||
headers = {"accept": "text/plain"}
|
||||
response = await self.request(
|
||||
"GET", f"repos/{owner}/{repo}/pulls/{index}.diff", headers_override=headers
|
||||
)
|
||||
return response
|
||||
|
||||
async def list_pr_reviews(self, owner: str, repo: str, index: int) -> list[dict]:
|
||||
"""List pull request reviews"""
|
||||
return await self.request("GET", f"repos/{owner}/{repo}/pulls/{index}/reviews")
|
||||
|
||||
async def create_pr_review(self, owner: str, repo: str, index: int, data: dict) -> dict:
|
||||
"""Create pull request review"""
|
||||
return await self.request(
|
||||
"POST", f"repos/{owner}/{repo}/pulls/{index}/reviews", json_data=data
|
||||
)
|
||||
|
||||
async def request_pr_reviewers(self, owner: str, repo: str, index: int, data: dict) -> dict:
|
||||
"""Request pull request reviewers"""
|
||||
return await self.request(
|
||||
"POST", f"repos/{owner}/{repo}/pulls/{index}/requested_reviewers", json_data=data
|
||||
)
|
||||
|
||||
# User endpoints
|
||||
async def get_user(self, username: str) -> dict:
|
||||
"""Get user information"""
|
||||
return await self.request("GET", f"users/{username}")
|
||||
|
||||
async def list_user_repos(self, username: str, page: int = 1, limit: int = 30) -> list[dict]:
|
||||
"""List user repositories"""
|
||||
params = {"page": page, "limit": limit}
|
||||
return await self.request("GET", f"users/{username}/repos", params=params)
|
||||
|
||||
async def search_users(self, query: str | None = None, uid: int | None = None) -> list[dict]:
|
||||
"""Search users"""
|
||||
params = {}
|
||||
if query:
|
||||
params["q"] = query
|
||||
if uid:
|
||||
params["uid"] = uid
|
||||
response = await self.request("GET", "users/search", params=params)
|
||||
return response.get("data", [])
|
||||
|
||||
# Organization endpoints
|
||||
async def list_organizations(self, page: int = 1, limit: int = 30) -> list[dict]:
|
||||
"""List current user's organizations"""
|
||||
params = {"page": page, "limit": limit}
|
||||
return await self.request("GET", "user/orgs", params=params)
|
||||
|
||||
async def get_organization(self, org: str) -> dict:
|
||||
"""Get organization information"""
|
||||
return await self.request("GET", f"orgs/{org}")
|
||||
|
||||
async def list_org_repos(self, org: str, page: int = 1, limit: int = 30) -> list[dict]:
|
||||
"""List organization repositories"""
|
||||
params = {"page": page, "limit": limit}
|
||||
return await self.request("GET", f"orgs/{org}/repos", params=params)
|
||||
|
||||
async def list_org_teams(self, org: str, page: int = 1, limit: int = 30) -> list[dict]:
|
||||
"""List organization teams"""
|
||||
params = {"page": page, "limit": limit}
|
||||
return await self.request("GET", f"orgs/{org}/teams", params=params)
|
||||
|
||||
async def list_team_members(self, team_id: int, page: int = 1, limit: int = 30) -> list[dict]:
|
||||
"""List team members"""
|
||||
params = {"page": page, "limit": limit}
|
||||
return await self.request("GET", f"teams/{team_id}/members", params=params)
|
||||
|
||||
# Webhook endpoints
|
||||
async def list_webhooks(self, owner: str, repo: str) -> list[dict]:
|
||||
"""List repository webhooks"""
|
||||
return await self.request("GET", f"repos/{owner}/{repo}/hooks")
|
||||
|
||||
async def create_webhook(self, owner: str, repo: str, data: dict) -> dict:
|
||||
"""Create a webhook"""
|
||||
return await self.request("POST", f"repos/{owner}/{repo}/hooks", json_data=data)
|
||||
|
||||
async def get_webhook(self, owner: str, repo: str, hook_id: int) -> dict:
|
||||
"""Get webhook details"""
|
||||
return await self.request("GET", f"repos/{owner}/{repo}/hooks/{hook_id}")
|
||||
|
||||
async def update_webhook(self, owner: str, repo: str, hook_id: int, data: dict) -> dict:
|
||||
"""Update a webhook"""
|
||||
return await self.request("PATCH", f"repos/{owner}/{repo}/hooks/{hook_id}", json_data=data)
|
||||
|
||||
async def delete_webhook(self, owner: str, repo: str, hook_id: int) -> dict:
|
||||
"""Delete a webhook"""
|
||||
return await self.request("DELETE", f"repos/{owner}/{repo}/hooks/{hook_id}")
|
||||
|
||||
async def test_webhook(self, owner: str, repo: str, hook_id: int) -> dict:
|
||||
"""Test a webhook"""
|
||||
return await self.request("POST", f"repos/{owner}/{repo}/hooks/{hook_id}/tests")
|
||||
15
plugins/gitea/handlers/__init__.py
Normal file
15
plugins/gitea/handlers/__init__.py
Normal file
@@ -0,0 +1,15 @@
|
||||
"""
|
||||
Gitea Plugin Handlers
|
||||
|
||||
All tool handlers for Gitea operations.
|
||||
"""
|
||||
|
||||
from . import issues, pull_requests, repositories, users, webhooks
|
||||
|
||||
__all__ = [
|
||||
"repositories",
|
||||
"issues",
|
||||
"pull_requests",
|
||||
"users",
|
||||
"webhooks",
|
||||
]
|
||||
516
plugins/gitea/handlers/issues.py
Normal file
516
plugins/gitea/handlers/issues.py
Normal file
@@ -0,0 +1,516 @@
|
||||
"""Issue Handler - manages Gitea issues, labels, milestones, and comments"""
|
||||
|
||||
import json
|
||||
from typing import Any
|
||||
|
||||
from plugins.gitea.client import GiteaClient
|
||||
|
||||
def get_tool_specifications() -> list[dict[str, Any]]:
|
||||
"""Return tool specifications for ToolGenerator"""
|
||||
return [
|
||||
# === ISSUES ===
|
||||
{
|
||||
"name": "list_issues",
|
||||
"method_name": "list_issues",
|
||||
"description": "List issues in a Gitea repository with filters. Returns paginated list of issues.",
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"owner": {"type": "string", "description": "Repository owner", "minLength": 1},
|
||||
"repo": {"type": "string", "description": "Repository name", "minLength": 1},
|
||||
"state": {
|
||||
"type": "string",
|
||||
"description": "Filter by state",
|
||||
"enum": ["open", "closed", "all"],
|
||||
"default": "open",
|
||||
},
|
||||
"labels": {
|
||||
"anyOf": [{"type": "string"}, {"type": "null"}],
|
||||
"description": "Comma-separated label IDs",
|
||||
},
|
||||
"q": {
|
||||
"anyOf": [{"type": "string"}, {"type": "null"}],
|
||||
"description": "Search query",
|
||||
},
|
||||
"page": {
|
||||
"type": "integer",
|
||||
"description": "Page number",
|
||||
"default": 1,
|
||||
"minimum": 1,
|
||||
},
|
||||
"limit": {
|
||||
"type": "integer",
|
||||
"description": "Items per page (1-100)",
|
||||
"default": 30,
|
||||
"minimum": 1,
|
||||
"maximum": 100,
|
||||
},
|
||||
},
|
||||
"required": ["owner", "repo"],
|
||||
},
|
||||
"scope": "read",
|
||||
},
|
||||
{
|
||||
"name": "get_issue",
|
||||
"method_name": "get_issue",
|
||||
"description": "Get details of a specific issue by number.",
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"owner": {"type": "string", "description": "Repository owner", "minLength": 1},
|
||||
"repo": {"type": "string", "description": "Repository name", "minLength": 1},
|
||||
"issue_number": {
|
||||
"type": "integer",
|
||||
"description": "Issue number",
|
||||
"minimum": 1,
|
||||
},
|
||||
},
|
||||
"required": ["owner", "repo", "issue_number"],
|
||||
},
|
||||
"scope": "read",
|
||||
},
|
||||
{
|
||||
"name": "create_issue",
|
||||
"method_name": "create_issue",
|
||||
"description": "Create a new issue in a Gitea repository with optional labels, assignees, and milestone.",
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"owner": {"type": "string", "description": "Repository owner", "minLength": 1},
|
||||
"repo": {"type": "string", "description": "Repository name", "minLength": 1},
|
||||
"title": {
|
||||
"type": "string",
|
||||
"description": "Issue title",
|
||||
"minLength": 1,
|
||||
"maxLength": 255,
|
||||
},
|
||||
"body": {
|
||||
"anyOf": [{"type": "string"}, {"type": "null"}],
|
||||
"description": "Issue description (supports Markdown)",
|
||||
},
|
||||
"assignee": {
|
||||
"anyOf": [{"type": "string"}, {"type": "null"}],
|
||||
"description": "Assignee username",
|
||||
},
|
||||
"assignees": {
|
||||
"anyOf": [{"type": "array", "items": {"type": "string"}}, {"type": "null"}],
|
||||
"description": "List of assignee usernames",
|
||||
},
|
||||
"labels": {
|
||||
"anyOf": [
|
||||
{"type": "array", "items": {"type": "integer"}},
|
||||
{"type": "null"},
|
||||
],
|
||||
"description": "List of label IDs",
|
||||
},
|
||||
"milestone": {
|
||||
"anyOf": [{"type": "integer"}, {"type": "null"}],
|
||||
"description": "Milestone ID",
|
||||
},
|
||||
"closed": {
|
||||
"type": "boolean",
|
||||
"description": "Create as closed",
|
||||
"default": False,
|
||||
},
|
||||
},
|
||||
"required": ["owner", "repo", "title"],
|
||||
},
|
||||
"scope": "write",
|
||||
},
|
||||
{
|
||||
"name": "update_issue",
|
||||
"method_name": "update_issue",
|
||||
"description": "Update an existing issue. Can modify title, body, state, assignees, labels, and milestone.",
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"owner": {"type": "string", "description": "Repository owner", "minLength": 1},
|
||||
"repo": {"type": "string", "description": "Repository name", "minLength": 1},
|
||||
"issue_number": {
|
||||
"type": "integer",
|
||||
"description": "Issue number",
|
||||
"minimum": 1,
|
||||
},
|
||||
"title": {
|
||||
"anyOf": [{"type": "string"}, {"type": "null"}],
|
||||
"description": "Issue title",
|
||||
},
|
||||
"body": {
|
||||
"anyOf": [{"type": "string"}, {"type": "null"}],
|
||||
"description": "Issue description",
|
||||
},
|
||||
"state": {
|
||||
"anyOf": [{"type": "string", "enum": ["open", "closed"]}, {"type": "null"}],
|
||||
"description": "Issue state",
|
||||
},
|
||||
"assignee": {
|
||||
"anyOf": [{"type": "string"}, {"type": "null"}],
|
||||
"description": "Assignee username",
|
||||
},
|
||||
"assignees": {
|
||||
"anyOf": [{"type": "array", "items": {"type": "string"}}, {"type": "null"}],
|
||||
"description": "List of assignee usernames",
|
||||
},
|
||||
"labels": {
|
||||
"anyOf": [
|
||||
{"type": "array", "items": {"type": "integer"}},
|
||||
{"type": "null"},
|
||||
],
|
||||
"description": "List of label IDs",
|
||||
},
|
||||
"milestone": {
|
||||
"anyOf": [{"type": "integer"}, {"type": "null"}],
|
||||
"description": "Milestone ID",
|
||||
},
|
||||
},
|
||||
"required": ["owner", "repo", "issue_number"],
|
||||
},
|
||||
"scope": "write",
|
||||
},
|
||||
{
|
||||
"name": "close_issue",
|
||||
"method_name": "close_issue",
|
||||
"description": "Close an issue.",
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"owner": {"type": "string", "description": "Repository owner", "minLength": 1},
|
||||
"repo": {"type": "string", "description": "Repository name", "minLength": 1},
|
||||
"issue_number": {
|
||||
"type": "integer",
|
||||
"description": "Issue number",
|
||||
"minimum": 1,
|
||||
},
|
||||
},
|
||||
"required": ["owner", "repo", "issue_number"],
|
||||
},
|
||||
"scope": "write",
|
||||
},
|
||||
{
|
||||
"name": "reopen_issue",
|
||||
"method_name": "reopen_issue",
|
||||
"description": "Reopen a closed issue.",
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"owner": {"type": "string", "description": "Repository owner", "minLength": 1},
|
||||
"repo": {"type": "string", "description": "Repository name", "minLength": 1},
|
||||
"issue_number": {
|
||||
"type": "integer",
|
||||
"description": "Issue number",
|
||||
"minimum": 1,
|
||||
},
|
||||
},
|
||||
"required": ["owner", "repo", "issue_number"],
|
||||
},
|
||||
"scope": "write",
|
||||
},
|
||||
# === COMMENTS ===
|
||||
{
|
||||
"name": "list_issue_comments",
|
||||
"method_name": "list_issue_comments",
|
||||
"description": "List all comments on an issue.",
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"owner": {"type": "string", "description": "Repository owner", "minLength": 1},
|
||||
"repo": {"type": "string", "description": "Repository name", "minLength": 1},
|
||||
"issue_number": {
|
||||
"type": "integer",
|
||||
"description": "Issue number",
|
||||
"minimum": 1,
|
||||
},
|
||||
},
|
||||
"required": ["owner", "repo", "issue_number"],
|
||||
},
|
||||
"scope": "read",
|
||||
},
|
||||
{
|
||||
"name": "create_issue_comment",
|
||||
"method_name": "create_issue_comment",
|
||||
"description": "Add a comment to an issue.",
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"owner": {"type": "string", "description": "Repository owner", "minLength": 1},
|
||||
"repo": {"type": "string", "description": "Repository name", "minLength": 1},
|
||||
"issue_number": {
|
||||
"type": "integer",
|
||||
"description": "Issue number",
|
||||
"minimum": 1,
|
||||
},
|
||||
"body": {
|
||||
"type": "string",
|
||||
"description": "Comment body (supports Markdown)",
|
||||
"minLength": 1,
|
||||
},
|
||||
},
|
||||
"required": ["owner", "repo", "issue_number", "body"],
|
||||
},
|
||||
"scope": "write",
|
||||
},
|
||||
# === LABELS ===
|
||||
{
|
||||
"name": "list_labels",
|
||||
"method_name": "list_labels",
|
||||
"description": "List all labels in a repository.",
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"owner": {"type": "string", "description": "Repository owner", "minLength": 1},
|
||||
"repo": {"type": "string", "description": "Repository name", "minLength": 1},
|
||||
},
|
||||
"required": ["owner", "repo"],
|
||||
},
|
||||
"scope": "read",
|
||||
},
|
||||
{
|
||||
"name": "create_label",
|
||||
"method_name": "create_label",
|
||||
"description": "Create a new label in a repository.",
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"owner": {"type": "string", "description": "Repository owner", "minLength": 1},
|
||||
"repo": {"type": "string", "description": "Repository name", "minLength": 1},
|
||||
"name": {
|
||||
"type": "string",
|
||||
"description": "Label name",
|
||||
"minLength": 1,
|
||||
"maxLength": 50,
|
||||
},
|
||||
"color": {
|
||||
"type": "string",
|
||||
"description": "Label color (hex without #, e.g., 'ff0000')",
|
||||
"pattern": "^[0-9A-Fa-f]{6}$",
|
||||
},
|
||||
"description": {
|
||||
"anyOf": [{"type": "string"}, {"type": "null"}],
|
||||
"description": "Label description",
|
||||
"maxLength": 200,
|
||||
},
|
||||
},
|
||||
"required": ["owner", "repo", "name", "color"],
|
||||
},
|
||||
"scope": "write",
|
||||
},
|
||||
# === MILESTONES ===
|
||||
{
|
||||
"name": "list_milestones",
|
||||
"method_name": "list_milestones",
|
||||
"description": "List all milestones in a repository.",
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"owner": {"type": "string", "description": "Repository owner", "minLength": 1},
|
||||
"repo": {"type": "string", "description": "Repository name", "minLength": 1},
|
||||
"state": {
|
||||
"anyOf": [
|
||||
{"type": "string", "enum": ["open", "closed", "all"]},
|
||||
{"type": "null"},
|
||||
],
|
||||
"description": "Filter by state",
|
||||
"default": "open",
|
||||
},
|
||||
},
|
||||
"required": ["owner", "repo"],
|
||||
},
|
||||
"scope": "read",
|
||||
},
|
||||
{
|
||||
"name": "create_milestone",
|
||||
"method_name": "create_milestone",
|
||||
"description": "Create a new milestone in a repository.",
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"owner": {"type": "string", "description": "Repository owner", "minLength": 1},
|
||||
"repo": {"type": "string", "description": "Repository name", "minLength": 1},
|
||||
"title": {
|
||||
"type": "string",
|
||||
"description": "Milestone title",
|
||||
"minLength": 1,
|
||||
"maxLength": 255,
|
||||
},
|
||||
"description": {
|
||||
"anyOf": [{"type": "string"}, {"type": "null"}],
|
||||
"description": "Milestone description",
|
||||
},
|
||||
"due_on": {
|
||||
"anyOf": [{"type": "string", "format": "date-time"}, {"type": "null"}],
|
||||
"description": "Due date (ISO 8601 format)",
|
||||
},
|
||||
"state": {
|
||||
"type": "string",
|
||||
"description": "State",
|
||||
"enum": ["open", "closed"],
|
||||
"default": "open",
|
||||
},
|
||||
},
|
||||
"required": ["owner", "repo", "title"],
|
||||
},
|
||||
"scope": "write",
|
||||
},
|
||||
]
|
||||
|
||||
async def list_issues(
|
||||
client: GiteaClient,
|
||||
owner: str,
|
||||
repo: str,
|
||||
state: str = "open",
|
||||
labels: str | None = None,
|
||||
q: str | None = None,
|
||||
page: int = 1,
|
||||
limit: int = 30,
|
||||
) -> str:
|
||||
"""List repository issues"""
|
||||
params = {"state": state, "labels": labels, "q": q, "page": page, "limit": limit}
|
||||
issues = await client.list_issues(owner, repo, params)
|
||||
result = {"success": True, "count": len(issues), "issues": issues}
|
||||
return json.dumps(result, indent=2)
|
||||
|
||||
async def get_issue(client: GiteaClient, owner: str, repo: str, issue_number: int) -> str:
|
||||
"""Get issue details"""
|
||||
issue = await client.get_issue(owner, repo, issue_number)
|
||||
result = {"success": True, "issue": issue}
|
||||
return json.dumps(result, indent=2)
|
||||
|
||||
async def create_issue(
|
||||
client: GiteaClient,
|
||||
owner: str,
|
||||
repo: str,
|
||||
title: str,
|
||||
body: str | None = None,
|
||||
assignee: str | None = None,
|
||||
assignees: list[str] | None = None,
|
||||
labels: list[int] | None = None,
|
||||
milestone: int | None = None,
|
||||
closed: bool = False,
|
||||
) -> str:
|
||||
"""Create a new issue"""
|
||||
data = {
|
||||
"title": title,
|
||||
"body": body,
|
||||
"assignee": assignee,
|
||||
"assignees": assignees,
|
||||
"labels": labels,
|
||||
"milestone": milestone,
|
||||
"closed": closed,
|
||||
}
|
||||
issue = await client.create_issue(owner, repo, data)
|
||||
result = {
|
||||
"success": True,
|
||||
"message": f"Issue #{issue['number']} created successfully",
|
||||
"issue": issue,
|
||||
}
|
||||
return json.dumps(result, indent=2)
|
||||
|
||||
async def update_issue(
|
||||
client: GiteaClient, owner: str, repo: str, issue_number: int, **kwargs
|
||||
) -> str:
|
||||
"""Update an issue"""
|
||||
# Build update data from kwargs
|
||||
data = {
|
||||
k: v
|
||||
for k, v in kwargs.items()
|
||||
if v is not None and k not in ["owner", "repo", "issue_number"]
|
||||
}
|
||||
|
||||
issue = await client.update_issue(owner, repo, issue_number, data)
|
||||
result = {
|
||||
"success": True,
|
||||
"message": f"Issue #{issue_number} updated successfully",
|
||||
"issue": issue,
|
||||
}
|
||||
return json.dumps(result, indent=2)
|
||||
|
||||
async def close_issue(client: GiteaClient, owner: str, repo: str, issue_number: int) -> str:
|
||||
"""Close an issue"""
|
||||
data = {"state": "closed"}
|
||||
issue = await client.update_issue(owner, repo, issue_number, data)
|
||||
result = {
|
||||
"success": True,
|
||||
"message": f"Issue #{issue_number} closed successfully",
|
||||
"issue": issue,
|
||||
}
|
||||
return json.dumps(result, indent=2)
|
||||
|
||||
async def reopen_issue(client: GiteaClient, owner: str, repo: str, issue_number: int) -> str:
|
||||
"""Reopen an issue"""
|
||||
data = {"state": "open"}
|
||||
issue = await client.update_issue(owner, repo, issue_number, data)
|
||||
result = {
|
||||
"success": True,
|
||||
"message": f"Issue #{issue_number} reopened successfully",
|
||||
"issue": issue,
|
||||
}
|
||||
return json.dumps(result, indent=2)
|
||||
|
||||
# Comment operations
|
||||
async def list_issue_comments(client: GiteaClient, owner: str, repo: str, issue_number: int) -> str:
|
||||
"""List issue comments"""
|
||||
comments = await client.list_issue_comments(owner, repo, issue_number)
|
||||
result = {"success": True, "count": len(comments), "comments": comments}
|
||||
return json.dumps(result, indent=2)
|
||||
|
||||
async def create_issue_comment(
|
||||
client: GiteaClient, owner: str, repo: str, issue_number: int, body: str
|
||||
) -> str:
|
||||
"""Create issue comment"""
|
||||
data = {"body": body}
|
||||
comment = await client.create_issue_comment(owner, repo, issue_number, data)
|
||||
result = {
|
||||
"success": True,
|
||||
"message": f"Comment added to issue #{issue_number}",
|
||||
"comment": comment,
|
||||
}
|
||||
return json.dumps(result, indent=2)
|
||||
|
||||
# Label operations
|
||||
async def list_labels(client: GiteaClient, owner: str, repo: str) -> str:
|
||||
"""List repository labels"""
|
||||
labels = await client.list_labels(owner, repo)
|
||||
result = {"success": True, "count": len(labels), "labels": labels}
|
||||
return json.dumps(result, indent=2)
|
||||
|
||||
async def create_label(
|
||||
client: GiteaClient,
|
||||
owner: str,
|
||||
repo: str,
|
||||
name: str,
|
||||
color: str,
|
||||
description: str | None = None,
|
||||
) -> str:
|
||||
"""Create a label"""
|
||||
data = {"name": name, "color": color, "description": description}
|
||||
label = await client.create_label(owner, repo, data)
|
||||
result = {"success": True, "message": f"Label '{name}' created successfully", "label": label}
|
||||
return json.dumps(result, indent=2)
|
||||
|
||||
# Milestone operations
|
||||
async def list_milestones(
|
||||
client: GiteaClient, owner: str, repo: str, state: str | None = None
|
||||
) -> str:
|
||||
"""List repository milestones"""
|
||||
milestones = await client.list_milestones(owner, repo, state=state)
|
||||
result = {"success": True, "count": len(milestones), "milestones": milestones}
|
||||
return json.dumps(result, indent=2)
|
||||
|
||||
async def create_milestone(
|
||||
client: GiteaClient,
|
||||
owner: str,
|
||||
repo: str,
|
||||
title: str,
|
||||
description: str | None = None,
|
||||
due_on: str | None = None,
|
||||
state: str = "open",
|
||||
) -> str:
|
||||
"""Create a milestone"""
|
||||
data = {"title": title, "description": description, "due_on": due_on, "state": state}
|
||||
milestone = await client.create_milestone(owner, repo, data)
|
||||
result = {
|
||||
"success": True,
|
||||
"message": f"Milestone '{title}' created successfully",
|
||||
"milestone": milestone,
|
||||
}
|
||||
return json.dumps(result, indent=2)
|
||||
629
plugins/gitea/handlers/pull_requests.py
Normal file
629
plugins/gitea/handlers/pull_requests.py
Normal file
@@ -0,0 +1,629 @@
|
||||
"""Pull Request Handler - manages Gitea pull requests, reviews, and merges"""
|
||||
|
||||
import json
|
||||
from typing import Any
|
||||
|
||||
from plugins.gitea.client import GiteaClient
|
||||
|
||||
def get_tool_specifications() -> list[dict[str, Any]]:
|
||||
"""Return tool specifications for ToolGenerator"""
|
||||
return [
|
||||
# === PULL REQUESTS ===
|
||||
{
|
||||
"name": "list_pull_requests",
|
||||
"method_name": "list_pull_requests",
|
||||
"description": "List pull requests in a Gitea repository with filters.",
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"owner": {"type": "string", "description": "Repository owner", "minLength": 1},
|
||||
"repo": {"type": "string", "description": "Repository name", "minLength": 1},
|
||||
"state": {
|
||||
"type": "string",
|
||||
"description": "Filter by state",
|
||||
"enum": ["open", "closed", "all"],
|
||||
"default": "open",
|
||||
},
|
||||
"sort": {
|
||||
"type": "string",
|
||||
"description": "Sort by",
|
||||
"enum": ["created", "updated", "comments", "recentupdate"],
|
||||
"default": "created",
|
||||
},
|
||||
"labels": {
|
||||
"anyOf": [{"type": "string"}, {"type": "null"}],
|
||||
"description": "Comma-separated label IDs",
|
||||
},
|
||||
"milestone": {
|
||||
"anyOf": [{"type": "string"}, {"type": "null"}],
|
||||
"description": "Milestone name",
|
||||
},
|
||||
"page": {
|
||||
"type": "integer",
|
||||
"description": "Page number",
|
||||
"default": 1,
|
||||
"minimum": 1,
|
||||
},
|
||||
"limit": {
|
||||
"type": "integer",
|
||||
"description": "Items per page (1-100)",
|
||||
"default": 30,
|
||||
"minimum": 1,
|
||||
"maximum": 100,
|
||||
},
|
||||
},
|
||||
"required": ["owner", "repo"],
|
||||
},
|
||||
"scope": "read",
|
||||
},
|
||||
{
|
||||
"name": "get_pull_request",
|
||||
"method_name": "get_pull_request",
|
||||
"description": "Get details of a specific pull request by number.",
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"owner": {"type": "string", "description": "Repository owner", "minLength": 1},
|
||||
"repo": {"type": "string", "description": "Repository name", "minLength": 1},
|
||||
"pr_number": {
|
||||
"type": "integer",
|
||||
"description": "Pull request number",
|
||||
"minimum": 1,
|
||||
},
|
||||
},
|
||||
"required": ["owner", "repo", "pr_number"],
|
||||
},
|
||||
"scope": "read",
|
||||
},
|
||||
{
|
||||
"name": "create_pull_request",
|
||||
"method_name": "create_pull_request",
|
||||
"description": "Create a new pull request in a Gitea repository.",
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"owner": {"type": "string", "description": "Repository owner", "minLength": 1},
|
||||
"repo": {"type": "string", "description": "Repository name", "minLength": 1},
|
||||
"title": {
|
||||
"type": "string",
|
||||
"description": "Pull request title",
|
||||
"minLength": 1,
|
||||
"maxLength": 255,
|
||||
},
|
||||
"head": {
|
||||
"type": "string",
|
||||
"description": "Source branch (head branch)",
|
||||
"minLength": 1,
|
||||
},
|
||||
"base": {
|
||||
"type": "string",
|
||||
"description": "Target branch (base branch)",
|
||||
"minLength": 1,
|
||||
},
|
||||
"body": {
|
||||
"anyOf": [{"type": "string"}, {"type": "null"}],
|
||||
"description": "Pull request description (supports Markdown)",
|
||||
},
|
||||
"assignee": {
|
||||
"anyOf": [{"type": "string"}, {"type": "null"}],
|
||||
"description": "Assignee username",
|
||||
},
|
||||
"assignees": {
|
||||
"anyOf": [{"type": "array", "items": {"type": "string"}}, {"type": "null"}],
|
||||
"description": "List of assignee usernames",
|
||||
},
|
||||
"labels": {
|
||||
"anyOf": [
|
||||
{"type": "array", "items": {"type": "integer"}},
|
||||
{"type": "null"},
|
||||
],
|
||||
"description": "List of label IDs",
|
||||
},
|
||||
"milestone": {
|
||||
"anyOf": [{"type": "integer"}, {"type": "null"}],
|
||||
"description": "Milestone ID",
|
||||
},
|
||||
},
|
||||
"required": ["owner", "repo", "title", "head", "base"],
|
||||
},
|
||||
"scope": "write",
|
||||
},
|
||||
{
|
||||
"name": "update_pull_request",
|
||||
"method_name": "update_pull_request",
|
||||
"description": "Update an existing pull request. Can modify title, body, state, assignees, labels, and milestone.",
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"owner": {"type": "string", "description": "Repository owner", "minLength": 1},
|
||||
"repo": {"type": "string", "description": "Repository name", "minLength": 1},
|
||||
"pr_number": {
|
||||
"type": "integer",
|
||||
"description": "Pull request number",
|
||||
"minimum": 1,
|
||||
},
|
||||
"title": {
|
||||
"anyOf": [{"type": "string"}, {"type": "null"}],
|
||||
"description": "Pull request title",
|
||||
},
|
||||
"body": {
|
||||
"anyOf": [{"type": "string"}, {"type": "null"}],
|
||||
"description": "Pull request description",
|
||||
},
|
||||
"state": {
|
||||
"anyOf": [{"type": "string", "enum": ["open", "closed"]}, {"type": "null"}],
|
||||
"description": "Pull request state",
|
||||
},
|
||||
"assignee": {
|
||||
"anyOf": [{"type": "string"}, {"type": "null"}],
|
||||
"description": "Assignee username",
|
||||
},
|
||||
"assignees": {
|
||||
"anyOf": [{"type": "array", "items": {"type": "string"}}, {"type": "null"}],
|
||||
"description": "List of assignee usernames",
|
||||
},
|
||||
"labels": {
|
||||
"anyOf": [
|
||||
{"type": "array", "items": {"type": "integer"}},
|
||||
{"type": "null"},
|
||||
],
|
||||
"description": "List of label IDs",
|
||||
},
|
||||
"milestone": {
|
||||
"anyOf": [{"type": "integer"}, {"type": "null"}],
|
||||
"description": "Milestone ID",
|
||||
},
|
||||
},
|
||||
"required": ["owner", "repo", "pr_number"],
|
||||
},
|
||||
"scope": "write",
|
||||
},
|
||||
{
|
||||
"name": "merge_pull_request",
|
||||
"method_name": "merge_pull_request",
|
||||
"description": "Merge a pull request using specified merge method.",
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"owner": {"type": "string", "description": "Repository owner", "minLength": 1},
|
||||
"repo": {"type": "string", "description": "Repository name", "minLength": 1},
|
||||
"pr_number": {
|
||||
"type": "integer",
|
||||
"description": "Pull request number",
|
||||
"minimum": 1,
|
||||
},
|
||||
"method": {
|
||||
"type": "string",
|
||||
"description": "Merge method",
|
||||
"enum": ["merge", "rebase", "rebase-merge", "squash"],
|
||||
"default": "merge",
|
||||
},
|
||||
"title": {
|
||||
"anyOf": [{"type": "string"}, {"type": "null"}],
|
||||
"description": "Merge commit title",
|
||||
},
|
||||
"message": {
|
||||
"anyOf": [{"type": "string"}, {"type": "null"}],
|
||||
"description": "Merge commit message",
|
||||
},
|
||||
"delete_branch_after_merge": {
|
||||
"type": "boolean",
|
||||
"description": "Delete source branch after merge",
|
||||
"default": False,
|
||||
},
|
||||
},
|
||||
"required": ["owner", "repo", "pr_number"],
|
||||
},
|
||||
"scope": "write",
|
||||
},
|
||||
{
|
||||
"name": "close_pull_request",
|
||||
"method_name": "close_pull_request",
|
||||
"description": "Close a pull request without merging.",
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"owner": {"type": "string", "description": "Repository owner", "minLength": 1},
|
||||
"repo": {"type": "string", "description": "Repository name", "minLength": 1},
|
||||
"pr_number": {
|
||||
"type": "integer",
|
||||
"description": "Pull request number",
|
||||
"minimum": 1,
|
||||
},
|
||||
},
|
||||
"required": ["owner", "repo", "pr_number"],
|
||||
},
|
||||
"scope": "write",
|
||||
},
|
||||
{
|
||||
"name": "reopen_pull_request",
|
||||
"method_name": "reopen_pull_request",
|
||||
"description": "Reopen a closed pull request.",
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"owner": {"type": "string", "description": "Repository owner", "minLength": 1},
|
||||
"repo": {"type": "string", "description": "Repository name", "minLength": 1},
|
||||
"pr_number": {
|
||||
"type": "integer",
|
||||
"description": "Pull request number",
|
||||
"minimum": 1,
|
||||
},
|
||||
},
|
||||
"required": ["owner", "repo", "pr_number"],
|
||||
},
|
||||
"scope": "write",
|
||||
},
|
||||
# === PR DETAILS ===
|
||||
{
|
||||
"name": "list_pr_commits",
|
||||
"method_name": "list_pr_commits",
|
||||
"description": "List all commits in a pull request.",
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"owner": {"type": "string", "description": "Repository owner", "minLength": 1},
|
||||
"repo": {"type": "string", "description": "Repository name", "minLength": 1},
|
||||
"pr_number": {
|
||||
"type": "integer",
|
||||
"description": "Pull request number",
|
||||
"minimum": 1,
|
||||
},
|
||||
},
|
||||
"required": ["owner", "repo", "pr_number"],
|
||||
},
|
||||
"scope": "read",
|
||||
},
|
||||
{
|
||||
"name": "list_pr_files",
|
||||
"method_name": "list_pr_files",
|
||||
"description": "List all changed files in a pull request with additions/deletions count.",
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"owner": {"type": "string", "description": "Repository owner", "minLength": 1},
|
||||
"repo": {"type": "string", "description": "Repository name", "minLength": 1},
|
||||
"pr_number": {
|
||||
"type": "integer",
|
||||
"description": "Pull request number",
|
||||
"minimum": 1,
|
||||
},
|
||||
},
|
||||
"required": ["owner", "repo", "pr_number"],
|
||||
},
|
||||
"scope": "read",
|
||||
},
|
||||
{
|
||||
"name": "get_pr_diff",
|
||||
"method_name": "get_pr_diff",
|
||||
"description": "Get the unified diff of a pull request.",
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"owner": {"type": "string", "description": "Repository owner", "minLength": 1},
|
||||
"repo": {"type": "string", "description": "Repository name", "minLength": 1},
|
||||
"pr_number": {
|
||||
"type": "integer",
|
||||
"description": "Pull request number",
|
||||
"minimum": 1,
|
||||
},
|
||||
},
|
||||
"required": ["owner", "repo", "pr_number"],
|
||||
},
|
||||
"scope": "read",
|
||||
},
|
||||
{
|
||||
"name": "list_pr_comments",
|
||||
"method_name": "list_pr_comments",
|
||||
"description": "List all comments on a pull request.",
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"owner": {"type": "string", "description": "Repository owner", "minLength": 1},
|
||||
"repo": {"type": "string", "description": "Repository name", "minLength": 1},
|
||||
"pr_number": {
|
||||
"type": "integer",
|
||||
"description": "Pull request number",
|
||||
"minimum": 1,
|
||||
},
|
||||
},
|
||||
"required": ["owner", "repo", "pr_number"],
|
||||
},
|
||||
"scope": "read",
|
||||
},
|
||||
{
|
||||
"name": "create_pr_comment",
|
||||
"method_name": "create_pr_comment",
|
||||
"description": "Add a comment to a pull request.",
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"owner": {"type": "string", "description": "Repository owner", "minLength": 1},
|
||||
"repo": {"type": "string", "description": "Repository name", "minLength": 1},
|
||||
"pr_number": {
|
||||
"type": "integer",
|
||||
"description": "Pull request number",
|
||||
"minimum": 1,
|
||||
},
|
||||
"body": {
|
||||
"type": "string",
|
||||
"description": "Comment body (supports Markdown)",
|
||||
"minLength": 1,
|
||||
},
|
||||
},
|
||||
"required": ["owner", "repo", "pr_number", "body"],
|
||||
},
|
||||
"scope": "write",
|
||||
},
|
||||
# === PR REVIEWS ===
|
||||
{
|
||||
"name": "list_pr_reviews",
|
||||
"method_name": "list_pr_reviews",
|
||||
"description": "List all reviews on a pull request.",
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"owner": {"type": "string", "description": "Repository owner", "minLength": 1},
|
||||
"repo": {"type": "string", "description": "Repository name", "minLength": 1},
|
||||
"pr_number": {
|
||||
"type": "integer",
|
||||
"description": "Pull request number",
|
||||
"minimum": 1,
|
||||
},
|
||||
},
|
||||
"required": ["owner", "repo", "pr_number"],
|
||||
},
|
||||
"scope": "read",
|
||||
},
|
||||
{
|
||||
"name": "create_pr_review",
|
||||
"method_name": "create_pr_review",
|
||||
"description": "Create a review for a pull request (approve, request changes, or comment).",
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"owner": {"type": "string", "description": "Repository owner", "minLength": 1},
|
||||
"repo": {"type": "string", "description": "Repository name", "minLength": 1},
|
||||
"pr_number": {
|
||||
"type": "integer",
|
||||
"description": "Pull request number",
|
||||
"minimum": 1,
|
||||
},
|
||||
"event": {
|
||||
"type": "string",
|
||||
"description": "Review event type",
|
||||
"enum": ["APPROVED", "REQUEST_CHANGES", "COMMENT"],
|
||||
},
|
||||
"body": {
|
||||
"anyOf": [{"type": "string"}, {"type": "null"}],
|
||||
"description": "Review comment (optional for APPROVED)",
|
||||
},
|
||||
},
|
||||
"required": ["owner", "repo", "pr_number", "event"],
|
||||
},
|
||||
"scope": "write",
|
||||
},
|
||||
{
|
||||
"name": "request_pr_reviewers",
|
||||
"method_name": "request_pr_reviewers",
|
||||
"description": "Request reviewers for a pull request.",
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"owner": {"type": "string", "description": "Repository owner", "minLength": 1},
|
||||
"repo": {"type": "string", "description": "Repository name", "minLength": 1},
|
||||
"pr_number": {
|
||||
"type": "integer",
|
||||
"description": "Pull request number",
|
||||
"minimum": 1,
|
||||
},
|
||||
"reviewers": {
|
||||
"type": "array",
|
||||
"items": {"type": "string"},
|
||||
"description": "List of reviewer usernames",
|
||||
"minItems": 1,
|
||||
},
|
||||
},
|
||||
"required": ["owner", "repo", "pr_number", "reviewers"],
|
||||
},
|
||||
"scope": "write",
|
||||
},
|
||||
]
|
||||
|
||||
async def list_pull_requests(
|
||||
client: GiteaClient,
|
||||
owner: str,
|
||||
repo: str,
|
||||
state: str = "open",
|
||||
sort: str = "created",
|
||||
labels: str | None = None,
|
||||
milestone: str | None = None,
|
||||
page: int = 1,
|
||||
limit: int = 30,
|
||||
) -> str:
|
||||
"""List repository pull requests"""
|
||||
params = {
|
||||
"state": state,
|
||||
"sort": sort,
|
||||
"labels": labels,
|
||||
"milestone": milestone,
|
||||
"page": page,
|
||||
"limit": limit,
|
||||
}
|
||||
prs = await client.list_pull_requests(owner, repo, params)
|
||||
result = {"success": True, "count": len(prs), "pull_requests": prs}
|
||||
return json.dumps(result, indent=2)
|
||||
|
||||
async def get_pull_request(client: GiteaClient, owner: str, repo: str, pr_number: int) -> str:
|
||||
"""Get pull request details"""
|
||||
pr = await client.get_pull_request(owner, repo, pr_number)
|
||||
result = {"success": True, "pull_request": pr}
|
||||
return json.dumps(result, indent=2)
|
||||
|
||||
async def create_pull_request(
|
||||
client: GiteaClient,
|
||||
owner: str,
|
||||
repo: str,
|
||||
title: str,
|
||||
head: str,
|
||||
base: str,
|
||||
body: str | None = None,
|
||||
assignee: str | None = None,
|
||||
assignees: list[str] | None = None,
|
||||
labels: list[int] | None = None,
|
||||
milestone: int | None = None,
|
||||
) -> str:
|
||||
"""Create a new pull request"""
|
||||
data = {
|
||||
"title": title,
|
||||
"head": head,
|
||||
"base": base,
|
||||
"body": body,
|
||||
"assignee": assignee,
|
||||
"assignees": assignees,
|
||||
"labels": labels,
|
||||
"milestone": milestone,
|
||||
}
|
||||
pr = await client.create_pull_request(owner, repo, data)
|
||||
result = {
|
||||
"success": True,
|
||||
"message": f"Pull request #{pr['number']} created successfully",
|
||||
"pull_request": pr,
|
||||
}
|
||||
return json.dumps(result, indent=2)
|
||||
|
||||
async def update_pull_request(
|
||||
client: GiteaClient, owner: str, repo: str, pr_number: int, **kwargs
|
||||
) -> str:
|
||||
"""Update a pull request"""
|
||||
# Build update data from kwargs
|
||||
data = {
|
||||
k: v for k, v in kwargs.items() if v is not None and k not in ["owner", "repo", "pr_number"]
|
||||
}
|
||||
|
||||
pr = await client.update_pull_request(owner, repo, pr_number, data)
|
||||
result = {
|
||||
"success": True,
|
||||
"message": f"Pull request #{pr_number} updated successfully",
|
||||
"pull_request": pr,
|
||||
}
|
||||
return json.dumps(result, indent=2)
|
||||
|
||||
async def merge_pull_request(
|
||||
client: GiteaClient,
|
||||
owner: str,
|
||||
repo: str,
|
||||
pr_number: int,
|
||||
method: str = "merge",
|
||||
title: str | None = None,
|
||||
message: str | None = None,
|
||||
delete_branch_after_merge: bool = False,
|
||||
) -> str:
|
||||
"""Merge a pull request"""
|
||||
data = {
|
||||
"Do": method,
|
||||
"MergeTitleField": title,
|
||||
"MergeMessageField": message,
|
||||
"delete_branch_after_merge": delete_branch_after_merge,
|
||||
}
|
||||
merge_result = await client.merge_pull_request(owner, repo, pr_number, data)
|
||||
result = {
|
||||
"success": True,
|
||||
"message": f"Pull request #{pr_number} merged successfully using {method}",
|
||||
"result": merge_result,
|
||||
}
|
||||
return json.dumps(result, indent=2)
|
||||
|
||||
async def close_pull_request(client: GiteaClient, owner: str, repo: str, pr_number: int) -> str:
|
||||
"""Close a pull request"""
|
||||
data = {"state": "closed"}
|
||||
pr = await client.update_pull_request(owner, repo, pr_number, data)
|
||||
result = {
|
||||
"success": True,
|
||||
"message": f"Pull request #{pr_number} closed successfully",
|
||||
"pull_request": pr,
|
||||
}
|
||||
return json.dumps(result, indent=2)
|
||||
|
||||
async def reopen_pull_request(client: GiteaClient, owner: str, repo: str, pr_number: int) -> str:
|
||||
"""Reopen a pull request"""
|
||||
data = {"state": "open"}
|
||||
pr = await client.update_pull_request(owner, repo, pr_number, data)
|
||||
result = {
|
||||
"success": True,
|
||||
"message": f"Pull request #{pr_number} reopened successfully",
|
||||
"pull_request": pr,
|
||||
}
|
||||
return json.dumps(result, indent=2)
|
||||
|
||||
# PR Details
|
||||
async def list_pr_commits(client: GiteaClient, owner: str, repo: str, pr_number: int) -> str:
|
||||
"""List pull request commits"""
|
||||
commits = await client.list_pr_commits(owner, repo, pr_number)
|
||||
result = {"success": True, "count": len(commits), "commits": commits}
|
||||
return json.dumps(result, indent=2)
|
||||
|
||||
async def list_pr_files(client: GiteaClient, owner: str, repo: str, pr_number: int) -> str:
|
||||
"""List pull request files"""
|
||||
files = await client.list_pr_files(owner, repo, pr_number)
|
||||
result = {"success": True, "count": len(files), "files": files}
|
||||
return json.dumps(result, indent=2)
|
||||
|
||||
async def get_pr_diff(client: GiteaClient, owner: str, repo: str, pr_number: int) -> str:
|
||||
"""Get pull request diff"""
|
||||
diff = await client.get_pr_diff(owner, repo, pr_number)
|
||||
result = {"success": True, "diff": diff}
|
||||
return json.dumps(result, indent=2)
|
||||
|
||||
async def list_pr_comments(client: GiteaClient, owner: str, repo: str, pr_number: int) -> str:
|
||||
"""List pull request comments"""
|
||||
# PR comments are same as issue comments in Gitea API
|
||||
comments = await client.list_issue_comments(owner, repo, pr_number)
|
||||
result = {"success": True, "count": len(comments), "comments": comments}
|
||||
return json.dumps(result, indent=2)
|
||||
|
||||
async def create_pr_comment(
|
||||
client: GiteaClient, owner: str, repo: str, pr_number: int, body: str
|
||||
) -> str:
|
||||
"""Create pull request comment"""
|
||||
data = {"body": body}
|
||||
comment = await client.create_issue_comment(owner, repo, pr_number, data)
|
||||
result = {
|
||||
"success": True,
|
||||
"message": f"Comment added to pull request #{pr_number}",
|
||||
"comment": comment,
|
||||
}
|
||||
return json.dumps(result, indent=2)
|
||||
|
||||
# PR Reviews
|
||||
async def list_pr_reviews(client: GiteaClient, owner: str, repo: str, pr_number: int) -> str:
|
||||
"""List pull request reviews"""
|
||||
reviews = await client.list_pr_reviews(owner, repo, pr_number)
|
||||
result = {"success": True, "count": len(reviews), "reviews": reviews}
|
||||
return json.dumps(result, indent=2)
|
||||
|
||||
async def create_pr_review(
|
||||
client: GiteaClient, owner: str, repo: str, pr_number: int, event: str, body: str | None = None
|
||||
) -> str:
|
||||
"""Create pull request review"""
|
||||
data = {"event": event, "body": body}
|
||||
review = await client.create_pr_review(owner, repo, pr_number, data)
|
||||
result = {
|
||||
"success": True,
|
||||
"message": f"Review {event} added to pull request #{pr_number}",
|
||||
"review": review,
|
||||
}
|
||||
return json.dumps(result, indent=2)
|
||||
|
||||
async def request_pr_reviewers(
|
||||
client: GiteaClient, owner: str, repo: str, pr_number: int, reviewers: list[str]
|
||||
) -> str:
|
||||
"""Request pull request reviewers"""
|
||||
data = {"reviewers": reviewers}
|
||||
reviewers_result = await client.request_pr_reviewers(owner, repo, pr_number, data)
|
||||
result = {
|
||||
"success": True,
|
||||
"message": f"Reviewers requested for pull request #{pr_number}",
|
||||
"result": reviewers_result,
|
||||
}
|
||||
return json.dumps(result, indent=2)
|
||||
709
plugins/gitea/handlers/repositories.py
Normal file
709
plugins/gitea/handlers/repositories.py
Normal file
@@ -0,0 +1,709 @@
|
||||
"""Repository Handler - manages Gitea repositories, branches, tags, and files"""
|
||||
|
||||
import json
|
||||
from typing import Any
|
||||
|
||||
from plugins.gitea.client import GiteaClient
|
||||
|
||||
def get_tool_specifications() -> list[dict[str, Any]]:
|
||||
"""Return tool specifications for ToolGenerator"""
|
||||
return [
|
||||
# === REPOSITORIES ===
|
||||
{
|
||||
"name": "list_repositories",
|
||||
"method_name": "list_repositories",
|
||||
"description": "List Gitea repositories for a user/organization or current user. Returns repository list with metadata.",
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"owner": {
|
||||
"anyOf": [{"type": "string"}, {"type": "null"}],
|
||||
"description": "Repository owner username/org (null for current user repos)",
|
||||
},
|
||||
"type": {
|
||||
"type": "string",
|
||||
"description": "Filter by repository type",
|
||||
"enum": ["all", "owner", "collaborative", "member"],
|
||||
"default": "all",
|
||||
},
|
||||
"page": {
|
||||
"type": "integer",
|
||||
"description": "Page number",
|
||||
"default": 1,
|
||||
"minimum": 1,
|
||||
},
|
||||
"limit": {
|
||||
"type": "integer",
|
||||
"description": "Items per page (1-100)",
|
||||
"default": 30,
|
||||
"minimum": 1,
|
||||
"maximum": 100,
|
||||
},
|
||||
},
|
||||
},
|
||||
"scope": "read",
|
||||
},
|
||||
{
|
||||
"name": "get_repository",
|
||||
"method_name": "get_repository",
|
||||
"description": "Get details of a specific Gitea repository. Returns complete repository information.",
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"owner": {
|
||||
"type": "string",
|
||||
"description": "Repository owner username/org",
|
||||
"minLength": 1,
|
||||
},
|
||||
"repo": {"type": "string", "description": "Repository name", "minLength": 1},
|
||||
},
|
||||
"required": ["owner", "repo"],
|
||||
},
|
||||
"scope": "read",
|
||||
},
|
||||
{
|
||||
"name": "create_repository",
|
||||
"method_name": "create_repository",
|
||||
"description": "Create a new Gitea repository. Can create user or organization repository with optional auto-initialization.",
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"name": {
|
||||
"type": "string",
|
||||
"description": "Repository name",
|
||||
"minLength": 1,
|
||||
"maxLength": 100,
|
||||
},
|
||||
"description": {
|
||||
"anyOf": [{"type": "string"}, {"type": "null"}],
|
||||
"description": "Repository description",
|
||||
"maxLength": 500,
|
||||
},
|
||||
"private": {
|
||||
"type": "boolean",
|
||||
"description": "Make repository private",
|
||||
"default": False,
|
||||
},
|
||||
"auto_init": {
|
||||
"type": "boolean",
|
||||
"description": "Initialize with README",
|
||||
"default": False,
|
||||
},
|
||||
"gitignores": {
|
||||
"anyOf": [{"type": "string"}, {"type": "null"}],
|
||||
"description": "Gitignore template name",
|
||||
},
|
||||
"license": {
|
||||
"anyOf": [{"type": "string"}, {"type": "null"}],
|
||||
"description": "License template name",
|
||||
},
|
||||
"readme": {
|
||||
"anyOf": [{"type": "string"}, {"type": "null"}],
|
||||
"description": "Readme template",
|
||||
},
|
||||
"default_branch": {
|
||||
"anyOf": [{"type": "string"}, {"type": "null"}],
|
||||
"description": "Default branch name",
|
||||
},
|
||||
"org": {
|
||||
"anyOf": [{"type": "string"}, {"type": "null"}],
|
||||
"description": "Create under organization (null for user repo)",
|
||||
},
|
||||
},
|
||||
"required": ["name"],
|
||||
},
|
||||
"scope": "write",
|
||||
},
|
||||
{
|
||||
"name": "update_repository",
|
||||
"method_name": "update_repository",
|
||||
"description": "Update Gitea repository settings like name, description, visibility, and features.",
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"owner": {"type": "string", "description": "Repository owner", "minLength": 1},
|
||||
"repo": {"type": "string", "description": "Repository name", "minLength": 1},
|
||||
"name": {
|
||||
"anyOf": [{"type": "string"}, {"type": "null"}],
|
||||
"description": "New repository name",
|
||||
},
|
||||
"description": {
|
||||
"anyOf": [{"type": "string"}, {"type": "null"}],
|
||||
"description": "Repository description",
|
||||
},
|
||||
"website": {
|
||||
"anyOf": [{"type": "string"}, {"type": "null"}],
|
||||
"description": "Repository website",
|
||||
},
|
||||
"private": {
|
||||
"anyOf": [{"type": "boolean"}, {"type": "null"}],
|
||||
"description": "Make repository private",
|
||||
},
|
||||
"archived": {
|
||||
"anyOf": [{"type": "boolean"}, {"type": "null"}],
|
||||
"description": "Archive repository",
|
||||
},
|
||||
"has_issues": {
|
||||
"anyOf": [{"type": "boolean"}, {"type": "null"}],
|
||||
"description": "Enable issues",
|
||||
},
|
||||
"has_wiki": {
|
||||
"anyOf": [{"type": "boolean"}, {"type": "null"}],
|
||||
"description": "Enable wiki",
|
||||
},
|
||||
"default_branch": {
|
||||
"anyOf": [{"type": "string"}, {"type": "null"}],
|
||||
"description": "Default branch",
|
||||
},
|
||||
"allow_merge_commits": {
|
||||
"anyOf": [{"type": "boolean"}, {"type": "null"}],
|
||||
"description": "Allow merge commits",
|
||||
},
|
||||
"allow_rebase": {
|
||||
"anyOf": [{"type": "boolean"}, {"type": "null"}],
|
||||
"description": "Allow rebase",
|
||||
},
|
||||
"allow_squash_merge": {
|
||||
"anyOf": [{"type": "boolean"}, {"type": "null"}],
|
||||
"description": "Allow squash merge",
|
||||
},
|
||||
},
|
||||
"required": ["owner", "repo"],
|
||||
},
|
||||
"scope": "write",
|
||||
},
|
||||
{
|
||||
"name": "delete_repository",
|
||||
"method_name": "delete_repository",
|
||||
"description": "Delete a Gitea repository permanently. This action cannot be undone!",
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"owner": {"type": "string", "description": "Repository owner", "minLength": 1},
|
||||
"repo": {"type": "string", "description": "Repository name", "minLength": 1},
|
||||
},
|
||||
"required": ["owner", "repo"],
|
||||
},
|
||||
"scope": "admin",
|
||||
},
|
||||
# === BRANCHES ===
|
||||
{
|
||||
"name": "list_branches",
|
||||
"method_name": "list_branches",
|
||||
"description": "List all branches in a Gitea repository with commit information and protection status.",
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"owner": {"type": "string", "description": "Repository owner", "minLength": 1},
|
||||
"repo": {"type": "string", "description": "Repository name", "minLength": 1},
|
||||
"page": {
|
||||
"type": "integer",
|
||||
"description": "Page number",
|
||||
"default": 1,
|
||||
"minimum": 1,
|
||||
},
|
||||
"limit": {
|
||||
"type": "integer",
|
||||
"description": "Items per page (1-100)",
|
||||
"default": 30,
|
||||
"minimum": 1,
|
||||
"maximum": 100,
|
||||
},
|
||||
},
|
||||
"required": ["owner", "repo"],
|
||||
},
|
||||
"scope": "read",
|
||||
},
|
||||
{
|
||||
"name": "get_branch",
|
||||
"method_name": "get_branch",
|
||||
"description": "Get details of a specific branch including latest commit and protection settings.",
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"owner": {"type": "string", "description": "Repository owner", "minLength": 1},
|
||||
"repo": {"type": "string", "description": "Repository name", "minLength": 1},
|
||||
"branch": {"type": "string", "description": "Branch name", "minLength": 1},
|
||||
},
|
||||
"required": ["owner", "repo", "branch"],
|
||||
},
|
||||
"scope": "read",
|
||||
},
|
||||
{
|
||||
"name": "create_branch",
|
||||
"method_name": "create_branch",
|
||||
"description": "Create a new branch in a Gitea repository from existing branch or commit.",
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"owner": {"type": "string", "description": "Repository owner", "minLength": 1},
|
||||
"repo": {"type": "string", "description": "Repository name", "minLength": 1},
|
||||
"new_branch_name": {
|
||||
"type": "string",
|
||||
"description": "New branch name",
|
||||
"minLength": 1,
|
||||
},
|
||||
"old_branch_name": {
|
||||
"anyOf": [{"type": "string"}, {"type": "null"}],
|
||||
"description": "Source branch (default: default branch)",
|
||||
},
|
||||
"old_ref_name": {
|
||||
"anyOf": [{"type": "string"}, {"type": "null"}],
|
||||
"description": "Source commit SHA or ref",
|
||||
},
|
||||
},
|
||||
"required": ["owner", "repo", "new_branch_name"],
|
||||
},
|
||||
"scope": "write",
|
||||
},
|
||||
{
|
||||
"name": "delete_branch",
|
||||
"method_name": "delete_branch",
|
||||
"description": "Delete a branch from a Gitea repository. Cannot delete default branch.",
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"owner": {"type": "string", "description": "Repository owner", "minLength": 1},
|
||||
"repo": {"type": "string", "description": "Repository name", "minLength": 1},
|
||||
"branch": {
|
||||
"type": "string",
|
||||
"description": "Branch name to delete",
|
||||
"minLength": 1,
|
||||
},
|
||||
},
|
||||
"required": ["owner", "repo", "branch"],
|
||||
},
|
||||
"scope": "write",
|
||||
},
|
||||
# === TAGS ===
|
||||
{
|
||||
"name": "list_tags",
|
||||
"method_name": "list_tags",
|
||||
"description": "List all tags in a Gitea repository with commit information.",
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"owner": {"type": "string", "description": "Repository owner", "minLength": 1},
|
||||
"repo": {"type": "string", "description": "Repository name", "minLength": 1},
|
||||
"page": {
|
||||
"type": "integer",
|
||||
"description": "Page number",
|
||||
"default": 1,
|
||||
"minimum": 1,
|
||||
},
|
||||
"limit": {
|
||||
"type": "integer",
|
||||
"description": "Items per page (1-100)",
|
||||
"default": 30,
|
||||
"minimum": 1,
|
||||
"maximum": 100,
|
||||
},
|
||||
},
|
||||
"required": ["owner", "repo"],
|
||||
},
|
||||
"scope": "read",
|
||||
},
|
||||
{
|
||||
"name": "create_tag",
|
||||
"method_name": "create_tag",
|
||||
"description": "Create a new tag in a Gitea repository at specific commit.",
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"owner": {"type": "string", "description": "Repository owner", "minLength": 1},
|
||||
"repo": {"type": "string", "description": "Repository name", "minLength": 1},
|
||||
"tag_name": {"type": "string", "description": "Tag name", "minLength": 1},
|
||||
"message": {
|
||||
"anyOf": [{"type": "string"}, {"type": "null"}],
|
||||
"description": "Tag message (annotated tag)",
|
||||
},
|
||||
"target": {
|
||||
"anyOf": [{"type": "string"}, {"type": "null"}],
|
||||
"description": "Target commit SHA (default: latest)",
|
||||
},
|
||||
},
|
||||
"required": ["owner", "repo", "tag_name"],
|
||||
},
|
||||
"scope": "write",
|
||||
},
|
||||
{
|
||||
"name": "delete_tag",
|
||||
"method_name": "delete_tag",
|
||||
"description": "Delete a tag from a Gitea repository.",
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"owner": {"type": "string", "description": "Repository owner", "minLength": 1},
|
||||
"repo": {"type": "string", "description": "Repository name", "minLength": 1},
|
||||
"tag": {"type": "string", "description": "Tag name to delete", "minLength": 1},
|
||||
},
|
||||
"required": ["owner", "repo", "tag"],
|
||||
},
|
||||
"scope": "write",
|
||||
},
|
||||
# === FILES ===
|
||||
{
|
||||
"name": "get_file",
|
||||
"method_name": "get_file",
|
||||
"description": "Get file contents from a Gitea repository. Returns file content (Base64 encoded) and metadata.",
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"owner": {"type": "string", "description": "Repository owner", "minLength": 1},
|
||||
"repo": {"type": "string", "description": "Repository name", "minLength": 1},
|
||||
"path": {
|
||||
"type": "string",
|
||||
"description": "File path in repository",
|
||||
"minLength": 1,
|
||||
},
|
||||
"ref": {
|
||||
"anyOf": [{"type": "string"}, {"type": "null"}],
|
||||
"description": "Branch/tag/commit (default: default branch)",
|
||||
},
|
||||
},
|
||||
"required": ["owner", "repo", "path"],
|
||||
},
|
||||
"scope": "read",
|
||||
},
|
||||
{
|
||||
"name": "create_file",
|
||||
"method_name": "create_file",
|
||||
"description": "Create a new file in a Gitea repository with commit message.",
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"owner": {"type": "string", "description": "Repository owner", "minLength": 1},
|
||||
"repo": {"type": "string", "description": "Repository name", "minLength": 1},
|
||||
"path": {
|
||||
"type": "string",
|
||||
"description": "File path to create",
|
||||
"minLength": 1,
|
||||
},
|
||||
"content": {
|
||||
"type": "string",
|
||||
"description": "File content (will be Base64 encoded automatically unless content_is_base64=true)",
|
||||
},
|
||||
"message": {"type": "string", "description": "Commit message", "minLength": 1},
|
||||
"branch": {
|
||||
"anyOf": [{"type": "string"}, {"type": "null"}],
|
||||
"description": "Target branch (default: default branch)",
|
||||
},
|
||||
"new_branch": {
|
||||
"anyOf": [{"type": "string"}, {"type": "null"}],
|
||||
"description": "Create new branch for this commit",
|
||||
},
|
||||
"author_name": {
|
||||
"anyOf": [{"type": "string"}, {"type": "null"}],
|
||||
"description": "Author name",
|
||||
},
|
||||
"author_email": {
|
||||
"anyOf": [{"type": "string"}, {"type": "null"}],
|
||||
"description": "Author email",
|
||||
},
|
||||
"content_is_base64": {
|
||||
"type": "boolean",
|
||||
"description": "Set to true if content is already Base64 encoded (skip automatic encoding)",
|
||||
"default": False,
|
||||
},
|
||||
},
|
||||
"required": ["owner", "repo", "path", "content", "message"],
|
||||
},
|
||||
"scope": "write",
|
||||
},
|
||||
{
|
||||
"name": "update_file",
|
||||
"method_name": "update_file",
|
||||
"description": "Update an existing file in a Gitea repository. Requires current file SHA for conflict detection.",
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"owner": {"type": "string", "description": "Repository owner", "minLength": 1},
|
||||
"repo": {"type": "string", "description": "Repository name", "minLength": 1},
|
||||
"path": {
|
||||
"type": "string",
|
||||
"description": "File path to update",
|
||||
"minLength": 1,
|
||||
},
|
||||
"content": {
|
||||
"type": "string",
|
||||
"description": "New file content (will be Base64 encoded automatically)",
|
||||
},
|
||||
"sha": {
|
||||
"type": "string",
|
||||
"description": "Current file SHA (for conflict detection)",
|
||||
"minLength": 1,
|
||||
},
|
||||
"message": {"type": "string", "description": "Commit message", "minLength": 1},
|
||||
"branch": {
|
||||
"anyOf": [{"type": "string"}, {"type": "null"}],
|
||||
"description": "Target branch (default: default branch)",
|
||||
},
|
||||
"new_branch": {
|
||||
"anyOf": [{"type": "string"}, {"type": "null"}],
|
||||
"description": "Create new branch for this commit",
|
||||
},
|
||||
"content_is_base64": {
|
||||
"type": "boolean",
|
||||
"description": "Set to true if content is already Base64 encoded (skip automatic encoding)",
|
||||
"default": False,
|
||||
},
|
||||
},
|
||||
"required": ["owner", "repo", "path", "content", "sha", "message"],
|
||||
},
|
||||
"scope": "write",
|
||||
},
|
||||
{
|
||||
"name": "delete_file",
|
||||
"method_name": "delete_file",
|
||||
"description": "Delete a file from a Gitea repository. Requires current file SHA for conflict detection.",
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"owner": {"type": "string", "description": "Repository owner", "minLength": 1},
|
||||
"repo": {"type": "string", "description": "Repository name", "minLength": 1},
|
||||
"path": {
|
||||
"type": "string",
|
||||
"description": "File path to delete",
|
||||
"minLength": 1,
|
||||
},
|
||||
"sha": {
|
||||
"type": "string",
|
||||
"description": "Current file SHA (for conflict detection)",
|
||||
"minLength": 1,
|
||||
},
|
||||
"message": {"type": "string", "description": "Commit message", "minLength": 1},
|
||||
"branch": {
|
||||
"anyOf": [{"type": "string"}, {"type": "null"}],
|
||||
"description": "Target branch (default: default branch)",
|
||||
},
|
||||
},
|
||||
"required": ["owner", "repo", "path", "sha", "message"],
|
||||
},
|
||||
"scope": "write",
|
||||
},
|
||||
]
|
||||
|
||||
async def list_repositories(
|
||||
client: GiteaClient, owner: str | None = None, type: str = "all", page: int = 1, limit: int = 30
|
||||
) -> str:
|
||||
"""List Gitea repositories"""
|
||||
repos = await client.list_repositories(owner=owner, page=page, limit=limit)
|
||||
result = {"success": True, "count": len(repos), "repositories": repos}
|
||||
return json.dumps(result, indent=2)
|
||||
|
||||
async def get_repository(client: GiteaClient, owner: str, repo: str) -> str:
|
||||
"""Get repository details"""
|
||||
repository = await client.get_repository(owner, repo)
|
||||
result = {"success": True, "repository": repository}
|
||||
return json.dumps(result, indent=2)
|
||||
|
||||
async def create_repository(
|
||||
client: GiteaClient,
|
||||
name: str,
|
||||
description: str | None = None,
|
||||
private: bool = False,
|
||||
auto_init: bool = False,
|
||||
gitignores: str | None = None,
|
||||
license: str | None = None,
|
||||
readme: str | None = None,
|
||||
default_branch: str | None = None,
|
||||
org: str | None = None,
|
||||
) -> str:
|
||||
"""Create a new repository"""
|
||||
data = {
|
||||
"name": name,
|
||||
"description": description,
|
||||
"private": private,
|
||||
"auto_init": auto_init,
|
||||
"gitignores": gitignores,
|
||||
"license": license,
|
||||
"readme": readme,
|
||||
"default_branch": default_branch,
|
||||
}
|
||||
repository = await client.create_repository(data, org=org)
|
||||
result = {
|
||||
"success": True,
|
||||
"message": f"Repository '{name}' created successfully",
|
||||
"repository": repository,
|
||||
}
|
||||
return json.dumps(result, indent=2)
|
||||
|
||||
async def update_repository(client: GiteaClient, owner: str, repo: str, **kwargs) -> str:
|
||||
"""Update repository settings"""
|
||||
# Build update data from kwargs
|
||||
data = {k: v for k, v in kwargs.items() if v is not None and k not in ["owner", "repo"]}
|
||||
|
||||
repository = await client.update_repository(owner, repo, data)
|
||||
result = {
|
||||
"success": True,
|
||||
"message": f"Repository '{owner}/{repo}' updated successfully",
|
||||
"repository": repository,
|
||||
}
|
||||
return json.dumps(result, indent=2)
|
||||
|
||||
async def delete_repository(client: GiteaClient, owner: str, repo: str) -> str:
|
||||
"""Delete a repository"""
|
||||
await client.delete_repository(owner, repo)
|
||||
result = {"success": True, "message": f"Repository '{owner}/{repo}' deleted successfully"}
|
||||
return json.dumps(result, indent=2)
|
||||
|
||||
# Branch operations
|
||||
async def list_branches(
|
||||
client: GiteaClient, owner: str, repo: str, page: int = 1, limit: int = 30
|
||||
) -> str:
|
||||
"""List repository branches"""
|
||||
branches = await client.list_branches(owner, repo, page=page, limit=limit)
|
||||
result = {"success": True, "count": len(branches), "branches": branches}
|
||||
return json.dumps(result, indent=2)
|
||||
|
||||
async def get_branch(client: GiteaClient, owner: str, repo: str, branch: str) -> str:
|
||||
"""Get branch details"""
|
||||
branch_info = await client.get_branch(owner, repo, branch)
|
||||
result = {"success": True, "branch": branch_info}
|
||||
return json.dumps(result, indent=2)
|
||||
|
||||
async def create_branch(
|
||||
client: GiteaClient,
|
||||
owner: str,
|
||||
repo: str,
|
||||
new_branch_name: str,
|
||||
old_branch_name: str | None = None,
|
||||
old_ref_name: str | None = None,
|
||||
) -> str:
|
||||
"""Create a new branch"""
|
||||
data = {
|
||||
"new_branch_name": new_branch_name,
|
||||
"old_branch_name": old_branch_name,
|
||||
"old_ref_name": old_ref_name,
|
||||
}
|
||||
branch = await client.create_branch(owner, repo, data)
|
||||
result = {
|
||||
"success": True,
|
||||
"message": f"Branch '{new_branch_name}' created successfully",
|
||||
"branch": branch,
|
||||
}
|
||||
return json.dumps(result, indent=2)
|
||||
|
||||
async def delete_branch(client: GiteaClient, owner: str, repo: str, branch: str) -> str:
|
||||
"""Delete a branch"""
|
||||
await client.delete_branch(owner, repo, branch)
|
||||
result = {"success": True, "message": f"Branch '{branch}' deleted successfully"}
|
||||
return json.dumps(result, indent=2)
|
||||
|
||||
# Tag operations
|
||||
async def list_tags(
|
||||
client: GiteaClient, owner: str, repo: str, page: int = 1, limit: int = 30
|
||||
) -> str:
|
||||
"""List repository tags"""
|
||||
tags = await client.list_tags(owner, repo, page=page, limit=limit)
|
||||
result = {"success": True, "count": len(tags), "tags": tags}
|
||||
return json.dumps(result, indent=2)
|
||||
|
||||
async def create_tag(
|
||||
client: GiteaClient,
|
||||
owner: str,
|
||||
repo: str,
|
||||
tag_name: str,
|
||||
message: str | None = None,
|
||||
target: str | None = None,
|
||||
) -> str:
|
||||
"""Create a new tag"""
|
||||
data = {"tag_name": tag_name, "message": message, "target": target}
|
||||
tag = await client.create_tag(owner, repo, data)
|
||||
result = {"success": True, "message": f"Tag '{tag_name}' created successfully", "tag": tag}
|
||||
return json.dumps(result, indent=2)
|
||||
|
||||
async def delete_tag(client: GiteaClient, owner: str, repo: str, tag: str) -> str:
|
||||
"""Delete a tag"""
|
||||
await client.delete_tag(owner, repo, tag)
|
||||
result = {"success": True, "message": f"Tag '{tag}' deleted successfully"}
|
||||
return json.dumps(result, indent=2)
|
||||
|
||||
# File operations
|
||||
async def get_file(
|
||||
client: GiteaClient, owner: str, repo: str, path: str, ref: str | None = None
|
||||
) -> str:
|
||||
"""Get file contents"""
|
||||
file_data = await client.get_file(owner, repo, path, ref=ref)
|
||||
result = {"success": True, "file": file_data}
|
||||
return json.dumps(result, indent=2)
|
||||
|
||||
async def create_file(
|
||||
client: GiteaClient,
|
||||
owner: str,
|
||||
repo: str,
|
||||
path: str,
|
||||
content: str,
|
||||
message: str,
|
||||
branch: str | None = None,
|
||||
new_branch: str | None = None,
|
||||
author_name: str | None = None,
|
||||
author_email: str | None = None,
|
||||
content_is_base64: bool = False,
|
||||
) -> str:
|
||||
"""Create a new file"""
|
||||
data = {
|
||||
"content": content,
|
||||
"message": message,
|
||||
"branch": branch,
|
||||
"new_branch": new_branch,
|
||||
"content_is_base64": content_is_base64,
|
||||
"author": {},
|
||||
}
|
||||
|
||||
if author_name:
|
||||
data["author"]["name"] = author_name
|
||||
if author_email:
|
||||
data["author"]["email"] = author_email
|
||||
|
||||
file_result = await client.create_file(owner, repo, path, data)
|
||||
result = {
|
||||
"success": True,
|
||||
"message": f"File '{path}' created successfully",
|
||||
"file": file_result,
|
||||
}
|
||||
return json.dumps(result, indent=2)
|
||||
|
||||
async def update_file(
|
||||
client: GiteaClient,
|
||||
owner: str,
|
||||
repo: str,
|
||||
path: str,
|
||||
content: str,
|
||||
sha: str,
|
||||
message: str,
|
||||
branch: str | None = None,
|
||||
new_branch: str | None = None,
|
||||
content_is_base64: bool = False,
|
||||
) -> str:
|
||||
"""Update an existing file"""
|
||||
data = {
|
||||
"content": content,
|
||||
"sha": sha,
|
||||
"message": message,
|
||||
"branch": branch,
|
||||
"new_branch": new_branch,
|
||||
"content_is_base64": content_is_base64,
|
||||
}
|
||||
|
||||
file_result = await client.update_file(owner, repo, path, data)
|
||||
result = {
|
||||
"success": True,
|
||||
"message": f"File '{path}' updated successfully",
|
||||
"file": file_result,
|
||||
}
|
||||
return json.dumps(result, indent=2)
|
||||
|
||||
async def delete_file(
|
||||
client: GiteaClient,
|
||||
owner: str,
|
||||
repo: str,
|
||||
path: str,
|
||||
sha: str,
|
||||
message: str,
|
||||
branch: str | None = None,
|
||||
) -> str:
|
||||
"""Delete a file from repository"""
|
||||
await client.delete_file(owner, repo, path, sha, message, branch)
|
||||
result = {"success": True, "message": f"File '{path}' deleted successfully"}
|
||||
return json.dumps(result, indent=2)
|
||||
245
plugins/gitea/handlers/users.py
Normal file
245
plugins/gitea/handlers/users.py
Normal file
@@ -0,0 +1,245 @@
|
||||
"""User & Organization Handler - manages Gitea users, organizations, and teams"""
|
||||
|
||||
import json
|
||||
from typing import Any
|
||||
|
||||
from plugins.gitea.client import GiteaClient
|
||||
|
||||
def get_tool_specifications() -> list[dict[str, Any]]:
|
||||
"""Return tool specifications for ToolGenerator"""
|
||||
return [
|
||||
# === USERS ===
|
||||
{
|
||||
"name": "get_user",
|
||||
"method_name": "get_user",
|
||||
"description": "Get information about a Gitea user by username.",
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"username": {
|
||||
"type": "string",
|
||||
"description": "Username to look up",
|
||||
"minLength": 1,
|
||||
}
|
||||
},
|
||||
"required": ["username"],
|
||||
},
|
||||
"scope": "read",
|
||||
},
|
||||
{
|
||||
"name": "list_user_repos",
|
||||
"method_name": "list_user_repos",
|
||||
"description": "List all public repositories of a Gitea user.",
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"username": {"type": "string", "description": "Username", "minLength": 1},
|
||||
"page": {
|
||||
"type": "integer",
|
||||
"description": "Page number",
|
||||
"default": 1,
|
||||
"minimum": 1,
|
||||
},
|
||||
"limit": {
|
||||
"type": "integer",
|
||||
"description": "Items per page (1-100)",
|
||||
"default": 30,
|
||||
"minimum": 1,
|
||||
"maximum": 100,
|
||||
},
|
||||
},
|
||||
"required": ["username"],
|
||||
},
|
||||
"scope": "read",
|
||||
},
|
||||
{
|
||||
"name": "search_users",
|
||||
"method_name": "search_users",
|
||||
"description": "Search for Gitea users by query or user ID.",
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"q": {
|
||||
"anyOf": [{"type": "string"}, {"type": "null"}],
|
||||
"description": "Search query (username or full name)",
|
||||
},
|
||||
"uid": {
|
||||
"anyOf": [{"type": "integer"}, {"type": "null"}],
|
||||
"description": "User ID to search for",
|
||||
},
|
||||
},
|
||||
},
|
||||
"scope": "read",
|
||||
},
|
||||
# === ORGANIZATIONS ===
|
||||
{
|
||||
"name": "list_organizations",
|
||||
"method_name": "list_organizations",
|
||||
"description": "List organizations for the current authenticated user.",
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"page": {
|
||||
"type": "integer",
|
||||
"description": "Page number",
|
||||
"default": 1,
|
||||
"minimum": 1,
|
||||
},
|
||||
"limit": {
|
||||
"type": "integer",
|
||||
"description": "Items per page (1-100)",
|
||||
"default": 30,
|
||||
"minimum": 1,
|
||||
"maximum": 100,
|
||||
},
|
||||
},
|
||||
},
|
||||
"scope": "read",
|
||||
},
|
||||
{
|
||||
"name": "get_organization",
|
||||
"method_name": "get_organization",
|
||||
"description": "Get information about a Gitea organization by name.",
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"org": {"type": "string", "description": "Organization name", "minLength": 1}
|
||||
},
|
||||
"required": ["org"],
|
||||
},
|
||||
"scope": "read",
|
||||
},
|
||||
{
|
||||
"name": "list_org_repos",
|
||||
"method_name": "list_org_repos",
|
||||
"description": "List all repositories of an organization.",
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"org": {"type": "string", "description": "Organization name", "minLength": 1},
|
||||
"page": {
|
||||
"type": "integer",
|
||||
"description": "Page number",
|
||||
"default": 1,
|
||||
"minimum": 1,
|
||||
},
|
||||
"limit": {
|
||||
"type": "integer",
|
||||
"description": "Items per page (1-100)",
|
||||
"default": 30,
|
||||
"minimum": 1,
|
||||
"maximum": 100,
|
||||
},
|
||||
},
|
||||
"required": ["org"],
|
||||
},
|
||||
"scope": "read",
|
||||
},
|
||||
# === TEAMS ===
|
||||
{
|
||||
"name": "list_org_teams",
|
||||
"method_name": "list_org_teams",
|
||||
"description": "List all teams in an organization.",
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"org": {"type": "string", "description": "Organization name", "minLength": 1},
|
||||
"page": {
|
||||
"type": "integer",
|
||||
"description": "Page number",
|
||||
"default": 1,
|
||||
"minimum": 1,
|
||||
},
|
||||
"limit": {
|
||||
"type": "integer",
|
||||
"description": "Items per page (1-100)",
|
||||
"default": 30,
|
||||
"minimum": 1,
|
||||
"maximum": 100,
|
||||
},
|
||||
},
|
||||
"required": ["org"],
|
||||
},
|
||||
"scope": "read",
|
||||
},
|
||||
{
|
||||
"name": "list_team_members",
|
||||
"method_name": "list_team_members",
|
||||
"description": "List all members of a team.",
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"team_id": {"type": "integer", "description": "Team ID", "minimum": 1},
|
||||
"page": {
|
||||
"type": "integer",
|
||||
"description": "Page number",
|
||||
"default": 1,
|
||||
"minimum": 1,
|
||||
},
|
||||
"limit": {
|
||||
"type": "integer",
|
||||
"description": "Items per page (1-100)",
|
||||
"default": 30,
|
||||
"minimum": 1,
|
||||
"maximum": 100,
|
||||
},
|
||||
},
|
||||
"required": ["team_id"],
|
||||
},
|
||||
"scope": "read",
|
||||
},
|
||||
]
|
||||
|
||||
async def get_user(client: GiteaClient, username: str) -> str:
|
||||
"""Get user information"""
|
||||
user = await client.get_user(username)
|
||||
result = {"success": True, "user": user}
|
||||
return json.dumps(result, indent=2)
|
||||
|
||||
async def list_user_repos(
|
||||
client: GiteaClient, username: str, page: int = 1, limit: int = 30
|
||||
) -> str:
|
||||
"""List user repositories"""
|
||||
repos = await client.list_user_repos(username, page=page, limit=limit)
|
||||
result = {"success": True, "count": len(repos), "repositories": repos}
|
||||
return json.dumps(result, indent=2)
|
||||
|
||||
async def search_users(client: GiteaClient, q: str | None = None, uid: int | None = None) -> str:
|
||||
"""Search users"""
|
||||
users = await client.search_users(query=q, uid=uid)
|
||||
result = {"success": True, "count": len(users), "users": users}
|
||||
return json.dumps(result, indent=2)
|
||||
|
||||
# Organization operations
|
||||
async def list_organizations(client: GiteaClient, page: int = 1, limit: int = 30) -> str:
|
||||
"""List organizations"""
|
||||
orgs = await client.list_organizations(page=page, limit=limit)
|
||||
result = {"success": True, "count": len(orgs), "organizations": orgs}
|
||||
return json.dumps(result, indent=2)
|
||||
|
||||
async def get_organization(client: GiteaClient, org: str) -> str:
|
||||
"""Get organization information"""
|
||||
organization = await client.get_organization(org)
|
||||
result = {"success": True, "organization": organization}
|
||||
return json.dumps(result, indent=2)
|
||||
|
||||
async def list_org_repos(client: GiteaClient, org: str, page: int = 1, limit: int = 30) -> str:
|
||||
"""List organization repositories"""
|
||||
repos = await client.list_org_repos(org, page=page, limit=limit)
|
||||
result = {"success": True, "count": len(repos), "repositories": repos}
|
||||
return json.dumps(result, indent=2)
|
||||
|
||||
# Team operations
|
||||
async def list_org_teams(client: GiteaClient, org: str, page: int = 1, limit: int = 30) -> str:
|
||||
"""List organization teams"""
|
||||
teams = await client.list_org_teams(org, page=page, limit=limit)
|
||||
result = {"success": True, "count": len(teams), "teams": teams}
|
||||
return json.dumps(result, indent=2)
|
||||
|
||||
async def list_team_members(
|
||||
client: GiteaClient, team_id: int, page: int = 1, limit: int = 30
|
||||
) -> str:
|
||||
"""List team members"""
|
||||
members = await client.list_team_members(team_id, page=page, limit=limit)
|
||||
result = {"success": True, "count": len(members), "members": members}
|
||||
return json.dumps(result, indent=2)
|
||||
216
plugins/gitea/handlers/webhooks.py
Normal file
216
plugins/gitea/handlers/webhooks.py
Normal file
@@ -0,0 +1,216 @@
|
||||
"""Webhook Handler - manages Gitea webhooks for repositories"""
|
||||
|
||||
import json
|
||||
from typing import Any
|
||||
|
||||
from plugins.gitea.client import GiteaClient
|
||||
|
||||
def get_tool_specifications() -> list[dict[str, Any]]:
|
||||
"""Return tool specifications for ToolGenerator"""
|
||||
return [
|
||||
# === WEBHOOKS ===
|
||||
{
|
||||
"name": "list_webhooks",
|
||||
"method_name": "list_webhooks",
|
||||
"description": "List all webhooks configured for a repository.",
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"owner": {"type": "string", "description": "Repository owner", "minLength": 1},
|
||||
"repo": {"type": "string", "description": "Repository name", "minLength": 1},
|
||||
},
|
||||
"required": ["owner", "repo"],
|
||||
},
|
||||
"scope": "read",
|
||||
},
|
||||
{
|
||||
"name": "create_webhook",
|
||||
"method_name": "create_webhook",
|
||||
"description": "Create a new webhook for a repository to receive event notifications.",
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"owner": {"type": "string", "description": "Repository owner", "minLength": 1},
|
||||
"repo": {"type": "string", "description": "Repository name", "minLength": 1},
|
||||
"url": {
|
||||
"type": "string",
|
||||
"description": "Webhook URL to send events to",
|
||||
"format": "uri",
|
||||
},
|
||||
"events": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"create",
|
||||
"delete",
|
||||
"fork",
|
||||
"push",
|
||||
"issues",
|
||||
"issue_assign",
|
||||
"issue_label",
|
||||
"issue_milestone",
|
||||
"issue_comment",
|
||||
"pull_request",
|
||||
"pull_request_assign",
|
||||
"pull_request_label",
|
||||
"pull_request_milestone",
|
||||
"pull_request_comment",
|
||||
"pull_request_review_approved",
|
||||
"pull_request_review_rejected",
|
||||
"pull_request_review_comment",
|
||||
"pull_request_sync",
|
||||
"wiki",
|
||||
"repository",
|
||||
"release",
|
||||
],
|
||||
},
|
||||
"description": "List of events to trigger webhook",
|
||||
"minItems": 1,
|
||||
},
|
||||
"content_type": {
|
||||
"type": "string",
|
||||
"description": "Content type for webhook payload",
|
||||
"enum": ["json", "form"],
|
||||
"default": "json",
|
||||
},
|
||||
"secret": {
|
||||
"anyOf": [{"type": "string"}, {"type": "null"}],
|
||||
"description": "Secret for webhook signature verification",
|
||||
},
|
||||
"active": {
|
||||
"type": "boolean",
|
||||
"description": "Activate webhook immediately",
|
||||
"default": True,
|
||||
},
|
||||
"type": {
|
||||
"type": "string",
|
||||
"description": "Webhook type",
|
||||
"enum": [
|
||||
"gitea",
|
||||
"gogs",
|
||||
"slack",
|
||||
"discord",
|
||||
"dingtalk",
|
||||
"telegram",
|
||||
"msteams",
|
||||
"feishu",
|
||||
"wechatwork",
|
||||
"packagist",
|
||||
],
|
||||
"default": "gitea",
|
||||
},
|
||||
},
|
||||
"required": ["owner", "repo", "url", "events"],
|
||||
},
|
||||
"scope": "admin",
|
||||
},
|
||||
{
|
||||
"name": "delete_webhook",
|
||||
"method_name": "delete_webhook",
|
||||
"description": "Delete a webhook from a repository.",
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"owner": {"type": "string", "description": "Repository owner", "minLength": 1},
|
||||
"repo": {"type": "string", "description": "Repository name", "minLength": 1},
|
||||
"webhook_id": {
|
||||
"type": "integer",
|
||||
"description": "Webhook ID to delete",
|
||||
"minimum": 1,
|
||||
},
|
||||
},
|
||||
"required": ["owner", "repo", "webhook_id"],
|
||||
},
|
||||
"scope": "admin",
|
||||
},
|
||||
{
|
||||
"name": "test_webhook",
|
||||
"method_name": "test_webhook",
|
||||
"description": "Send a test payload to a webhook to verify it's working.",
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"owner": {"type": "string", "description": "Repository owner", "minLength": 1},
|
||||
"repo": {"type": "string", "description": "Repository name", "minLength": 1},
|
||||
"webhook_id": {
|
||||
"type": "integer",
|
||||
"description": "Webhook ID to test",
|
||||
"minimum": 1,
|
||||
},
|
||||
},
|
||||
"required": ["owner", "repo", "webhook_id"],
|
||||
},
|
||||
"scope": "admin",
|
||||
},
|
||||
{
|
||||
"name": "get_webhook",
|
||||
"method_name": "get_webhook",
|
||||
"description": "Get details of a specific webhook.",
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"owner": {"type": "string", "description": "Repository owner", "minLength": 1},
|
||||
"repo": {"type": "string", "description": "Repository name", "minLength": 1},
|
||||
"webhook_id": {"type": "integer", "description": "Webhook ID", "minimum": 1},
|
||||
},
|
||||
"required": ["owner", "repo", "webhook_id"],
|
||||
},
|
||||
"scope": "read",
|
||||
},
|
||||
]
|
||||
|
||||
async def list_webhooks(client: GiteaClient, owner: str, repo: str) -> str:
|
||||
"""List repository webhooks"""
|
||||
webhooks = await client.list_webhooks(owner, repo)
|
||||
result = {"success": True, "count": len(webhooks), "webhooks": webhooks}
|
||||
return json.dumps(result, indent=2)
|
||||
|
||||
async def create_webhook(
|
||||
client: GiteaClient,
|
||||
owner: str,
|
||||
repo: str,
|
||||
url: str,
|
||||
events: list[str],
|
||||
content_type: str = "json",
|
||||
secret: str | None = None,
|
||||
active: bool = True,
|
||||
type: str = "gitea",
|
||||
) -> str:
|
||||
"""Create a webhook"""
|
||||
# Build webhook configuration
|
||||
config = {"url": url, "content_type": content_type}
|
||||
if secret:
|
||||
config["secret"] = secret
|
||||
|
||||
data = {"type": type, "config": config, "events": events, "active": active}
|
||||
|
||||
webhook = await client.create_webhook(owner, repo, data)
|
||||
result = {
|
||||
"success": True,
|
||||
"message": f"Webhook created successfully for {owner}/{repo}",
|
||||
"webhook": webhook,
|
||||
}
|
||||
return json.dumps(result, indent=2)
|
||||
|
||||
async def delete_webhook(client: GiteaClient, owner: str, repo: str, webhook_id: int) -> str:
|
||||
"""Delete a webhook"""
|
||||
await client.delete_webhook(owner, repo, webhook_id)
|
||||
result = {"success": True, "message": f"Webhook {webhook_id} deleted successfully"}
|
||||
return json.dumps(result, indent=2)
|
||||
|
||||
async def test_webhook(client: GiteaClient, owner: str, repo: str, webhook_id: int) -> str:
|
||||
"""Test a webhook"""
|
||||
test_result = await client.test_webhook(owner, repo, webhook_id)
|
||||
result = {
|
||||
"success": True,
|
||||
"message": f"Test payload sent to webhook {webhook_id}",
|
||||
"result": test_result,
|
||||
}
|
||||
return json.dumps(result, indent=2)
|
||||
|
||||
async def get_webhook(client: GiteaClient, owner: str, repo: str, webhook_id: int) -> str:
|
||||
"""Get webhook details"""
|
||||
webhook = await client.get_webhook(owner, repo, webhook_id)
|
||||
result = {"success": True, "webhook": webhook}
|
||||
return json.dumps(result, indent=2)
|
||||
161
plugins/gitea/plugin.py
Normal file
161
plugins/gitea/plugin.py
Normal file
@@ -0,0 +1,161 @@
|
||||
"""
|
||||
Gitea Plugin - Clean Architecture
|
||||
|
||||
Complete Gitea management through REST API with OAuth support.
|
||||
Modular handlers for better organization and maintainability.
|
||||
"""
|
||||
|
||||
from typing import Any
|
||||
|
||||
from plugins.base import BasePlugin
|
||||
from plugins.gitea import handlers
|
||||
from plugins.gitea.client import GiteaClient
|
||||
|
||||
class GiteaPlugin(BasePlugin):
|
||||
"""
|
||||
Gitea project plugin - Clean architecture.
|
||||
|
||||
Provides comprehensive Gitea management capabilities including:
|
||||
- Repository management (CRUD, branches, tags, files)
|
||||
- Issue tracking (issues, labels, milestones, comments)
|
||||
- Pull requests (PRs, reviews, merges)
|
||||
- User and organization management
|
||||
- Webhook configuration
|
||||
- OAuth integration
|
||||
"""
|
||||
|
||||
@staticmethod
|
||||
def get_plugin_name() -> str:
|
||||
"""Return plugin type identifier"""
|
||||
return "gitea"
|
||||
|
||||
@staticmethod
|
||||
def get_required_config_keys() -> list[str]:
|
||||
"""Return required configuration keys"""
|
||||
return ["url"] # Token is optional (can use OAuth)
|
||||
|
||||
def __init__(self, config: dict[str, Any], project_id: str | None = None):
|
||||
"""
|
||||
Initialize Gitea plugin with handlers.
|
||||
|
||||
Args:
|
||||
config: Configuration dictionary containing:
|
||||
- url: Gitea instance URL
|
||||
- token: (Optional) Personal access token
|
||||
- oauth_enabled: (Optional) Whether OAuth is enabled
|
||||
project_id: Optional project ID (auto-generated if not provided)
|
||||
"""
|
||||
super().__init__(config, project_id=project_id)
|
||||
|
||||
# Create Gitea API client
|
||||
self.client = GiteaClient(
|
||||
site_url=config["url"],
|
||||
token=config.get("token"),
|
||||
oauth_enabled=config.get("oauth_enabled", False),
|
||||
)
|
||||
|
||||
# No handler instances needed in Option B architecture
|
||||
# Tools are delegated directly to handler functions
|
||||
|
||||
@staticmethod
|
||||
def get_tool_specifications() -> list[dict[str, Any]]:
|
||||
"""
|
||||
Return all tool specifications for ToolGenerator.
|
||||
|
||||
This method is called by ToolGenerator to create unified tools
|
||||
with site parameter routing.
|
||||
|
||||
Returns:
|
||||
List of tool specification dictionaries
|
||||
"""
|
||||
specs = []
|
||||
|
||||
# Collect specifications from all handlers
|
||||
specs.extend(handlers.repositories.get_tool_specifications())
|
||||
specs.extend(handlers.issues.get_tool_specifications())
|
||||
specs.extend(handlers.pull_requests.get_tool_specifications())
|
||||
specs.extend(handlers.users.get_tool_specifications())
|
||||
specs.extend(handlers.webhooks.get_tool_specifications())
|
||||
|
||||
return specs
|
||||
|
||||
def __getattr__(self, name: str):
|
||||
"""
|
||||
Dynamically delegate method calls to appropriate handlers.
|
||||
|
||||
This allows ToolGenerator to call methods like plugin.list_repositories()
|
||||
without explicitly defining each method.
|
||||
|
||||
Args:
|
||||
name: Method name being called
|
||||
|
||||
Returns:
|
||||
Handler function from the appropriate handler module
|
||||
"""
|
||||
# Try to find the method in handlers modules
|
||||
try:
|
||||
# Check repositories handlers
|
||||
if hasattr(handlers.repositories, name):
|
||||
func = getattr(handlers.repositories, name)
|
||||
|
||||
# Create wrapper that passes self.client
|
||||
async def wrapper(**kwargs):
|
||||
return await func(self.client, **kwargs)
|
||||
|
||||
return wrapper
|
||||
|
||||
# Check issues handlers
|
||||
if hasattr(handlers.issues, name):
|
||||
func = getattr(handlers.issues, name)
|
||||
|
||||
async def wrapper(**kwargs):
|
||||
return await func(self.client, **kwargs)
|
||||
|
||||
return wrapper
|
||||
|
||||
# Check pull_requests handlers
|
||||
if hasattr(handlers.pull_requests, name):
|
||||
func = getattr(handlers.pull_requests, name)
|
||||
|
||||
async def wrapper(**kwargs):
|
||||
return await func(self.client, **kwargs)
|
||||
|
||||
return wrapper
|
||||
|
||||
# Check users handlers
|
||||
if hasattr(handlers.users, name):
|
||||
func = getattr(handlers.users, name)
|
||||
|
||||
async def wrapper(**kwargs):
|
||||
return await func(self.client, **kwargs)
|
||||
|
||||
return wrapper
|
||||
|
||||
# Check webhooks handlers
|
||||
if hasattr(handlers.webhooks, name):
|
||||
func = getattr(handlers.webhooks, name)
|
||||
|
||||
async def wrapper(**kwargs):
|
||||
return await func(self.client, **kwargs)
|
||||
|
||||
return wrapper
|
||||
|
||||
# Method not found in any handler
|
||||
raise AttributeError(f"'{self.__class__.__name__}' object has no attribute '{name}'")
|
||||
|
||||
except AttributeError:
|
||||
raise
|
||||
|
||||
async def health_check(self) -> dict[str, Any]:
|
||||
"""
|
||||
Check if Gitea instance is accessible.
|
||||
|
||||
Returns:
|
||||
Dict containing health check result
|
||||
"""
|
||||
try:
|
||||
# Try to get user information (requires authentication)
|
||||
await self.client.request("GET", "user")
|
||||
return {"healthy": True, "message": "Gitea instance is accessible"}
|
||||
except Exception as e:
|
||||
return {"healthy": False, "message": f"Gitea health check failed: {str(e)}"}
|
||||
127
plugins/gitea/schemas/__init__.py
Normal file
127
plugins/gitea/schemas/__init__.py
Normal file
@@ -0,0 +1,127 @@
|
||||
"""
|
||||
Gitea Plugin Pydantic Schemas
|
||||
|
||||
All validation schemas for Gitea plugin operations.
|
||||
"""
|
||||
|
||||
from .common import (
|
||||
ErrorResponse,
|
||||
GiteaPermissions,
|
||||
GiteaTimestamps,
|
||||
GiteaUser,
|
||||
PaginationParams,
|
||||
Site,
|
||||
SuccessResponse,
|
||||
)
|
||||
from .issue import (
|
||||
Comment,
|
||||
CreateCommentRequest,
|
||||
CreateIssueRequest,
|
||||
CreateLabelRequest,
|
||||
CreateMilestoneRequest,
|
||||
Issue,
|
||||
IssueListFilters,
|
||||
Label,
|
||||
Milestone,
|
||||
UpdateIssueRequest,
|
||||
)
|
||||
from .pull_request import (
|
||||
CreatePullRequestRequest,
|
||||
CreateReviewRequest,
|
||||
MergePullRequestRequest,
|
||||
PRBranchInfo,
|
||||
PRCommit,
|
||||
PRFile,
|
||||
PRListFilters,
|
||||
PRReview,
|
||||
PullRequest,
|
||||
RequestReviewersRequest,
|
||||
UpdatePullRequestRequest,
|
||||
)
|
||||
from .repository import (
|
||||
Branch,
|
||||
CreateBranchRequest,
|
||||
CreateFileRequest,
|
||||
CreateRepositoryRequest,
|
||||
CreateTagRequest,
|
||||
FileContent,
|
||||
Repository,
|
||||
Tag,
|
||||
UpdateFileRequest,
|
||||
UpdateRepositoryRequest,
|
||||
)
|
||||
from .user import (
|
||||
Email,
|
||||
Organization,
|
||||
SearchOrgsRequest,
|
||||
SearchUsersRequest,
|
||||
Team,
|
||||
TeamMember,
|
||||
User,
|
||||
)
|
||||
from .webhook import (
|
||||
CreateWebhookRequest,
|
||||
UpdateWebhookRequest,
|
||||
Webhook,
|
||||
WebhookConfig,
|
||||
WebhookTestResult,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
# Common
|
||||
"Site",
|
||||
"PaginationParams",
|
||||
"ErrorResponse",
|
||||
"SuccessResponse",
|
||||
"GiteaUser",
|
||||
"GiteaPermissions",
|
||||
"GiteaTimestamps",
|
||||
# Repository
|
||||
"Repository",
|
||||
"Branch",
|
||||
"Tag",
|
||||
"FileContent",
|
||||
"CreateRepositoryRequest",
|
||||
"UpdateRepositoryRequest",
|
||||
"CreateBranchRequest",
|
||||
"CreateTagRequest",
|
||||
"CreateFileRequest",
|
||||
"UpdateFileRequest",
|
||||
# Issue
|
||||
"Label",
|
||||
"Milestone",
|
||||
"Issue",
|
||||
"Comment",
|
||||
"CreateIssueRequest",
|
||||
"UpdateIssueRequest",
|
||||
"CreateCommentRequest",
|
||||
"CreateLabelRequest",
|
||||
"CreateMilestoneRequest",
|
||||
"IssueListFilters",
|
||||
# Pull Request
|
||||
"PRBranchInfo",
|
||||
"PullRequest",
|
||||
"PRReview",
|
||||
"PRCommit",
|
||||
"PRFile",
|
||||
"CreatePullRequestRequest",
|
||||
"UpdatePullRequestRequest",
|
||||
"MergePullRequestRequest",
|
||||
"CreateReviewRequest",
|
||||
"RequestReviewersRequest",
|
||||
"PRListFilters",
|
||||
# User
|
||||
"User",
|
||||
"Organization",
|
||||
"Team",
|
||||
"TeamMember",
|
||||
"Email",
|
||||
"SearchUsersRequest",
|
||||
"SearchOrgsRequest",
|
||||
# Webhook
|
||||
"Webhook",
|
||||
"WebhookConfig",
|
||||
"CreateWebhookRequest",
|
||||
"UpdateWebhookRequest",
|
||||
"WebhookTestResult",
|
||||
]
|
||||
73
plugins/gitea/schemas/common.py
Normal file
73
plugins/gitea/schemas/common.py
Normal file
@@ -0,0 +1,73 @@
|
||||
"""
|
||||
Common Pydantic Schemas for Gitea Plugin
|
||||
|
||||
Shared validation schemas used across Gitea handlers.
|
||||
"""
|
||||
|
||||
from datetime import datetime
|
||||
from typing import Any
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, Field
|
||||
|
||||
class Site(BaseModel):
|
||||
"""Gitea site configuration"""
|
||||
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
site_id: str = Field(..., description="Site identifier (e.g., 'site1')")
|
||||
url: str = Field(..., description="Gitea instance URL")
|
||||
token: str | None = Field(None, description="Personal access token")
|
||||
alias: str | None = Field(None, description="Site alias")
|
||||
oauth_enabled: bool = Field(default=False, description="Whether OAuth is enabled")
|
||||
|
||||
class PaginationParams(BaseModel):
|
||||
"""Pagination parameters for list endpoints"""
|
||||
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
page: int = Field(default=1, ge=1, description="Page number (starts at 1)")
|
||||
limit: int = Field(default=30, ge=1, le=100, description="Number of items per page (1-100)")
|
||||
|
||||
class ErrorResponse(BaseModel):
|
||||
"""Standard error response"""
|
||||
|
||||
error: bool = Field(default=True)
|
||||
message: str = Field(..., description="Error message")
|
||||
code: str | None = Field(None, description="Error code")
|
||||
details: dict[str, Any] | None = Field(None, description="Additional error details")
|
||||
|
||||
class SuccessResponse(BaseModel):
|
||||
"""Standard success response"""
|
||||
|
||||
success: bool = Field(default=True)
|
||||
message: str = Field(..., description="Success message")
|
||||
data: dict[str, Any] | None = Field(None, description="Response data")
|
||||
|
||||
class GiteaUser(BaseModel):
|
||||
"""Gitea user information"""
|
||||
|
||||
model_config = ConfigDict(extra="allow")
|
||||
|
||||
id: int
|
||||
login: str
|
||||
full_name: str | None = None
|
||||
email: str | None = None
|
||||
avatar_url: str | None = None
|
||||
is_admin: bool = False
|
||||
|
||||
class GiteaPermissions(BaseModel):
|
||||
"""Repository permissions"""
|
||||
|
||||
model_config = ConfigDict(extra="allow")
|
||||
|
||||
admin: bool = False
|
||||
push: bool = False
|
||||
pull: bool = False
|
||||
|
||||
class GiteaTimestamps(BaseModel):
|
||||
"""Common timestamp fields"""
|
||||
|
||||
model_config = ConfigDict(extra="allow")
|
||||
|
||||
created_at: datetime | None = None
|
||||
updated_at: datetime | None = None
|
||||
184
plugins/gitea/schemas/issue.py
Normal file
184
plugins/gitea/schemas/issue.py
Normal file
@@ -0,0 +1,184 @@
|
||||
"""
|
||||
Issue Pydantic Schemas
|
||||
|
||||
Validation schemas for Gitea issue operations.
|
||||
"""
|
||||
|
||||
from datetime import datetime
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, Field, field_validator
|
||||
|
||||
from .common import GiteaUser
|
||||
|
||||
class Label(BaseModel):
|
||||
"""Issue/PR label"""
|
||||
|
||||
model_config = ConfigDict(extra="allow")
|
||||
|
||||
id: int
|
||||
name: str
|
||||
color: str
|
||||
description: str | None = None
|
||||
url: str | None = None
|
||||
|
||||
class Milestone(BaseModel):
|
||||
"""Issue/PR milestone"""
|
||||
|
||||
model_config = ConfigDict(extra="allow")
|
||||
|
||||
id: int
|
||||
title: str
|
||||
description: str | None = None
|
||||
state: str # "open" or "closed"
|
||||
open_issues: int = 0
|
||||
closed_issues: int = 0
|
||||
created_at: datetime
|
||||
updated_at: datetime | None = None
|
||||
closed_at: datetime | None = None
|
||||
due_on: datetime | None = None
|
||||
|
||||
class Issue(BaseModel):
|
||||
"""Gitea issue model"""
|
||||
|
||||
model_config = ConfigDict(extra="allow")
|
||||
|
||||
id: int
|
||||
number: int
|
||||
user: GiteaUser
|
||||
original_author: str | None = None
|
||||
original_author_id: int | None = None
|
||||
title: str
|
||||
body: str | None = None
|
||||
ref: str | None = None
|
||||
labels: list[Label] = []
|
||||
milestone: Milestone | None = None
|
||||
assignee: GiteaUser | None = None
|
||||
assignees: list[GiteaUser] = []
|
||||
state: str # "open" or "closed"
|
||||
is_locked: bool = False
|
||||
comments: int = 0
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
closed_at: datetime | None = None
|
||||
due_date: datetime | None = None
|
||||
pull_request: dict | None = None # Non-null if this is a PR
|
||||
repository: dict | None = None
|
||||
html_url: str
|
||||
url: str
|
||||
|
||||
class Comment(BaseModel):
|
||||
"""Issue/PR comment"""
|
||||
|
||||
model_config = ConfigDict(extra="allow")
|
||||
|
||||
id: int
|
||||
html_url: str
|
||||
pull_request_url: str | None = None
|
||||
issue_url: str | None = None
|
||||
user: GiteaUser
|
||||
original_author: str | None = None
|
||||
original_author_id: int | None = None
|
||||
body: str
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
|
||||
class CreateIssueRequest(BaseModel):
|
||||
"""Request to create an issue"""
|
||||
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
title: str = Field(..., min_length=1, max_length=255, description="Issue title")
|
||||
body: str | None = Field(None, description="Issue body/description")
|
||||
assignee: str | None = Field(None, description="Username of assignee")
|
||||
assignees: list[str] | None = Field(None, description="List of assignee usernames")
|
||||
closed: bool | None = Field(False, description="Create as closed")
|
||||
due_date: datetime | None = Field(None, description="Due date")
|
||||
labels: list[int] | None = Field(None, description="List of label IDs")
|
||||
milestone: int | None = Field(None, description="Milestone ID")
|
||||
ref: str | None = Field(None, description="Issue ref")
|
||||
|
||||
class UpdateIssueRequest(BaseModel):
|
||||
"""Request to update an issue"""
|
||||
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
title: str | None = Field(None, min_length=1, max_length=255, description="Issue title")
|
||||
body: str | None = Field(None, description="Issue body/description")
|
||||
assignee: str | None = Field(None, description="Username of assignee")
|
||||
assignees: list[str] | None = Field(None, description="List of assignee usernames")
|
||||
state: str | None = Field(None, description="State (open/closed)")
|
||||
due_date: datetime | None = Field(None, description="Due date")
|
||||
labels: list[int] | None = Field(None, description="List of label IDs")
|
||||
milestone: int | None = Field(None, description="Milestone ID")
|
||||
ref: str | None = Field(None, description="Issue ref")
|
||||
unset_due_date: bool | None = Field(None, description="Unset due date")
|
||||
|
||||
@classmethod
|
||||
@field_validator("state")
|
||||
def validate_state(cls, v):
|
||||
if v and v not in ["open", "closed"]:
|
||||
raise ValueError("State must be 'open' or 'closed'")
|
||||
return v
|
||||
|
||||
class CreateCommentRequest(BaseModel):
|
||||
"""Request to create a comment"""
|
||||
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
body: str = Field(..., min_length=1, description="Comment body")
|
||||
|
||||
class CreateLabelRequest(BaseModel):
|
||||
"""Request to create a label"""
|
||||
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
name: str = Field(..., min_length=1, max_length=50, description="Label name")
|
||||
color: str = Field(..., pattern=r"^[0-9A-Fa-f]{6}$", description="Label color (hex without #)")
|
||||
description: str | None = Field(None, max_length=200, description="Label description")
|
||||
|
||||
class CreateMilestoneRequest(BaseModel):
|
||||
"""Request to create a milestone"""
|
||||
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
title: str = Field(..., min_length=1, max_length=255, description="Milestone title")
|
||||
description: str | None = Field(None, description="Milestone description")
|
||||
due_on: datetime | None = Field(None, description="Due date")
|
||||
state: str | None = Field("open", description="State (open/closed)")
|
||||
|
||||
@classmethod
|
||||
@field_validator("state")
|
||||
def validate_state(cls, v):
|
||||
if v not in ["open", "closed"]:
|
||||
raise ValueError("State must be 'open' or 'closed'")
|
||||
return v
|
||||
|
||||
class IssueListFilters(BaseModel):
|
||||
"""Filters for listing issues"""
|
||||
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
state: str | None = Field("open", description="Filter by state (open/closed/all)")
|
||||
labels: str | None = Field(None, description="Comma-separated label IDs")
|
||||
q: str | None = Field(None, description="Search query")
|
||||
type: str | None = Field(None, description="Filter by type (issues/pulls)")
|
||||
milestones: str | None = Field(None, description="Comma-separated milestone names")
|
||||
since: datetime | None = Field(None, description="Only show items updated after this time")
|
||||
before: datetime | None = Field(None, description="Only show items updated before this time")
|
||||
created_by: str | None = Field(None, description="Filter by creator username")
|
||||
assigned_by: str | None = Field(None, description="Filter by assignee username")
|
||||
mentioned_by: str | None = Field(None, description="Filter by mentioned username")
|
||||
|
||||
@classmethod
|
||||
@field_validator("state")
|
||||
def validate_state(cls, v):
|
||||
if v and v not in ["open", "closed", "all"]:
|
||||
raise ValueError("State must be 'open', 'closed', or 'all'")
|
||||
return v
|
||||
|
||||
@classmethod
|
||||
@field_validator("type")
|
||||
def validate_type(cls, v):
|
||||
if v and v not in ["issues", "pulls"]:
|
||||
raise ValueError("Type must be 'issues' or 'pulls'")
|
||||
return v
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user