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:
airano
2026-02-17 08:34:44 +03:30
commit cf62e65c55
237 changed files with 87596 additions and 0 deletions

69
.dockerignore Normal file
View 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
View 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
View 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

View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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: MIT](https://img.shields.io/badge/license-MIT-blue.svg)](LICENSE)
[![Python 3.11+](https://img.shields.io/badge/python-3.11+-3776ab.svg)](https://www.python.org/)
[![Tests: 164 passing](https://img.shields.io/badge/tests-164%20passing-brightgreen.svg)]()
[![Tools: 587](https://img.shields.io/badge/tools-587-orange.svg)]()
</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
View 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
View 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
View 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
View 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
View 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
View 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)

View 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
View 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

File diff suppressed because it is too large Load Diff

View 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
View 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
View 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

View 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
View 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
View 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
View 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")

View 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
View 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",
]

View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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

View 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

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

448
docs/getting-started.md Normal file
View 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
View 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

View 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

View 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
View 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
View 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
View 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
View 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
View 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",
]

View 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

File diff suppressed because it is too large Load Diff

View 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",
]

View 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)

View 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)

View 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)

View 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)

View 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)

View 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)

View 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)

View 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
View 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
View 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

View 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

File diff suppressed because it is too large Load Diff

View 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",
]

View 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)

View 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)

View 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)

View 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)

View 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)

View 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)

View 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)

View 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)

View 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
View 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)

View 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
View 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")

View 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",
]

View 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)

View 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)

View 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)

View 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)

View 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
View 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)}"}

View 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",
]

View 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

View 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