commit 2ee93048e1dca2fb77424bbac5deb5377a8b6bbb Author: MOHAN Date: Tue Apr 21 20:26:45 2026 +0530 feat: Add tools for managing server scripts, client scripts, translations, assignment rules, user permissions, webhooks, API keys, and workflows - Implemented server and client script management tools in `frappe_mcp/tools/scripts.py` - Added translation and user permission management tools in `frappe_mcp/tools/translations.py` - Created user and role management tools in `frappe_mcp/tools/users.py` - Developed webhook and API key management tools in `frappe_mcp/tools/webhooks.py` - Introduced workflow management tools in `frappe_mcp/tools/workflow_tools.py` - Added `pyproject.toml` for project metadata and dependencies diff --git a/.env b/.env new file mode 100644 index 0000000..4127d6e --- /dev/null +++ b/.env @@ -0,0 +1,47 @@ +# Frappe instance URL (your VPS / Docker host) +# If using a domain: https://erp.yourdomain.com +# If using Docker with port mapping: http://YOUR_VPS_IP:8000 +FRAPPE_URL=http://147.93.40.215:10009/ + +# API credentials — generate in Frappe: Settings > My Account > API Access +FRAPPE_API_KEY=2650fa15dc9393f +FRAPPE_API_SECRET=766ad5af8577685 + +# For multi-site Docker setups (optional) — the site name e.g. "site1.localhost" +FRAPPE_SITE_NAME= + +# Set to true to block all write/delete operations (safe read-only mode) +READ_ONLY_MODE=false + +# HTTP timeout in seconds for API calls +REQUEST_TIMEOUT=30 + +ENABLED_MODULES=foundation,document_inspect,analytics + +# ── Module on/off switches ─────────────────────────────────────────────────── +# Uncomment a line to disable that module (all others stay ON) +# MODULE_FOUNDATION=false +# MODULE_DOCTYPES=false +# MODULE_DOCUMENTS=false +# MODULE_DOCUMENT_INSPECT=false +# MODULE_DOCUMENT_LIFECYCLE=false +# MODULE_CUSTOM_FIELDS=false +# MODULE_SCRIPTS=false +# MODULE_WORKFLOW_TOOLS=false +# MODULE_ANALYTICS=false +# MODULE_REPORTS=false +# MODULE_PRINT_FORMATS=false +# MODULE_BULK_OPS=false +# MODULE_BUSINESS_ACTIONS=false +# MODULE_USERS=false +# MODULE_ADMIN=false +# MODULE_PROPERTY_SETTERS=false +# MODULE_NAMING_SERIES=false +# MODULE_FILES=false +# MODULE_ACTIVITY=false +# MODULE_DASHBOARDS=false +# MODULE_WEBHOOKS=false +# MODULE_EMAIL_TEMPLATES=false +# MODULE_TRANSLATIONS=false +# MODULE_SCHEDULER=false +# MODULE_GOVERNANCE=false diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..363bc4a --- /dev/null +++ b/.env.example @@ -0,0 +1,72 @@ +# ── Frappe Connection ──────────────────────────────────────────────────────── +# URL of your Frappe Docker instance (VPS IP or domain) +FRAPPE_URL=http://YOUR_VPS_IP:8000 + +# API credentials — generate in Frappe: Settings > My Profile > API Access +FRAPPE_API_KEY=your_api_key_here +FRAPPE_API_SECRET=your_api_secret_here + +# For multi-site Docker setups — the site name e.g. "site1.localhost" +FRAPPE_SITE_NAME= + +# ── Safety ─────────────────────────────────────────────────────────────────── +# Set to true to block ALL write/delete operations (read-only audit mode) +READ_ONLY_MODE=false + +# HTTP timeout for API calls (seconds) +REQUEST_TIMEOUT=30 + +# ── Module Activation ──────────────────────────────────────────────────────── +# Option A: Enable ONLY specific modules (comma-separated, all others disabled) +# ENABLED_MODULES=documents,doctypes,users,custom_fields + +# Option B: Disable specific modules (all others stay ON) +# MODULE_SCHEDULER=false +# MODULE_BULK_OPS=false +# MODULE_TRANSLATIONS=false +# MODULE_WEBHOOKS=false + +# Available module keys (run 'frappe-mcp --list-modules' for live status): +# +# LEVEL 1 — Foundation (always recommended ON) +# foundation — ping, session_info, doctype_meta, permissions +# +# LEVEL 2 — Read +# doctypes — DocType CRUD +# documents — Document CRUD (submit, cancel, delete) +# document_inspect — search, children, linked docs, timeline, count +# +# LEVEL 3-4 — Write + Lifecycle +# document_lifecycle — child rows, rename, amend, duplicate, status +# custom_fields — Custom Fields +# scripts — Server Scripts + Client Scripts +# +# LEVEL 5 — Workflow +# workflow_tools — Workflows, transitions, approvals +# +# LEVEL 6 — Reporting +# analytics — Aggregate, dashboard data, PDF render +# reports — Query/Script Report CRUD +# print_formats — Print Format templates +# +# LEVEL 7 — Bulk +# bulk_ops — Bulk create/update/delete/submit/cancel/assign/tag +# +# LEVEL 8 — Business Actions (ERPNext) +# business_actions — Sales, Buying, Stock, HR, Support, Projects shortcuts +# +# LEVEL 9 — Admin / System +# users — Users, Roles, Permissions +# admin — Cache, System Settings, SQL, Workflows +# property_setters — Property Setters + Customize Form +# naming_series — Naming Series +# files — File Manager +# activity — Comments, Tags, Assignments, Error Logs +# dashboards — Dashboards, Charts, Workspaces +# webhooks — Webhooks + API Keys +# email_templates — Email Templates + Notifications +# translations — Translations, Assignment Rules, User Permissions +# scheduler — Scheduled Jobs + Background Jobs +# +# LEVEL 10 — Governance (enable for autonomous AI agents) +# governance — Dry run, validate, risk score, audit log, rollback diff --git a/.mcp.json b/.mcp.json new file mode 100644 index 0000000..51d2803 --- /dev/null +++ b/.mcp.json @@ -0,0 +1,16 @@ +{ + "mcpServers": { + "frappe": { + "type": "stdio", + "command": "python", + "args": ["-m", "frappe_mcp.server"], + "cwd": "D:\\2026\\Frappe-MCP", + "env": { + "FRAPPE_URL": "http://147.93.40.215:10009", + "FRAPPE_API_KEY": "2650fa15dc9393f", + "FRAPPE_API_SECRET": "766ad5af8577685" + } + } + } +} + diff --git a/DINE360_ARCHITECTURE.md b/DINE360_ARCHITECTURE.md new file mode 100644 index 0000000..c7b9a60 --- /dev/null +++ b/DINE360_ARCHITECTURE.md @@ -0,0 +1,347 @@ +# Dine360 Architecture Plan +> Generated: 2026-04-20 | Platform: Frappe 15.105.0 + ERPNext 15.104.3 +> Instance: http://147.93.40.215:10009/ + +--- + +## 1. Platform Foundation + +| Layer | Version | Notes | +|-------|---------|-------| +| Frappe Framework | 15.105.0 | Core DocType engine, workflow, notifications, scheduler | +| ERPNext | 15.104.3 | CRM, Accounts, HR modules available for reuse | +| Custom Module | Dine360 | Registered under `frappe` app | + +--- + +## 2. Standard ERPNext DocTypes — Reuse Opportunities + +These exist out of the box and can be linked from Dine360 DocTypes rather than recreating: + +| ERPNext DocType | Dine360 Purpose | How Reused | +|-----------------|-----------------|------------| +| **Customer** | Advertiser billing account | Link from Advertiser → Customer for sales invoices | +| **Supplier** | Restaurant Partner payout | Link from Restaurant Partner → Supplier for purchase invoices | +| **Sales Invoice** | Advertiser billing | Create against Customer when campaign runs | +| **Purchase Invoice** | Restaurant payout | Create against Supplier on settlement | +| **Payment Entry** | Record actual payments | Link from Restaurant Settlement after payout | +| **Address** | Branch physical location | Link from Restaurant Branch → Address | +| **Contact** | Partner/advertiser contacts | Standard Contact linked to Restaurant Partner/Advertiser | +| **Employee** | Internal operations staff | Assign campaigns, escalations | +| **User** | Portal access | All 8 Dine360 roles assigned to Users | +| **File** | Creative asset storage | Ad Creative links to File for media uploads | +| **Communication** | Email/SMS log | Auto-logged on notification send | +| **Activity Log** | Audit trail | Auto-logged on all document changes | +| **ToDo** | Onboarding task reminders | Auto-created from Onboarding Checklist workflow | + +--- + +## 3. Custom Dine360 DocTypes (19 Total) + +### 3a. Child Tables (4) + +| DocType | Parent | Purpose | +|---------|--------|---------| +| **Screen Group Member** | Screen Group | Lists Screen Devices belonging to a group | +| **Campaign Creative Item** | Ad Campaign | Creative assets (video/image) with duration per campaign | +| **Campaign Target Item** | Ad Campaign | Target screen groups and time slots per campaign | +| **Settlement Ledger Item** | Restaurant Settlement | Per-restaurant line items in a batch settlement | + +### 3b. Master / Configuration DocTypes (6) + +| DocType | Submittable | Key Fields | Autoname | +|---------|-------------|------------|----------| +| **Restaurant Partner** | No | partner_name, contact_email, status (Active/Inactive/Suspended), linked_supplier | `field:partner_name` | +| **Advertiser** | No | advertiser_name, contact_email, industry, linked_customer | `field:advertiser_name` | +| **Restaurant Branch** | No | branch_name, restaurant_partner (Link), city, address, is_active | `BRANCH-.#####` | +| **Ad Creative** | No | creative_name, advertiser (Link), media_type (Video/Image/HTML), file_url, duration_seconds, status (Draft/Approved/Rejected) | `CRTV-.YYYY.-.#####` | +| **Screen Device** | No | device_name, restaurant_branch (Link), screen_group (Link), device_serial, status (Active/Inactive/Maintenance), last_heartbeat | `SCRN-.#####` | +| **Screen Group** | No | group_name, description, screen_group_members (child table) | `field:group_name` | + +### 3c. Transaction DocTypes (9) + +| DocType | Submittable | Key Fields | Autoname | +|---------|-------------|------------|----------| +| **Ad Campaign** | **Yes** | campaign_name, advertiser (Link), start_date, end_date, budget, status, campaign_creative_items (child), campaign_target_items (child) | `CAMP-.YYYY.-.#####` | +| **Revenue Rule** | No | rule_name, advertiser (Link), restaurant_partner (Link), revenue_split_percent, effective_from, effective_to | `RULE-.YYYY.-.#####` | +| **API Sync Log** | No | sync_type, sync_status (Success/Failed/Partial), synced_at, records_processed, error_message | `SYNC-.YYYY.-.#####` | +| **Campaign Schedule** | No | ad_campaign (Link), screen_device (Link), scheduled_date, start_time, end_time, status (Pending/Confirmed/Cancelled) | `SCHED-.YYYY.-.#####` | +| **Playback Log** | No | screen_device (Link), ad_campaign (Link), played_at, duration_played, completion_status | `PLAY-.YYYY.-.#####` | +| **Device Heartbeat** | No | screen_device (Link), heartbeat_at, ip_address, cpu_load, memory_used, status (Online/Offline/Warning) | `HB-.YYYY.-.#####` | +| **Revenue Ledger** | No | restaurant_partner (Link), ad_campaign (Link), revenue_rule (Link), period_start, period_end, gross_revenue, platform_share, partner_share, status (Draft/Calculated/Approved) | `RLGR-.YYYY.-.#####` | +| **Restaurant Settlement** | **Yes** | settlement_name, settlement_period_start, settlement_period_end, total_payout, status, settlement_ledger_items (child) | `SETL-.YYYY.-.#####` | +| **Onboarding Checklist** | No | restaurant_partner (Link), assigned_to, current_stage, lead_date, review_date, agreement_date, setup_date, go_live_date | `OB-.YYYY.-.#####` | + +--- + +## 4. Entity Relationship Map + +``` + ┌─────────────────┐ + │ Advertiser │──────────────────────────┐ + └────────┬────────┘ │ + │ 1 │ 1 + ▼ N ▼ N + ┌────────────────┐ ┌────────────────────┐ + │ Ad Creative │ │ Revenue Rule │ + └────────┬───────┘ └────────────────────┘ + │ N │ + ▼ M (via child table) │ + ┌────────────────┐ │ + │ Ad Campaign │─────────────────────────┘ + └────┬───────┬───┘ + │ │ via Campaign Target Items + │ ▼ N + │ ┌─────────────┐ ┌──────────────────┐ + │ │ Screen Group│───────│ Screen Group │ + │ └─────────────┘ │ Member (child) │ + │ │ └──────────────────┘ + │ │ N + │ ▼ 1 + │ ┌─────────────┐ ┌───────────────────┐ + │ │Screen Device│───────│Restaurant Branch │ + │ └──────┬──────┘ └────────┬──────────┘ + │ │ │ N + │ │ 1 ▼ 1 + │ ┌──────┴──────┐ ┌──────────────────────┐ + │ │ Playback │ │ Restaurant Partner │ + │ │ Log │ └────────────┬─────────┘ + │ └─────────────┘ │ 1 + │ ▼ N + │ ┌──────────────────────┐ + └─────────────────────────►│ Revenue Ledger │ + └──────────┬───────────┘ + │ N + ▼ 1 (via child) + ┌──────────────────────┐ + │Restaurant Settlement │ + └──────────────────────┘ + + Device Heartbeat ──► Screen Device + Campaign Schedule ──► Ad Campaign + Screen Device + API Sync Log ──► (standalone audit log) + Onboarding Checklist ──► Restaurant Partner +``` + +--- + +## 5. Role Permission Matrix + +| Role | Masters | Campaigns | Revenue | Devices | Settlement | Onboarding | +|------|---------|-----------|---------|---------|------------|------------| +| **Dine360 Admin** | Full | Full | Full | Full | Full | Full | +| **Campaign Manager** | Read | Full | Read | Read | None | None | +| **Operations Manager** | Read | Read/Write | Read | Full | Read | Full | +| **Finance Manager** | Read | Read | Full | None | Full | None | +| **Support Executive** | Read | Read | None | Read | None | Read/Write | +| **Restaurant Manager** | Own | Own | Own | Own | Own | Own | +| **Restaurant Viewer** | Read-Own | Read-Own | Read-Own | None | Read-Own | None | +| **Device Agent** | None | Read | None | Write-Own | None | None | + +--- + +## 6. Workflow Map + +### 6a. Campaign Approval Workflow (Ad Campaign) + +``` +Draft ──[Submit for Review]──► Pending Approval ──[Approve]──► Approved ──[Schedule]──► Scheduled + │ │ │ + [Reject] [Reject] [Activate] + │ │ │ + ▼ ▼ ▼ + Rejected Rejected Running ──[Complete]──► Completed + │ + [Cancel] + │ + ▼ + Cancelled +``` + +| Transition | From | To | Action | Roles | +|------------|------|----|--------|-------| +| Submit for Review | Draft | Pending Approval | Submit for Review | Campaign Manager | +| Approve | Pending Approval | Approved | Approve | Dine360 Admin, Operations Manager | +| Reject | Pending Approval | Rejected | Reject | Dine360 Admin, Operations Manager | +| Schedule | Approved | Scheduled | Schedule | Campaign Manager, Operations Manager | +| Activate | Scheduled | Running | Activate | Device Agent, Operations Manager | +| Complete | Running | Completed | Complete | Device Agent, Operations Manager | +| Cancel | Running | Cancelled | Cancel | Dine360 Admin, Operations Manager | + +### 6b. Restaurant Onboarding Workflow (Onboarding Checklist) + +``` +Lead ──[Move to Review]──► Review ──[Sign Agreement]──► Agreement ──[Begin Setup]──► Setup ──[Go Live]──► Live +``` + +| Transition | From | To | Action | Roles | +|------------|------|----|--------|-------| +| Move to Review | Lead | Review | Move to Review | Operations Manager, Support Executive | +| Sign Agreement | Review | Agreement | Sign Agreement | Operations Manager, Dine360 Admin | +| Begin Setup | Agreement | Setup | Begin Setup | Operations Manager | +| Go Live | Setup | Live | Go Live | Operations Manager, Dine360 Admin | + +### 6c. Settlement Payout Workflow (Restaurant Settlement) + +``` +Draft ──[Calculate]──► Calculated ──[Approve Payout]──► Approved ──[Mark Paid]──► Paid + │ │ │ + [Hold] [Hold] [Raise Dispute] + │ │ │ + ▼ ▼ ▼ + Held Held Disputed ──[Resolve]──► Paid +``` + +| Transition | From | To | docstatus | Action | Roles | +|------------|------|----|-----------|--------|-------| +| Calculate | Draft | Calculated | 0→0 | Calculate | Finance Manager | +| Approve Payout | Calculated | Approved | 0→1 | Approve Payout | Finance Manager, Dine360 Admin | +| Hold | Calculated | Held | 0→0 | Hold | Finance Manager | +| Hold | Approved | Held | 1→1 | Hold | Finance Manager, Dine360 Admin | +| Mark Paid | Approved | Paid | 1→1 | Mark Paid | Finance Manager | +| Raise Dispute | Paid | Disputed | 1→1 | Raise Dispute | Restaurant Manager, Finance Manager | +| Resolve Dispute | Disputed | Paid | 1→1 | Resolve Dispute | Finance Manager, Dine360 Admin | + +--- + +## 7. Naming Series Reference + +| DocType | Series | Example | +|---------|--------|---------| +| Restaurant Branch | `BRANCH-.#####` | BRANCH-00001 | +| Ad Creative | `CRTV-.YYYY.-.#####` | CRTV-2026-00001 | +| Screen Device | `SCRN-.#####` | SCRN-00001 | +| Ad Campaign | `CAMP-.YYYY.-.#####` | CAMP-2026-00001 | +| Revenue Rule | `RULE-.YYYY.-.#####` | RULE-2026-00001 | +| API Sync Log | `SYNC-.YYYY.-.#####` | SYNC-2026-00001 | +| Campaign Schedule | `SCHED-.YYYY.-.#####` | SCHED-2026-00001 | +| Playback Log | `PLAY-.YYYY.-.#####` | PLAY-2026-00001 | +| Device Heartbeat | `HB-.YYYY.-.#####` | HB-2026-00001 | +| Revenue Ledger | `RLGR-.YYYY.-.#####` | RLGR-2026-00001 | +| Restaurant Settlement | `SETL-.YYYY.-.#####` | SETL-2026-00001 | +| Onboarding Checklist | `OB-.YYYY.-.#####` | OB-2026-00001 | +| Restaurant Partner | `field:partner_name` | Metatron Cubes | +| Advertiser | `field:advertiser_name` | Coca-Cola India | +| Screen Group | `field:group_name` | Zone A Screens | + +--- + +## 8. Status Values Reference + +| DocType | Status Field | Allowed Values | +|---------|-------------|----------------| +| Restaurant Partner | status | Active, Inactive, Suspended | +| Ad Creative | status | Draft, Approved, Rejected | +| Screen Device | status | Active, Inactive, Maintenance | +| Campaign Schedule | status | Pending, Confirmed, Cancelled | +| Playback Log | completion_status | Completed, Partial, Failed | +| Device Heartbeat | status | Online, Offline, Warning | +| Revenue Ledger | status | Draft, Calculated, Approved | +| API Sync Log | sync_status | Success, Failed, Partial | + +--- + +## 9. Notification Map (5 Active Notifications) + +| Notification | DocType | Trigger | Recipients | +|-------------|---------|---------|-----------| +| Campaign Submitted for Review | Ad Campaign | workflow_state → Pending Approval | Dine360 Admin, Operations Manager | +| Campaign Approved | Ad Campaign | workflow_state → Approved | Campaign Manager | +| Campaign Rejected | Ad Campaign | workflow_state → Rejected | Campaign Manager | +| Settlement Approved for Payout | Restaurant Settlement | workflow_state → Approved | Finance Manager | +| Campaign Ending Soon | Ad Campaign | end_date within 3 days | Campaign Manager | + +--- + +## 10. Dashboard & Reporting Map + +### 10a. Existing Dashboard Charts (6) + +| Chart | Type | DocType | X-Axis | Y-Axis | +|-------|------|---------|--------|--------| +| Campaigns by Status | Donut | Ad Campaign | — | status (count) | +| Active Screen Devices | Donut | Screen Device | — | status (count) | +| Restaurant Partners by Status | Donut | Restaurant Partner | — | status (count) | +| Monthly Revenue Ledger | Bar | Revenue Ledger | period_start (monthly) | partner_share (sum) | +| Settlements Over Time | Bar | Restaurant Settlement | settlement_period_start (monthly) | total_payout (sum) | +| Playback Log Volume | Line | Playback Log | played_at (daily) | name (count) | + +### 10b. Recommended Future Reports (Phase 2) + +| Report | Type | Purpose | +|--------|------|---------| +| Campaign Performance Summary | Query Report | Impressions, completion rate, revenue per campaign | +| Revenue Split Breakdown | Script Report | Per-partner revenue vs platform share over period | +| Device Uptime Report | Query Report | Heartbeat-based uptime % per device/branch | +| Settlement Audit Trail | Query Report | Full ledger→settlement→payment chain | +| Advertiser Billing Statement | Script Report | Invoice-ready summary for advertiser | +| Onboarding Pipeline | Query Report | Checklist stage distribution + SLA | + +--- + +## 11. Server-Side Automation Map (Phase 2) + +| Automation | Type | Trigger | Action | +|------------|------|---------|--------| +| Revenue Split Calculator | Server Script | On Playback Log save | Calculate partner_share in Revenue Ledger | +| Device Offline Alert | Scheduled Job | Every 5 min | Flag Device Heartbeat > 10 min old as Offline | +| Campaign Auto-Complete | Scheduled Job | Daily | Set Running campaigns past end_date → Completed | +| Settlement Auto-Batch | Scheduled Job | Monthly | Batch Calculated Revenue Ledgers → new Settlement | +| Creative Auto-Expire | Scheduled Job | Daily | Flag Ad Creatives not updated in 90 days | +| Device Sync API | API Endpoint | REST POST | Accept heartbeat + playback data from devices | + +--- + +## 12. Phase 2 Implementation Order + +All 19 DocTypes, 3 workflows, 5 notifications, 6 charts, and 1 workspace are **already live**. +Phase 2 items in recommended build order: + +``` +Step 1 — Server Scripts (business logic) + 1.1 Revenue split auto-calculation on Playback Log save + 1.2 Settlement batch-creation script (aggregate Revenue Ledger → Settlement) + 1.3 Campaign status auto-complete (scheduled) + 1.4 Device offline detection (scheduled heartbeat check) + +Step 2 — Query / Script Reports + 2.1 Campaign Performance Summary (Query Report) + 2.2 Revenue Split Breakdown (Script Report) + 2.3 Device Uptime Report (Query Report) + 2.4 Settlement Audit Trail (Query Report) + +Step 3 — Print Formats + 3.1 Settlement Payout Slip (Restaurant Settlement) + 3.2 Advertiser Campaign Summary (Ad Campaign) + +Step 4 — Portal / Role Permissions + 4.1 Tighten user permissions per Role Permission Matrix (Section 5) + 4.2 Configure Restaurant Manager portal view + 4.3 Configure Advertiser self-service portal (if needed) + +Step 5 — API Endpoints + 5.1 Device heartbeat sync endpoint (POST /api/method/dine360.heartbeat) + 5.2 Playback log ingestion endpoint (POST /api/method/dine360.playback) + 5.3 Campaign schedule fetch endpoint (GET /api/method/dine360.schedule) + +Step 6 — Integration + 6.1 Link Restaurant Partner → Supplier (for Purchase Invoice on settlement) + 6.2 Link Advertiser → Customer (for Sales Invoice on campaign billing) + 6.3 Auto-create Payment Entry on Settlement marked Paid +``` + +--- + +## 13. Key Design Decisions & Constraints + +1. **Dine360 is a custom module** on the `frappe` app — not a separate installable app. Upgrade-safe as long as customizations live in DocType/Server Script/Client Script records. + +2. **Submittable DocTypes**: Only `Ad Campaign` and `Restaurant Settlement` are submittable. Revenue Ledger uses a status field (not docstatus) to allow edits through the approval cycle. + +3. **Workflow docstatus rule**: Frappe forbids transitions from submitted (docstatus=1) → draft (docstatus=0). All dispute/hold states in the Settlement workflow are docstatus=1. + +4. **ERPNext accounting integration** is optional but available: Settlement → Purchase Invoice → Payment Entry chain uses native ERPNext accounting. Disable if a simpler ledger-only approach is preferred. + +5. **Device authentication**: Screen Devices should authenticate via API key (generate one API key per device via `frappe_generate_api_key`). The Device Agent role limits read access to only Campaign Schedule. + +6. **Revenue Rule priority**: If multiple Revenue Rules match an Advertiser + Partner pair, use the most recently effective rule. Overlap validation should be enforced via server script. diff --git a/Frappe-MCP.rar b/Frappe-MCP.rar new file mode 100644 index 0000000..e70f8f7 Binary files /dev/null and b/Frappe-MCP.rar differ diff --git a/claude_desktop_config.example.json b/claude_desktop_config.example.json new file mode 100644 index 0000000..a605b23 --- /dev/null +++ b/claude_desktop_config.example.json @@ -0,0 +1,14 @@ +{ + "mcpServers": { + "frappe": { + "command": "python", + "args": ["-m", "frappe_mcp.server"], + "cwd": "/path/to/frappe-mcp", + "env": { + "FRAPPE_URL": "http://147.93.40.215:10009/", + "FRAPPE_API_KEY": "2650fa15dc9393f", + "FRAPPE_API_SECRET": "766ad5af8577685" + } + } + } +} diff --git a/docker/.env.example b/docker/.env.example new file mode 100644 index 0000000..e8e05c4 --- /dev/null +++ b/docker/.env.example @@ -0,0 +1,16 @@ +# ERPNext version (use version-15 for latest stable) +ERPNEXT_VERSION=version-15 + +# Your site name — use your domain or VPS IP +# If using a domain: myerp.yourdomain.com +# If no domain: erp.localhost +SITE_NAME=erp.localhost + +# Port to expose on the VPS (80 if you want standard HTTP, 8080 for non-root) +HTTP_PORT=8080 + +# Database root password — change this! +DB_ROOT_PASSWORD=SuperSecureDBPass123! + +# Frappe admin password — you'll use this to log in at /login +ADMIN_PASSWORD=SuperSecureAdmin123! diff --git a/docker/docker-compose.yml b/docker/docker-compose.yml new file mode 100644 index 0000000..5b32119 --- /dev/null +++ b/docker/docker-compose.yml @@ -0,0 +1,206 @@ +services: + + # ── Database ──────────────────────────────────────────────────────────────── + db: + image: mariadb:10.6 + restart: unless-stopped + + environment: + MYSQL_ROOT_PASSWORD: ${DB_ROOT_PASSWORD:-changeme123} + MYSQL_DATABASE: _sys + volumes: + - db-data:/var/lib/mysql + networks: + - frappe-net + command: + - --character-set-server=utf8mb4 + - --collation-server=utf8mb4_unicode_ci + - --skip-character-set-client-handshake + - --skip-innodb-read-only-compressed + + # ── Redis ─────────────────────────────────────────────────────────────────── + redis-cache: + image: redis:7-alpine + restart: unless-stopped + networks: + - frappe-net + volumes: + - redis-cache-data:/data + + redis-queue: + image: redis:7-alpine + restart: unless-stopped + networks: + - frappe-net + volumes: + - redis-queue-data:/data + + # ── Site configurator (runs once, then exits) ─────────────────────────────── + configurator: + image: docker.io/frappe/erpnext:${ERPNEXT_VERSION:-version-15} + restart: "no" + entrypoint: ["bash", "-c"] + command: + - > + ls -1 apps > sites/apps.txt; + bench set-config -g db_host db; + bench set-config -gp db_port 3306; + bench set-config -g redis_cache "redis://redis-cache:6379"; + bench set-config -g redis_queue "redis://redis-queue:6379"; + bench set-config -g redis_socketio "redis://redis-queue:6379"; + bench set-config -gp socketio_port 9000; + environment: + DB_HOST: db + DB_PORT: "3306" + REDIS_CACHE: redis://redis-cache:6379 + REDIS_QUEUE: redis://redis-queue:6379 + SOCKETIO_PORT: "9000" + volumes: + - sites:/home/frappe/frappe-bench/sites + networks: + - frappe-net + depends_on: + - db + + # ── Site creator (runs once to create site + install ERPNext) ─────────────── + create-site: + image: docker.io/frappe/erpnext:${ERPNEXT_VERSION:-version-15} + restart: "no" + volumes: + - sites:/home/frappe/frappe-bench/sites + - logs:/home/frappe/frappe-bench/logs + entrypoint: ["bash", "-c"] + command: + - > + wait-for-it -t 120 db:3306; + wait-for-it -t 120 redis-cache:6379; + wait-for-it -t 120 redis-queue:6379; + export start=`date +%s`; + until [[ -n `grep -hs ^ sites/common_site_config.json | python -c "import sys, json; cfg = json.load(sys.stdin); print(cfg.get('db_host', ''))"` ]]; do + echo "Waiting for configurator to finish..."; + sleep 5; + if (( `date +%s`-start > 120 )); then echo "Timed out"; break; fi + done; + echo "Starting site creation..."; + bench new-site ${SITE_NAME:-erp.localhost} \ + --no-mariadb-socket \ + --db-root-password=${DB_ROOT_PASSWORD:-changeme123} \ + --admin-password=${ADMIN_PASSWORD:-admin123} \ + --install-app erpnext; + bench --site ${SITE_NAME:-erp.localhost} set-config allow_cors 1; + echo "Site created successfully."; + environment: + DB_ROOT_PASSWORD: ${DB_ROOT_PASSWORD:-changeme123} + ADMIN_PASSWORD: ${ADMIN_PASSWORD:-admin123} + SITE_NAME: ${SITE_NAME:-erp.localhost} + networks: + - frappe-net + depends_on: + configurator: + condition: service_completed_successfully + + # ── Main backend ───────────────────────────────────────────────────────────── + backend: + image: docker.io/frappe/erpnext:${ERPNEXT_VERSION:-version-15} + restart: unless-stopped + volumes: + - sites:/home/frappe/frappe-bench/sites + - logs:/home/frappe/frappe-bench/logs + networks: + - frappe-net + depends_on: + create-site: + condition: service_completed_successfully + + # ── WebSocket server ───────────────────────────────────────────────────────── + websocket: + image: docker.io/frappe/erpnext:${ERPNEXT_VERSION:-version-15} + restart: unless-stopped + command: ["node", "/home/frappe/frappe-bench/apps/frappe/socketio.js"] + volumes: + - sites:/home/frappe/frappe-bench/sites + - logs:/home/frappe/frappe-bench/logs + networks: + - frappe-net + depends_on: + create-site: + condition: service_completed_successfully + + # ── Queue workers ───────────────────────────────────────────────────────────── + queue-short: + image: docker.io/frappe/erpnext:${ERPNEXT_VERSION:-version-15} + restart: unless-stopped + command: ["bench", "worker", "--queue", "short,default"] + volumes: + - sites:/home/frappe/frappe-bench/sites + - logs:/home/frappe/frappe-bench/logs + networks: + - frappe-net + depends_on: + create-site: + condition: service_completed_successfully + + queue-long: + image: docker.io/frappe/erpnext:${ERPNEXT_VERSION:-version-15} + restart: unless-stopped + command: ["bench", "worker", "--queue", "long,default,short"] + volumes: + - sites:/home/frappe/frappe-bench/sites + - logs:/home/frappe/frappe-bench/logs + networks: + - frappe-net + depends_on: + create-site: + condition: service_completed_successfully + + # ── Scheduler ───────────────────────────────────────────────────────────────── + scheduler: + image: docker.io/frappe/erpnext:${ERPNEXT_VERSION:-version-15} + restart: unless-stopped + command: ["bench", "schedule"] + volumes: + - sites:/home/frappe/frappe-bench/sites + - logs:/home/frappe/frappe-bench/logs + networks: + - frappe-net + depends_on: + create-site: + condition: service_completed_successfully + + # ── Nginx (reverse proxy + static files) ───────────────────────────────────── + frontend: + image: docker.io/frappe/erpnext:${ERPNEXT_VERSION:-version-15} + restart: unless-stopped + command: ["nginx-entrypoint.sh"] + environment: + BACKEND: backend:8000 + SOCKETIO: websocket:9000 + FRAPPE_SITE_NAME_HEADER: ${SITE_NAME:-erp.localhost} + UPSTREAM_REAL_IP_ADDRESS: 127.0.0.1 + UPSTREAM_REAL_IP_HEADER: X-Forwarded-For + UPSTREAM_REAL_IP_RECURSIVE: "off" + PROXY_READ_TIMEOUT: "120" + CLIENT_MAX_BODY_SIZE: "50m" + volumes: + - sites:/home/frappe/frappe-bench/sites + - logs:/home/frappe/frappe-bench/logs + ports: + - "${HTTP_PORT:-8080}:8080" + networks: + - frappe-net + depends_on: + backend: + condition: service_started + websocket: + condition: service_started + +volumes: + db-data: + redis-cache-data: + redis-queue-data: + sites: + logs: + +networks: + frappe-net: + driver: bridge diff --git a/frappe_mcp/__init__.py b/frappe_mcp/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/frappe_mcp/__pycache__/__init__.cpython-311.pyc b/frappe_mcp/__pycache__/__init__.cpython-311.pyc new file mode 100644 index 0000000..52a6f7e Binary files /dev/null and b/frappe_mcp/__pycache__/__init__.cpython-311.pyc differ diff --git a/frappe_mcp/__pycache__/__init__.cpython-314.pyc b/frappe_mcp/__pycache__/__init__.cpython-314.pyc new file mode 100644 index 0000000..1381676 Binary files /dev/null and b/frappe_mcp/__pycache__/__init__.cpython-314.pyc differ diff --git a/frappe_mcp/__pycache__/audit_store.cpython-311.pyc b/frappe_mcp/__pycache__/audit_store.cpython-311.pyc new file mode 100644 index 0000000..5cc96b0 Binary files /dev/null and b/frappe_mcp/__pycache__/audit_store.cpython-311.pyc differ diff --git a/frappe_mcp/__pycache__/audit_store.cpython-314.pyc b/frappe_mcp/__pycache__/audit_store.cpython-314.pyc new file mode 100644 index 0000000..b351e58 Binary files /dev/null and b/frappe_mcp/__pycache__/audit_store.cpython-314.pyc differ diff --git a/frappe_mcp/__pycache__/config.cpython-311.pyc b/frappe_mcp/__pycache__/config.cpython-311.pyc new file mode 100644 index 0000000..678fd2b Binary files /dev/null and b/frappe_mcp/__pycache__/config.cpython-311.pyc differ diff --git a/frappe_mcp/__pycache__/config.cpython-314.pyc b/frappe_mcp/__pycache__/config.cpython-314.pyc new file mode 100644 index 0000000..255e05a Binary files /dev/null and b/frappe_mcp/__pycache__/config.cpython-314.pyc differ diff --git a/frappe_mcp/__pycache__/healthcheck.cpython-314.pyc b/frappe_mcp/__pycache__/healthcheck.cpython-314.pyc new file mode 100644 index 0000000..36b8235 Binary files /dev/null and b/frappe_mcp/__pycache__/healthcheck.cpython-314.pyc differ diff --git a/frappe_mcp/__pycache__/module_registry.cpython-311.pyc b/frappe_mcp/__pycache__/module_registry.cpython-311.pyc new file mode 100644 index 0000000..f158e91 Binary files /dev/null and b/frappe_mcp/__pycache__/module_registry.cpython-311.pyc differ diff --git a/frappe_mcp/__pycache__/module_registry.cpython-314.pyc b/frappe_mcp/__pycache__/module_registry.cpython-314.pyc new file mode 100644 index 0000000..936d696 Binary files /dev/null and b/frappe_mcp/__pycache__/module_registry.cpython-314.pyc differ diff --git a/frappe_mcp/__pycache__/server.cpython-311.pyc b/frappe_mcp/__pycache__/server.cpython-311.pyc new file mode 100644 index 0000000..6293c0e Binary files /dev/null and b/frappe_mcp/__pycache__/server.cpython-311.pyc differ diff --git a/frappe_mcp/__pycache__/server.cpython-314.pyc b/frappe_mcp/__pycache__/server.cpython-314.pyc new file mode 100644 index 0000000..a2e6c12 Binary files /dev/null and b/frappe_mcp/__pycache__/server.cpython-314.pyc differ diff --git a/frappe_mcp/audit_store.py b/frappe_mcp/audit_store.py new file mode 100644 index 0000000..0e68a0f --- /dev/null +++ b/frappe_mcp/audit_store.py @@ -0,0 +1,55 @@ +""" +Local audit store — records every MCP tool call to a JSONL file on the MCP server. +This is MCP-level auditing (not Frappe-level). Complements Frappe's Version DocType. +""" + +import json +import os +import time +from pathlib import Path +from datetime import datetime, timezone + +_AUDIT_FILE = Path(os.environ.get("MCP_AUDIT_LOG", "frappe_mcp_audit.jsonl")) + + +def log_action(tool_name: str, arguments: dict, result_summary: str = "", risk: str = "low") -> str: + """Append one audit record. Returns the record ID.""" + record = { + "id": f"mcp-{int(time.time() * 1000)}", + "timestamp": datetime.now(timezone.utc).isoformat(), + "tool": tool_name, + "arguments": arguments, + "result_summary": result_summary[:200], + "risk": risk, + } + with _AUDIT_FILE.open("a", encoding="utf-8") as f: + f.write(json.dumps(record) + "\n") + return record["id"] + + +def read_audit_log(limit: int = 50, tool_filter: str = "") -> list[dict]: + if not _AUDIT_FILE.exists(): + return [] + records = [] + with _AUDIT_FILE.open("r", encoding="utf-8") as f: + for line in f: + line = line.strip() + if not line: + continue + try: + r = json.loads(line) + if tool_filter and tool_filter not in r.get("tool", ""): + continue + records.append(r) + except json.JSONDecodeError: + pass + return list(reversed(records))[:limit] + + +def clear_audit_log() -> int: + if not _AUDIT_FILE.exists(): + return 0 + with _AUDIT_FILE.open("r", encoding="utf-8") as f: + count = sum(1 for line in f if line.strip()) + _AUDIT_FILE.unlink() + return count diff --git a/frappe_mcp/client/__init__.py b/frappe_mcp/client/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/frappe_mcp/client/__pycache__/__init__.cpython-311.pyc b/frappe_mcp/client/__pycache__/__init__.cpython-311.pyc new file mode 100644 index 0000000..2d2a635 Binary files /dev/null and b/frappe_mcp/client/__pycache__/__init__.cpython-311.pyc differ diff --git a/frappe_mcp/client/__pycache__/__init__.cpython-314.pyc b/frappe_mcp/client/__pycache__/__init__.cpython-314.pyc new file mode 100644 index 0000000..42dc6d5 Binary files /dev/null and b/frappe_mcp/client/__pycache__/__init__.cpython-314.pyc differ diff --git a/frappe_mcp/client/__pycache__/frappe_api.cpython-311.pyc b/frappe_mcp/client/__pycache__/frappe_api.cpython-311.pyc new file mode 100644 index 0000000..b194444 Binary files /dev/null and b/frappe_mcp/client/__pycache__/frappe_api.cpython-311.pyc differ diff --git a/frappe_mcp/client/__pycache__/frappe_api.cpython-314.pyc b/frappe_mcp/client/__pycache__/frappe_api.cpython-314.pyc new file mode 100644 index 0000000..1d4223e Binary files /dev/null and b/frappe_mcp/client/__pycache__/frappe_api.cpython-314.pyc differ diff --git a/frappe_mcp/client/frappe_api.py b/frappe_mcp/client/frappe_api.py new file mode 100644 index 0000000..9048468 --- /dev/null +++ b/frappe_mcp/client/frappe_api.py @@ -0,0 +1,144 @@ +""" +Async HTTP client for Frappe REST API. +Handles API key/secret auth — works with any remote Frappe instance including Docker. +""" + +import json +import httpx +from typing import Any +from frappe_mcp.config import get_settings + + +class FrappeAPIError(Exception): + def __init__(self, message: str, status_code: int = 0, exc_type: str = ""): + super().__init__(message) + self.status_code = status_code + self.exc_type = exc_type + + +class FrappeClient: + def __init__(self): + self.settings = get_settings() + self.base_url = self.settings.frappe_url + self._auth_header = self._build_auth_header() + + def _build_auth_header(self) -> dict[str, str]: + key = self.settings.frappe_api_key + secret = self.settings.frappe_api_secret + if not key or not secret: + raise FrappeAPIError("FRAPPE_API_KEY and FRAPPE_API_SECRET must be set in .env") + return {"Authorization": f"token {key}:{secret}"} + + def _headers(self, extra: dict | None = None) -> dict[str, str]: + h = {**self._auth_header, "Content-Type": "application/json", "Accept": "application/json"} + if self.settings.frappe_site_name: + h["X-Frappe-Site-Name"] = self.settings.frappe_site_name + if extra: + h.update(extra) + return h + + def _url(self, path: str) -> str: + return f"{self.base_url}{path}" + + def _raise_for_frappe_error(self, data: dict) -> None: + if "exc_type" in data or ("message" in data and data.get("exc_type")): + raise FrappeAPIError( + data.get("message", "Unknown Frappe error"), + exc_type=data.get("exc_type", ""), + ) + + async def get(self, path: str, params: dict | None = None) -> Any: + async with httpx.AsyncClient(timeout=self.settings.request_timeout) as client: + resp = await client.get(self._url(path), headers=self._headers(), params=params) + resp.raise_for_status() + data = resp.json() + self._raise_for_frappe_error(data) + return data.get("data", data) + + def _handle_error_response(self, resp: httpx.Response) -> None: + """Extract the real Frappe error message from 4xx/5xx responses.""" + if resp.is_error: + try: + data = resp.json() + # Frappe puts the real error in _server_messages or exc + server_msgs = data.get("_server_messages", "") + exc = data.get("exc", "") + message = data.get("message", "") + if server_msgs: + import json as _json + try: + msgs = _json.loads(server_msgs) + parsed = [_json.loads(m).get("message", m) if isinstance(m, str) else m for m in msgs] + raise FrappeAPIError( + " | ".join(str(p) for p in parsed), + status_code=resp.status_code, + ) + except (ValueError, TypeError): + pass + if exc: + last_line = [l for l in exc.strip().splitlines() if l.strip()] + raise FrappeAPIError(last_line[-1] if last_line else exc, status_code=resp.status_code) + if message: + raise FrappeAPIError(message, status_code=resp.status_code) + except FrappeAPIError: + raise + except Exception: + pass + resp.raise_for_status() + + async def post(self, path: str, payload: dict | None = None) -> Any: + async with httpx.AsyncClient(timeout=self.settings.request_timeout) as client: + resp = await client.post(self._url(path), headers=self._headers(), json=payload or {}) + self._handle_error_response(resp) + data = resp.json() + self._raise_for_frappe_error(data) + return data.get("data", data) + + async def put(self, path: str, payload: dict | None = None) -> Any: + async with httpx.AsyncClient(timeout=self.settings.request_timeout) as client: + resp = await client.put(self._url(path), headers=self._headers(), json=payload or {}) + self._handle_error_response(resp) + data = resp.json() + self._raise_for_frappe_error(data) + return data.get("data", data) + + async def delete(self, path: str) -> Any: + async with httpx.AsyncClient(timeout=self.settings.request_timeout) as client: + resp = await client.delete(self._url(path), headers=self._headers()) + resp.raise_for_status() + data = resp.json() + self._raise_for_frappe_error(data) + return data.get("data", data) + + # --- Frappe-specific convenience methods --- + + async def call_method(self, method: str, **kwargs) -> Any: + """Call a whitelisted Frappe server-side method.""" + return await self.post(f"/api/method/{method}", payload=kwargs) + + async def get_doc(self, doctype: str, name: str) -> dict: + return await self.get(f"/api/resource/{doctype}/{name}") + + async def get_list( + self, + doctype: str, + fields: list[str] | None = None, + filters: list | None = None, + limit: int = 20, + order_by: str = "modified desc", + ) -> list[dict]: + params: dict[str, Any] = {"limit": limit, "order_by": order_by} + if fields: + params["fields"] = json.dumps(fields) + if filters: + params["filters"] = json.dumps(filters) + return await self.get(f"/api/resource/{doctype}", params=params) + + async def create_doc(self, doctype: str, data: dict) -> dict: + return await self.post(f"/api/resource/{doctype}", payload=data) + + async def update_doc(self, doctype: str, name: str, data: dict) -> dict: + return await self.put(f"/api/resource/{doctype}/{name}", payload=data) + + async def delete_doc(self, doctype: str, name: str) -> dict: + return await self.delete(f"/api/resource/{doctype}/{name}") diff --git a/frappe_mcp/config.py b/frappe_mcp/config.py new file mode 100644 index 0000000..aa34c63 --- /dev/null +++ b/frappe_mcp/config.py @@ -0,0 +1,31 @@ +from pydantic_settings import BaseSettings +from pydantic import field_validator +from functools import lru_cache + + +class Settings(BaseSettings): + frappe_url: str = "http://localhost:8000" + frappe_api_key: str = "" + frappe_api_secret: str = "" + frappe_site_name: str = "" # optional: for multi-site Docker setups + + # Safety: set to True to block destructive operations + read_only_mode: bool = False + + # Timeout for REST calls in seconds + request_timeout: int = 30 + + @field_validator("frappe_url") + @classmethod + def strip_trailing_slash(cls, v: str) -> str: + return v.rstrip("/") + + # Module activation — handled by module_registry, stored here so pydantic doesn't reject it + enabled_modules: str = "" + + model_config = {"env_file": ".env", "env_file_encoding": "utf-8", "extra": "ignore"} + + +@lru_cache +def get_settings() -> Settings: + return Settings() diff --git a/frappe_mcp/healthcheck.py b/frappe_mcp/healthcheck.py new file mode 100644 index 0000000..c172336 --- /dev/null +++ b/frappe_mcp/healthcheck.py @@ -0,0 +1,82 @@ +""" +Health check — run with: frappe-mcp --test-connection +Verifies connectivity, auth, and Frappe version before starting the MCP server. +""" + +import asyncio +import sys +from frappe_mcp.client.frappe_api import FrappeClient, FrappeAPIError +from frappe_mcp.config import get_settings + + +async def run_health_check() -> bool: + settings = get_settings() + passed = True + + print("\nFrappe MCP -- Connection Test") + print("=" * 45) + print(f" URL : {settings.frappe_url}") + print(f" API Key : {settings.frappe_api_key[:6]}..." if settings.frappe_api_key else " API Key : (not set)") + print(f" Site Name : {settings.frappe_site_name or '(default)'}") + print(f" Read-only : {settings.read_only_mode}") + print("=" * 45) + + client = FrappeClient() + + # 1. Ping / version check + print("\n[1/4] Checking Frappe version...", end=" ", flush=True) + try: + versions = await client.call_method("frappe.utils.change_log.get_versions") + frappe_ver = versions.get("frappe", {}).get("version", "unknown") + print(f"OK (Frappe {frappe_ver})") + except FrappeAPIError as e: + print(f"FAIL — {e}") + passed = False + except Exception as e: + print(f"FAIL — {e}") + passed = False + + # 2. Auth check — fetch current user + print("[2/4] Verifying API credentials...", end=" ", flush=True) + try: + user = await client.call_method("frappe.auth.get_logged_user") + print(f"OK (logged in as: {user})") + except FrappeAPIError as e: + print(f"FAIL — {e}") + passed = False + except Exception as e: + print(f"FAIL — {e}") + passed = False + + # 3. Read DocType list + print("[3/4] Testing DocType list access...", end=" ", flush=True) + try: + result = await client.get_list("DocType", fields=["name"], limit=1) + print(f"OK (found {len(result)} DocType(s))") + except Exception as e: + print(f"FAIL — {e}") + passed = False + + # 4. Write check (skipped in read-only mode) + if settings.read_only_mode: + print("[4/4] Write check... SKIPPED (read_only_mode=true)") + else: + print("[4/4] Testing write permission (System Settings read)...", end=" ", flush=True) + try: + await client.get_doc("System Settings", "System Settings") + print("OK") + except Exception as e: + print(f"WARN — {e}") + + print("=" * 45) + if passed: + print("PASS All checks passed. MCP server is ready.\n") + else: + print("FAIL Some checks failed. Fix the issues above before starting.\n") + + return passed + + +def main_health_check(): + ok = asyncio.run(run_health_check()) + sys.exit(0 if ok else 1) diff --git a/frappe_mcp/module_registry.py b/frappe_mcp/module_registry.py new file mode 100644 index 0000000..0136c72 --- /dev/null +++ b/frappe_mcp/module_registry.py @@ -0,0 +1,137 @@ +""" +Module Registry — controls which tool modules are active. + +Enable/disable via .env: + ENABLED_MODULES=documents,doctypes,users → whitelist mode + MODULE_SCHEDULER=false → disable one module + MODULE_GOVERNANCE=false → disable another + +Default: all modules enabled. +""" + +import os +from typing import Callable +from mcp.types import Tool + +# ── Import all tool modules ────────────────────────────────────────────────── +from frappe_mcp.tools import ( + foundation, + doctypes, + documents, + document_inspect, + document_lifecycle, + custom_fields, + scripts, + workflow_tools, + users, + admin, + analytics, + print_formats, + email_templates, + property_setters, + naming_series, + bulk_ops, + files, + activity, + dashboards, + webhooks, + reports, + scheduler, + translations, + business_actions, + governance, +) + +# ── Module definitions ─────────────────────────────────────────────────────── +# (module_key, module_object, description) +ALL_MODULES: list[tuple[str, object, str]] = [ + # Level 1 — Foundation + ("foundation", foundation, "L1: ping, session_info, doctype_meta, permissions, modules"), + # Level 2 — Read + ("doctypes", doctypes, "L1-2: DocType CRUD — create, read, update, list"), + ("documents", documents, "L2-3: Document CRUD — create, read, update, delete, submit, cancel"), + ("document_inspect", document_inspect, "L2: Search, children, linked docs, timeline, count, attachments"), + # Level 3-4 — Write + Lifecycle + ("document_lifecycle", document_lifecycle, "L3-4: Child rows, rename, amend, duplicate, status, draft save"), + ("custom_fields", custom_fields, "L2/9: Custom Fields — add, edit, remove, list"), + ("scripts", scripts, "L9: Server Scripts + Client Scripts"), + # Level 5 — Workflow + ("workflow_tools", workflow_tools, "L5: Workflows, transitions, approvals"), + # Level 6 — Reporting + ("analytics", analytics, "L6: Aggregate, dashboard data, PDF render, number cards"), + ("reports", reports, "L6/9: Query/Script Report CRUD"), + ("print_formats", print_formats, "L6: Print Format templates"), + # Level 7 — Bulk + ("bulk_ops", bulk_ops, "L7: Bulk create/update/delete/submit/cancel/assign/tag"), + # Level 8 — Business Actions + ("business_actions", business_actions, "L8: ERPNext shortcuts — Sales, Buying, Stock, HR, Support, Projects"), + # Level 9 — Admin / System Intelligence + ("users", users, "L9: Users, Roles, DocType permissions"), + ("admin", admin, "L9: Cache, system settings, SQL, workflows, scheduler"), + ("property_setters", property_setters, "L9: Property Setters + Customize Form"), + ("naming_series", naming_series, "L9: Naming Series — patterns and counters"), + ("files", files, "L2/9: File Manager — upload, list, delete, move"), + ("activity", activity, "L2/9: Comments, tags, assignments, ToDo, error logs"), + ("dashboards", dashboards, "L6/9: Dashboards, charts, number cards, workspaces"), + ("webhooks", webhooks, "L9: Webhooks + API Keys"), + ("email_templates", email_templates, "L9: Email Templates + Notifications"), + ("translations", translations, "L9: Translations, Assignment Rules, User Permissions"), + ("scheduler", scheduler, "L9: Scheduled Jobs + Background Jobs"), + # Level 10 — Safety & Governance + ("governance", governance, "L10: Dry run, validate, risk score, audit log, rollback"), +] + + +def _is_module_enabled(key: str) -> bool: + """ + Resolution order (first match wins): + 1. MODULE_=false → disabled + 2. MODULE_=true → enabled + 3. ENABLED_MODULES=a,b → whitelist — only listed enabled + 4. Default: enabled + """ + env_key = f"MODULE_{key.upper()}" + explicit = os.environ.get(env_key, "").strip().lower() + if explicit == "false": + return False + if explicit == "true": + return True + + enabled_list = os.environ.get("ENABLED_MODULES", "").strip() + if enabled_list: + return key in [m.strip() for m in enabled_list.split(",")] + + return True + + +def get_enabled_tools() -> list[Tool]: + tools: list[Tool] = [] + for key, module, _ in ALL_MODULES: + if _is_module_enabled(key): + tools.extend(module.tools()) + return tools + + +def get_enabled_handlers() -> dict[str, Callable]: + handlers: dict[str, Callable] = {} + for key, module, _ in ALL_MODULES: + if _is_module_enabled(key): + handlers.update(module.handlers()) + return handlers + + +def print_module_status(): + print("\nFrappe MCP — Module Status") + print("=" * 70) + total_on = 0 + total_all = 0 + for key, module, desc in ALL_MODULES: + enabled = _is_module_enabled(key) + count = len(module.tools()) + total_all += count + if enabled: + total_on += count + status = "ON " if enabled else "OFF" + print(f" [{status}] {key:<22} ({count:>2} tools) {desc}") + print("=" * 70) + print(f" Active: {total_on} tools | Total available: {total_all} tools\n") diff --git a/frappe_mcp/server.py b/frappe_mcp/server.py new file mode 100644 index 0000000..e133397 --- /dev/null +++ b/frappe_mcp/server.py @@ -0,0 +1,103 @@ +""" +Frappe MCP Server — entry point. +Modules can be individually enabled/disabled via ENABLED_MODULES in .env. + +Transports: + (default) stdio — for local Claude Desktop use + --sse HTTP/SSE — for VPS hosting (requires starlette + uvicorn) +""" + +import asyncio +import os +import sys +from mcp.server import Server +from mcp.server.stdio import stdio_server +from mcp.types import Tool, TextContent + +from frappe_mcp.module_registry import get_enabled_tools, get_enabled_handlers + +app = Server("frappe-mcp") + + +@app.list_tools() +async def list_tools() -> list[Tool]: + return get_enabled_tools() + + +@app.call_tool() +async def call_tool(name: str, arguments: dict) -> list[TextContent]: + handlers = get_enabled_handlers() + handler = handlers.get(name) + if not handler: + return [TextContent(type="text", text=f"Unknown tool: {name}")] + try: + result = await handler(arguments) + return [TextContent(type="text", text=result)] + except Exception as e: + return [TextContent(type="text", text=f"Error: {e}")] + + +def main(): + if "--test-connection" in sys.argv: + from frappe_mcp.healthcheck import run_health_check + ok = asyncio.run(run_health_check()) + sys.exit(0 if ok else 1) + if "--list-modules" in sys.argv: + from frappe_mcp.module_registry import print_module_status + print_module_status() + sys.exit(0) + if "--sse" in sys.argv: + _run_sse() + return + asyncio.run(_run_stdio()) + + +async def _run_stdio(): + async with stdio_server() as (read_stream, write_stream): + await app.run(read_stream, write_stream, app.create_initialization_options()) + + +def _run_sse(): + try: + import uvicorn + from starlette.applications import Starlette + from starlette.middleware import Middleware + from starlette.middleware.base import BaseHTTPMiddleware + from starlette.requests import Request + from starlette.responses import Response + from starlette.routing import Mount, Route + from mcp.server.sse import SseServerTransport + except ImportError: + print("SSE dependencies missing. Run: pip install 'frappe-mcp[sse]'", file=sys.stderr) + sys.exit(1) + + host = os.environ.get("MCP_HOST", "0.0.0.0") + port = int(os.environ.get("MCP_PORT", "8001")) + bearer_token = os.environ.get("MCP_BEARER_TOKEN", "") + + sse = SseServerTransport("/messages/") + + async def handle_sse(request: Request) -> Response: + if bearer_token: + auth = request.headers.get("Authorization", "") + if auth != f"Bearer {bearer_token}": + return Response("Unauthorized", status_code=401) + async with sse.connect_sse( + request.scope, request.receive, request._send + ) as streams: + await app.run(streams[0], streams[1], app.create_initialization_options()) + return Response() + + starlette_app = Starlette( + routes=[ + Route("/sse", endpoint=handle_sse), + Mount("/messages/", app=sse.handle_post_message), + ] + ) + + print(f"Frappe MCP SSE server starting on {host}:{port}") + uvicorn.run(starlette_app, host=host, port=port) + + +if __name__ == "__main__": + main() diff --git a/frappe_mcp/tools/__init__.py b/frappe_mcp/tools/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/frappe_mcp/tools/__pycache__/__init__.cpython-311.pyc b/frappe_mcp/tools/__pycache__/__init__.cpython-311.pyc new file mode 100644 index 0000000..826d0ef Binary files /dev/null and b/frappe_mcp/tools/__pycache__/__init__.cpython-311.pyc differ diff --git a/frappe_mcp/tools/__pycache__/__init__.cpython-314.pyc b/frappe_mcp/tools/__pycache__/__init__.cpython-314.pyc new file mode 100644 index 0000000..7e8b43c Binary files /dev/null and b/frappe_mcp/tools/__pycache__/__init__.cpython-314.pyc differ diff --git a/frappe_mcp/tools/__pycache__/activity.cpython-311.pyc b/frappe_mcp/tools/__pycache__/activity.cpython-311.pyc new file mode 100644 index 0000000..74d16cb Binary files /dev/null and b/frappe_mcp/tools/__pycache__/activity.cpython-311.pyc differ diff --git a/frappe_mcp/tools/__pycache__/activity.cpython-314.pyc b/frappe_mcp/tools/__pycache__/activity.cpython-314.pyc new file mode 100644 index 0000000..e03ce2d Binary files /dev/null and b/frappe_mcp/tools/__pycache__/activity.cpython-314.pyc differ diff --git a/frappe_mcp/tools/__pycache__/admin.cpython-311.pyc b/frappe_mcp/tools/__pycache__/admin.cpython-311.pyc new file mode 100644 index 0000000..8aadef4 Binary files /dev/null and b/frappe_mcp/tools/__pycache__/admin.cpython-311.pyc differ diff --git a/frappe_mcp/tools/__pycache__/admin.cpython-314.pyc b/frappe_mcp/tools/__pycache__/admin.cpython-314.pyc new file mode 100644 index 0000000..fd96f91 Binary files /dev/null and b/frappe_mcp/tools/__pycache__/admin.cpython-314.pyc differ diff --git a/frappe_mcp/tools/__pycache__/analytics.cpython-311.pyc b/frappe_mcp/tools/__pycache__/analytics.cpython-311.pyc new file mode 100644 index 0000000..9948f43 Binary files /dev/null and b/frappe_mcp/tools/__pycache__/analytics.cpython-311.pyc differ diff --git a/frappe_mcp/tools/__pycache__/analytics.cpython-314.pyc b/frappe_mcp/tools/__pycache__/analytics.cpython-314.pyc new file mode 100644 index 0000000..6e62409 Binary files /dev/null and b/frappe_mcp/tools/__pycache__/analytics.cpython-314.pyc differ diff --git a/frappe_mcp/tools/__pycache__/bulk_ops.cpython-311.pyc b/frappe_mcp/tools/__pycache__/bulk_ops.cpython-311.pyc new file mode 100644 index 0000000..438d203 Binary files /dev/null and b/frappe_mcp/tools/__pycache__/bulk_ops.cpython-311.pyc differ diff --git a/frappe_mcp/tools/__pycache__/bulk_ops.cpython-314.pyc b/frappe_mcp/tools/__pycache__/bulk_ops.cpython-314.pyc new file mode 100644 index 0000000..16c5570 Binary files /dev/null and b/frappe_mcp/tools/__pycache__/bulk_ops.cpython-314.pyc differ diff --git a/frappe_mcp/tools/__pycache__/business_actions.cpython-311.pyc b/frappe_mcp/tools/__pycache__/business_actions.cpython-311.pyc new file mode 100644 index 0000000..b9b9deb Binary files /dev/null and b/frappe_mcp/tools/__pycache__/business_actions.cpython-311.pyc differ diff --git a/frappe_mcp/tools/__pycache__/business_actions.cpython-314.pyc b/frappe_mcp/tools/__pycache__/business_actions.cpython-314.pyc new file mode 100644 index 0000000..8b5ac5a Binary files /dev/null and b/frappe_mcp/tools/__pycache__/business_actions.cpython-314.pyc differ diff --git a/frappe_mcp/tools/__pycache__/custom_fields.cpython-311.pyc b/frappe_mcp/tools/__pycache__/custom_fields.cpython-311.pyc new file mode 100644 index 0000000..f03f523 Binary files /dev/null and b/frappe_mcp/tools/__pycache__/custom_fields.cpython-311.pyc differ diff --git a/frappe_mcp/tools/__pycache__/custom_fields.cpython-314.pyc b/frappe_mcp/tools/__pycache__/custom_fields.cpython-314.pyc new file mode 100644 index 0000000..b253e0a Binary files /dev/null and b/frappe_mcp/tools/__pycache__/custom_fields.cpython-314.pyc differ diff --git a/frappe_mcp/tools/__pycache__/dashboards.cpython-311.pyc b/frappe_mcp/tools/__pycache__/dashboards.cpython-311.pyc new file mode 100644 index 0000000..f70d3e2 Binary files /dev/null and b/frappe_mcp/tools/__pycache__/dashboards.cpython-311.pyc differ diff --git a/frappe_mcp/tools/__pycache__/dashboards.cpython-314.pyc b/frappe_mcp/tools/__pycache__/dashboards.cpython-314.pyc new file mode 100644 index 0000000..301d003 Binary files /dev/null and b/frappe_mcp/tools/__pycache__/dashboards.cpython-314.pyc differ diff --git a/frappe_mcp/tools/__pycache__/doctypes.cpython-311.pyc b/frappe_mcp/tools/__pycache__/doctypes.cpython-311.pyc new file mode 100644 index 0000000..65b1c41 Binary files /dev/null and b/frappe_mcp/tools/__pycache__/doctypes.cpython-311.pyc differ diff --git a/frappe_mcp/tools/__pycache__/doctypes.cpython-314.pyc b/frappe_mcp/tools/__pycache__/doctypes.cpython-314.pyc new file mode 100644 index 0000000..7879677 Binary files /dev/null and b/frappe_mcp/tools/__pycache__/doctypes.cpython-314.pyc differ diff --git a/frappe_mcp/tools/__pycache__/document_inspect.cpython-311.pyc b/frappe_mcp/tools/__pycache__/document_inspect.cpython-311.pyc new file mode 100644 index 0000000..aea7280 Binary files /dev/null and b/frappe_mcp/tools/__pycache__/document_inspect.cpython-311.pyc differ diff --git a/frappe_mcp/tools/__pycache__/document_inspect.cpython-314.pyc b/frappe_mcp/tools/__pycache__/document_inspect.cpython-314.pyc new file mode 100644 index 0000000..0ab87f0 Binary files /dev/null and b/frappe_mcp/tools/__pycache__/document_inspect.cpython-314.pyc differ diff --git a/frappe_mcp/tools/__pycache__/document_lifecycle.cpython-311.pyc b/frappe_mcp/tools/__pycache__/document_lifecycle.cpython-311.pyc new file mode 100644 index 0000000..67c2d1b Binary files /dev/null and b/frappe_mcp/tools/__pycache__/document_lifecycle.cpython-311.pyc differ diff --git a/frappe_mcp/tools/__pycache__/document_lifecycle.cpython-314.pyc b/frappe_mcp/tools/__pycache__/document_lifecycle.cpython-314.pyc new file mode 100644 index 0000000..056f0bc Binary files /dev/null and b/frappe_mcp/tools/__pycache__/document_lifecycle.cpython-314.pyc differ diff --git a/frappe_mcp/tools/__pycache__/documents.cpython-311.pyc b/frappe_mcp/tools/__pycache__/documents.cpython-311.pyc new file mode 100644 index 0000000..fb7ce74 Binary files /dev/null and b/frappe_mcp/tools/__pycache__/documents.cpython-311.pyc differ diff --git a/frappe_mcp/tools/__pycache__/documents.cpython-314.pyc b/frappe_mcp/tools/__pycache__/documents.cpython-314.pyc new file mode 100644 index 0000000..f1cedb6 Binary files /dev/null and b/frappe_mcp/tools/__pycache__/documents.cpython-314.pyc differ diff --git a/frappe_mcp/tools/__pycache__/email_templates.cpython-311.pyc b/frappe_mcp/tools/__pycache__/email_templates.cpython-311.pyc new file mode 100644 index 0000000..4675c2c Binary files /dev/null and b/frappe_mcp/tools/__pycache__/email_templates.cpython-311.pyc differ diff --git a/frappe_mcp/tools/__pycache__/email_templates.cpython-314.pyc b/frappe_mcp/tools/__pycache__/email_templates.cpython-314.pyc new file mode 100644 index 0000000..9876f83 Binary files /dev/null and b/frappe_mcp/tools/__pycache__/email_templates.cpython-314.pyc differ diff --git a/frappe_mcp/tools/__pycache__/files.cpython-311.pyc b/frappe_mcp/tools/__pycache__/files.cpython-311.pyc new file mode 100644 index 0000000..0204e0b Binary files /dev/null and b/frappe_mcp/tools/__pycache__/files.cpython-311.pyc differ diff --git a/frappe_mcp/tools/__pycache__/files.cpython-314.pyc b/frappe_mcp/tools/__pycache__/files.cpython-314.pyc new file mode 100644 index 0000000..dcc08f3 Binary files /dev/null and b/frappe_mcp/tools/__pycache__/files.cpython-314.pyc differ diff --git a/frappe_mcp/tools/__pycache__/foundation.cpython-311.pyc b/frappe_mcp/tools/__pycache__/foundation.cpython-311.pyc new file mode 100644 index 0000000..77400f0 Binary files /dev/null and b/frappe_mcp/tools/__pycache__/foundation.cpython-311.pyc differ diff --git a/frappe_mcp/tools/__pycache__/foundation.cpython-314.pyc b/frappe_mcp/tools/__pycache__/foundation.cpython-314.pyc new file mode 100644 index 0000000..936c179 Binary files /dev/null and b/frappe_mcp/tools/__pycache__/foundation.cpython-314.pyc differ diff --git a/frappe_mcp/tools/__pycache__/governance.cpython-311.pyc b/frappe_mcp/tools/__pycache__/governance.cpython-311.pyc new file mode 100644 index 0000000..185da25 Binary files /dev/null and b/frappe_mcp/tools/__pycache__/governance.cpython-311.pyc differ diff --git a/frappe_mcp/tools/__pycache__/governance.cpython-314.pyc b/frappe_mcp/tools/__pycache__/governance.cpython-314.pyc new file mode 100644 index 0000000..124df4b Binary files /dev/null and b/frappe_mcp/tools/__pycache__/governance.cpython-314.pyc differ diff --git a/frappe_mcp/tools/__pycache__/naming_series.cpython-311.pyc b/frappe_mcp/tools/__pycache__/naming_series.cpython-311.pyc new file mode 100644 index 0000000..6a8b329 Binary files /dev/null and b/frappe_mcp/tools/__pycache__/naming_series.cpython-311.pyc differ diff --git a/frappe_mcp/tools/__pycache__/naming_series.cpython-314.pyc b/frappe_mcp/tools/__pycache__/naming_series.cpython-314.pyc new file mode 100644 index 0000000..43df70c Binary files /dev/null and b/frappe_mcp/tools/__pycache__/naming_series.cpython-314.pyc differ diff --git a/frappe_mcp/tools/__pycache__/print_formats.cpython-311.pyc b/frappe_mcp/tools/__pycache__/print_formats.cpython-311.pyc new file mode 100644 index 0000000..bc4d177 Binary files /dev/null and b/frappe_mcp/tools/__pycache__/print_formats.cpython-311.pyc differ diff --git a/frappe_mcp/tools/__pycache__/print_formats.cpython-314.pyc b/frappe_mcp/tools/__pycache__/print_formats.cpython-314.pyc new file mode 100644 index 0000000..aaea6eb Binary files /dev/null and b/frappe_mcp/tools/__pycache__/print_formats.cpython-314.pyc differ diff --git a/frappe_mcp/tools/__pycache__/property_setters.cpython-311.pyc b/frappe_mcp/tools/__pycache__/property_setters.cpython-311.pyc new file mode 100644 index 0000000..fa5ceb0 Binary files /dev/null and b/frappe_mcp/tools/__pycache__/property_setters.cpython-311.pyc differ diff --git a/frappe_mcp/tools/__pycache__/property_setters.cpython-314.pyc b/frappe_mcp/tools/__pycache__/property_setters.cpython-314.pyc new file mode 100644 index 0000000..5ea860a Binary files /dev/null and b/frappe_mcp/tools/__pycache__/property_setters.cpython-314.pyc differ diff --git a/frappe_mcp/tools/__pycache__/reports.cpython-311.pyc b/frappe_mcp/tools/__pycache__/reports.cpython-311.pyc new file mode 100644 index 0000000..6ebf4b5 Binary files /dev/null and b/frappe_mcp/tools/__pycache__/reports.cpython-311.pyc differ diff --git a/frappe_mcp/tools/__pycache__/reports.cpython-314.pyc b/frappe_mcp/tools/__pycache__/reports.cpython-314.pyc new file mode 100644 index 0000000..79f4261 Binary files /dev/null and b/frappe_mcp/tools/__pycache__/reports.cpython-314.pyc differ diff --git a/frappe_mcp/tools/__pycache__/scheduler.cpython-311.pyc b/frappe_mcp/tools/__pycache__/scheduler.cpython-311.pyc new file mode 100644 index 0000000..0f01921 Binary files /dev/null and b/frappe_mcp/tools/__pycache__/scheduler.cpython-311.pyc differ diff --git a/frappe_mcp/tools/__pycache__/scheduler.cpython-314.pyc b/frappe_mcp/tools/__pycache__/scheduler.cpython-314.pyc new file mode 100644 index 0000000..f1b3634 Binary files /dev/null and b/frappe_mcp/tools/__pycache__/scheduler.cpython-314.pyc differ diff --git a/frappe_mcp/tools/__pycache__/scripts.cpython-311.pyc b/frappe_mcp/tools/__pycache__/scripts.cpython-311.pyc new file mode 100644 index 0000000..d150779 Binary files /dev/null and b/frappe_mcp/tools/__pycache__/scripts.cpython-311.pyc differ diff --git a/frappe_mcp/tools/__pycache__/scripts.cpython-314.pyc b/frappe_mcp/tools/__pycache__/scripts.cpython-314.pyc new file mode 100644 index 0000000..a15168f Binary files /dev/null and b/frappe_mcp/tools/__pycache__/scripts.cpython-314.pyc differ diff --git a/frappe_mcp/tools/__pycache__/translations.cpython-311.pyc b/frappe_mcp/tools/__pycache__/translations.cpython-311.pyc new file mode 100644 index 0000000..311727d Binary files /dev/null and b/frappe_mcp/tools/__pycache__/translations.cpython-311.pyc differ diff --git a/frappe_mcp/tools/__pycache__/translations.cpython-314.pyc b/frappe_mcp/tools/__pycache__/translations.cpython-314.pyc new file mode 100644 index 0000000..0bd4c42 Binary files /dev/null and b/frappe_mcp/tools/__pycache__/translations.cpython-314.pyc differ diff --git a/frappe_mcp/tools/__pycache__/users.cpython-311.pyc b/frappe_mcp/tools/__pycache__/users.cpython-311.pyc new file mode 100644 index 0000000..472c01d Binary files /dev/null and b/frappe_mcp/tools/__pycache__/users.cpython-311.pyc differ diff --git a/frappe_mcp/tools/__pycache__/users.cpython-314.pyc b/frappe_mcp/tools/__pycache__/users.cpython-314.pyc new file mode 100644 index 0000000..6f5f301 Binary files /dev/null and b/frappe_mcp/tools/__pycache__/users.cpython-314.pyc differ diff --git a/frappe_mcp/tools/__pycache__/webhooks.cpython-311.pyc b/frappe_mcp/tools/__pycache__/webhooks.cpython-311.pyc new file mode 100644 index 0000000..9bd396e Binary files /dev/null and b/frappe_mcp/tools/__pycache__/webhooks.cpython-311.pyc differ diff --git a/frappe_mcp/tools/__pycache__/webhooks.cpython-314.pyc b/frappe_mcp/tools/__pycache__/webhooks.cpython-314.pyc new file mode 100644 index 0000000..55a231b Binary files /dev/null and b/frappe_mcp/tools/__pycache__/webhooks.cpython-314.pyc differ diff --git a/frappe_mcp/tools/__pycache__/workflow_tools.cpython-311.pyc b/frappe_mcp/tools/__pycache__/workflow_tools.cpython-311.pyc new file mode 100644 index 0000000..0b9cfdb Binary files /dev/null and b/frappe_mcp/tools/__pycache__/workflow_tools.cpython-311.pyc differ diff --git a/frappe_mcp/tools/__pycache__/workflow_tools.cpython-314.pyc b/frappe_mcp/tools/__pycache__/workflow_tools.cpython-314.pyc new file mode 100644 index 0000000..39bfbe3 Binary files /dev/null and b/frappe_mcp/tools/__pycache__/workflow_tools.cpython-314.pyc differ diff --git a/frappe_mcp/tools/activity.py b/frappe_mcp/tools/activity.py new file mode 100644 index 0000000..eec556c --- /dev/null +++ b/frappe_mcp/tools/activity.py @@ -0,0 +1,390 @@ +""" +Activity, collaboration, and system log tools. +Covers: Comments, Tags, Assignments, ToDo, Activity Log, Error Log, Document Follow. +""" + +import json +from mcp.types import Tool +from frappe_mcp.client.frappe_api import FrappeClient + + +def tools() -> list[Tool]: + return [ + # --- Comments --- + Tool( + name="frappe_add_comment", + description="Add a comment to any document.", + inputSchema={ + "type": "object", + "required": ["reference_doctype", "reference_name", "content"], + "properties": { + "reference_doctype": {"type": "string"}, + "reference_name": {"type": "string"}, + "content": {"type": "string"}, + "comment_type": { + "type": "string", + "enum": ["Comment", "Info", "Warning", "Error", "Workflow", "Label"], + "default": "Comment", + }, + }, + }, + ), + Tool( + name="frappe_get_comments", + description="Get all comments for a document.", + inputSchema={ + "type": "object", + "required": ["reference_doctype", "reference_name"], + "properties": { + "reference_doctype": {"type": "string"}, + "reference_name": {"type": "string"}, + }, + }, + ), + # --- Tags --- + Tool( + name="frappe_add_tag", + description="Add a tag to a document.", + inputSchema={ + "type": "object", + "required": ["doctype", "name", "tag"], + "properties": { + "doctype": {"type": "string"}, + "name": {"type": "string"}, + "tag": {"type": "string"}, + }, + }, + ), + Tool( + name="frappe_remove_tag", + description="Remove a tag from a document.", + inputSchema={ + "type": "object", + "required": ["doctype", "name", "tag"], + "properties": { + "doctype": {"type": "string"}, + "name": {"type": "string"}, + "tag": {"type": "string"}, + }, + }, + ), + Tool( + name="frappe_get_tags", + description="Get all tags on a document.", + inputSchema={ + "type": "object", + "required": ["doctype", "name"], + "properties": { + "doctype": {"type": "string"}, + "name": {"type": "string"}, + }, + }, + ), + # --- Assignments --- + Tool( + name="frappe_assign_document", + description="Assign a document to one or more users.", + inputSchema={ + "type": "object", + "required": ["doctype", "name", "assign_to"], + "properties": { + "doctype": {"type": "string"}, + "name": {"type": "string"}, + "assign_to": { + "type": "array", + "items": {"type": "string"}, + "description": "List of user emails to assign", + }, + "description": {"type": "string"}, + "due_date": {"type": "string", "description": "YYYY-MM-DD"}, + "priority": {"type": "string", "enum": ["Low", "Medium", "High"], "default": "Medium"}, + "notify": {"type": "integer", "default": 0}, + }, + }, + ), + Tool( + name="frappe_remove_assignment", + description="Remove assignment of a document from a user.", + inputSchema={ + "type": "object", + "required": ["doctype", "name", "assign_to"], + "properties": { + "doctype": {"type": "string"}, + "name": {"type": "string"}, + "assign_to": {"type": "string", "description": "User email to unassign"}, + }, + }, + ), + Tool( + name="frappe_get_assignments", + description="Get all active assignments for a document.", + inputSchema={ + "type": "object", + "required": ["doctype", "name"], + "properties": { + "doctype": {"type": "string"}, + "name": {"type": "string"}, + }, + }, + ), + # --- ToDo --- + Tool( + name="frappe_create_todo", + description="Create a ToDo item (task) optionally linked to a document.", + inputSchema={ + "type": "object", + "required": ["description"], + "properties": { + "description": {"type": "string"}, + "assigned_by": {"type": "string"}, + "owner": {"type": "string", "description": "Assigned to user"}, + "reference_type": {"type": "string"}, + "reference_name": {"type": "string"}, + "due_date": {"type": "string"}, + "priority": {"type": "string", "enum": ["Low", "Medium", "High"], "default": "Medium"}, + "status": {"type": "string", "enum": ["Open", "Closed"], "default": "Open"}, + }, + }, + ), + # --- Activity Log --- + Tool( + name="frappe_get_activity_log", + description="Get the activity log for a specific document (who changed what and when).", + inputSchema={ + "type": "object", + "required": ["doctype", "name"], + "properties": { + "doctype": {"type": "string"}, + "name": {"type": "string"}, + "limit": {"type": "integer", "default": 20}, + }, + }, + ), + # --- Error Logs --- + Tool( + name="frappe_get_error_logs", + description="Get recent system error logs.", + inputSchema={ + "type": "object", + "properties": { + "limit": {"type": "integer", "default": 20}, + "method": {"type": "string", "description": "Filter by method name"}, + }, + }, + ), + Tool( + name="frappe_clear_error_logs", + description="Clear all error logs.", + inputSchema={"type": "object", "properties": {}}, + ), + # --- Document Follow --- + Tool( + name="frappe_follow_document", + description="Follow a document to receive notifications on changes.", + inputSchema={ + "type": "object", + "required": ["doctype", "name"], + "properties": { + "doctype": {"type": "string"}, + "name": {"type": "string"}, + }, + }, + ), + Tool( + name="frappe_unfollow_document", + description="Unfollow a document.", + inputSchema={ + "type": "object", + "required": ["doctype", "name"], + "properties": { + "doctype": {"type": "string"}, + "name": {"type": "string"}, + }, + }, + ), + ] + + +def handlers() -> dict: + return { + "frappe_add_comment": _add_comment, + "frappe_get_comments": _get_comments, + "frappe_add_tag": _add_tag, + "frappe_remove_tag": _remove_tag, + "frappe_get_tags": _get_tags, + "frappe_assign_document": _assign_document, + "frappe_remove_assignment": _remove_assignment, + "frappe_get_assignments": _get_assignments, + "frappe_create_todo": _create_todo, + "frappe_get_activity_log": _get_activity_log, + "frappe_get_error_logs": _get_error_logs, + "frappe_clear_error_logs": _clear_error_logs, + "frappe_follow_document": _follow_document, + "frappe_unfollow_document": _unfollow_document, + } + + +async def _add_comment(args: dict) -> str: + client = FrappeClient() + result = await client.call_method( + "frappe.client.add_comment", + reference_doctype=args["reference_doctype"], + reference_name=args["reference_name"], + content=args["content"], + comment_email="", + comment_by="", + ) + return json.dumps(result, indent=2) + + +async def _get_comments(args: dict) -> str: + client = FrappeClient() + result = await client.get_list( + "Comment", + fields=["name", "comment_type", "comment_by", "content", "creation"], + filters=[ + ["reference_doctype", "=", args["reference_doctype"]], + ["reference_name", "=", args["reference_name"]], + ], + limit=50, + order_by="creation asc", + ) + return json.dumps(result, indent=2) + + +async def _add_tag(args: dict) -> str: + client = FrappeClient() + result = await client.call_method( + "frappe.desk.doctype.tag.tag.add_tag", + tag=args["tag"], + dt=args["doctype"], + dn=args["name"], + ) + return json.dumps(result, indent=2) + + +async def _remove_tag(args: dict) -> str: + client = FrappeClient() + result = await client.call_method( + "frappe.desk.doctype.tag.tag.remove_tag", + tag=args["tag"], + dt=args["doctype"], + dn=args["name"], + ) + return json.dumps(result, indent=2) + + +async def _get_tags(args: dict) -> str: + client = FrappeClient() + result = await client.call_method( + "frappe.desk.doctype.tag.tag.get_tags", + dt=args["doctype"], + dn=args["name"], + ) + return json.dumps(result, indent=2) + + +async def _assign_document(args: dict) -> str: + client = FrappeClient() + result = await client.call_method( + "frappe.desk.form.assign_to.add", + args={ + "doctype": args["doctype"], + "name": args["name"], + "assign_to": args["assign_to"], + "description": args.get("description", ""), + "due_date": args.get("due_date", ""), + "priority": args.get("priority", "Medium"), + "notify": args.get("notify", 0), + }, + ) + return json.dumps(result, indent=2) + + +async def _remove_assignment(args: dict) -> str: + client = FrappeClient() + result = await client.call_method( + "frappe.desk.form.assign_to.remove", + doctype=args["doctype"], + name=args["name"], + assign_to=args["assign_to"], + ) + return json.dumps(result, indent=2) + + +async def _get_assignments(args: dict) -> str: + client = FrappeClient() + result = await client.get_list( + "ToDo", + fields=["name", "owner", "description", "due_date", "priority", "status"], + filters=[ + ["reference_type", "=", args["doctype"]], + ["reference_name", "=", args["name"]], + ["status", "=", "Open"], + ], + limit=20, + ) + return json.dumps(result, indent=2) + + +async def _create_todo(args: dict) -> str: + client = FrappeClient() + payload = {k: v for k, v in args.items()} + result = await client.create_doc("ToDo", payload) + return json.dumps(result, indent=2) + + +async def _get_activity_log(args: dict) -> str: + client = FrappeClient() + result = await client.get_list( + "Version", + fields=["name", "owner", "creation", "data"], + filters=[ + ["ref_doctype", "=", args["doctype"]], + ["docname", "=", args["name"]], + ], + limit=args.get("limit", 20), + order_by="creation desc", + ) + return json.dumps(result, indent=2) + + +async def _get_error_logs(args: dict) -> str: + client = FrappeClient() + filters = [] + if method := args.get("method"): + filters.append(["method", "like", f"%{method}%"]) + result = await client.get_list( + "Error Log", + fields=["name", "method", "error", "creation"], + filters=filters if filters else None, + limit=args.get("limit", 20), + order_by="creation desc", + ) + return json.dumps(result, indent=2) + + +async def _clear_error_logs(args: dict) -> str: + client = FrappeClient() + result = await client.call_method("frappe.core.doctype.error_log.error_log.clear_error_logs") + return json.dumps(result, indent=2) + + +async def _follow_document(args: dict) -> str: + client = FrappeClient() + result = await client.call_method( + "frappe.desk.form.document_follow.follow_document", + doctype=args["doctype"], + doc_name=args["name"], + ) + return json.dumps(result, indent=2) + + +async def _unfollow_document(args: dict) -> str: + client = FrappeClient() + result = await client.call_method( + "frappe.desk.form.document_follow.unfollow_document", + doctype=args["doctype"], + doc_name=args["name"], + ) + return json.dumps(result, indent=2) diff --git a/frappe_mcp/tools/admin.py b/frappe_mcp/tools/admin.py new file mode 100644 index 0000000..425acad --- /dev/null +++ b/frappe_mcp/tools/admin.py @@ -0,0 +1,185 @@ +"""Admin / bench tools — cache, migrate, app management, system settings.""" + +import json +from mcp.types import Tool +from frappe_mcp.client.frappe_api import FrappeClient +from frappe_mcp.config import get_settings + + +def tools() -> list[Tool]: + return [ + Tool( + name="frappe_clear_cache", + description="Clear Frappe's server-side cache.", + inputSchema={"type": "object", "properties": {}}, + ), + Tool( + name="frappe_get_installed_apps", + description="List all Frappe apps installed on the site.", + inputSchema={"type": "object", "properties": {}}, + ), + Tool( + name="frappe_get_system_settings", + description="Get the current System Settings document.", + inputSchema={"type": "object", "properties": {}}, + ), + Tool( + name="frappe_update_system_settings", + description="Update System Settings fields.", + inputSchema={ + "type": "object", + "required": ["updates"], + "properties": { + "updates": { + "type": "object", + "description": "Fields to update e.g. {\"language\": \"en\", \"time_zone\": \"Asia/Kolkata\"}", + }, + }, + }, + ), + Tool( + name="frappe_run_report", + description="Run a Script Report or Query Report and return results.", + inputSchema={ + "type": "object", + "required": ["report_name"], + "properties": { + "report_name": {"type": "string"}, + "filters": { + "type": "object", + "description": "Report filters as key-value pairs", + }, + }, + }, + ), + Tool( + name="frappe_execute_query", + description=( + "Execute a read-only SQL query on the Frappe database via frappe.db.sql. " + "Only SELECT statements are allowed." + ), + inputSchema={ + "type": "object", + "required": ["query"], + "properties": { + "query": {"type": "string", "description": "SQL SELECT query"}, + "values": { + "type": "array", + "description": "Parameterized values for %s placeholders", + }, + }, + }, + ), + Tool( + name="frappe_get_site_info", + description="Get basic site information: Frappe version, installed apps, site config.", + inputSchema={"type": "object", "properties": {}}, + ), + Tool( + name="frappe_create_workflow", + description="Create a Workflow for a DocType with states and transitions.", + inputSchema={ + "type": "object", + "required": ["workflow_name", "document_type", "states", "transitions"], + "properties": { + "workflow_name": {"type": "string"}, + "document_type": {"type": "string"}, + "is_active": {"type": "integer", "default": 1}, + "states": { + "type": "array", + "description": "List of workflow state objects with: state, doc_status, allow_edit", + "items": {"type": "object"}, + }, + "transitions": { + "type": "array", + "description": "List of transition objects with: state, action, next_state, allowed", + "items": {"type": "object"}, + }, + "send_email_alert": {"type": "integer", "default": 0}, + }, + }, + ), + ] + + +def handlers() -> dict: + return { + "frappe_clear_cache": _clear_cache, + "frappe_get_installed_apps": _get_installed_apps, + "frappe_get_system_settings": _get_system_settings, + "frappe_update_system_settings": _update_system_settings, + "frappe_run_report": _run_report, + "frappe_execute_query": _execute_query, + "frappe_get_site_info": _get_site_info, + "frappe_create_workflow": _create_workflow, + } + + +async def _clear_cache(args: dict) -> str: + client = FrappeClient() + result = await client.call_method("frappe.sessions.clear") + return json.dumps(result, indent=2) + + +async def _get_installed_apps(args: dict) -> str: + client = FrappeClient() + result = await client.call_method("frappe.utils.change_log.get_versions") + return json.dumps(result, indent=2) + + +async def _get_system_settings(args: dict) -> str: + client = FrappeClient() + result = await client.get_doc("System Settings", "System Settings") + return json.dumps(result, indent=2) + + +async def _update_system_settings(args: dict) -> str: + if get_settings().read_only_mode: + return "Error: read_only_mode is enabled." + client = FrappeClient() + result = await client.update_doc("System Settings", "System Settings", args["updates"]) + return json.dumps(result, indent=2) + + +async def _run_report(args: dict) -> str: + client = FrappeClient() + result = await client.call_method( + "frappe.desk.query_report.run", + report_name=args["report_name"], + filters=args.get("filters", {}), + ) + return json.dumps(result, indent=2) + + +async def _execute_query(args: dict) -> str: + query = args["query"].strip().lower() + if not query.startswith("select"): + return "Error: only SELECT queries are allowed." + client = FrappeClient() + result = await client.call_method( + "frappe.client.get_list", + doctype="__query__", + query=args["query"], + values=args.get("values", []), + ) + return json.dumps(result, indent=2) + + +async def _get_site_info(args: dict) -> str: + client = FrappeClient() + result = await client.call_method("frappe.utils.change_log.get_versions") + return json.dumps(result, indent=2) + + +async def _create_workflow(args: dict) -> str: + client = FrappeClient() + payload = { + "workflow_name": args["workflow_name"], + "document_type": args["document_type"], + "is_active": args.get("is_active", 1), + "send_email_alert": args.get("send_email_alert", 0), + "states": args["states"], + "transitions": args["transitions"], + } + result = await client.create_doc("Workflow", payload) + return json.dumps(result, indent=2) diff --git a/frappe_mcp/tools/analytics.py b/frappe_mcp/tools/analytics.py new file mode 100644 index 0000000..c63513b --- /dev/null +++ b/frappe_mcp/tools/analytics.py @@ -0,0 +1,176 @@ +""" +Level 6 — Reporting and analytics tools. +Aggregate queries, dashboard data, PDF rendering. +""" + +import json +from mcp.types import Tool +from frappe_mcp.client.frappe_api import FrappeClient + + +def tools() -> list[Tool]: + return [ + Tool( + name="frappe_aggregate_documents", + description=( + "Aggregate document data: sum, avg, min, max, or count grouped by a field. " + "e.g. total sales per customer, count of issues per status." + ), + inputSchema={ + "type": "object", + "required": ["doctype", "function", "field"], + "properties": { + "doctype": {"type": "string"}, + "function": { + "type": "string", + "enum": ["sum", "avg", "min", "max", "count"], + }, + "field": {"type": "string", "description": "Field to aggregate e.g. 'grand_total'"}, + "group_by": {"type": "string", "description": "Field to group by e.g. 'customer'"}, + "filters": {"type": "array"}, + "limit": {"type": "integer", "default": 50}, + }, + }, + ), + Tool( + name="frappe_get_dashboard_data", + description=( + "Pull data for a Dashboard Chart — returns the chart's data points " + "for display as a summary or KPI." + ), + inputSchema={ + "type": "object", + "required": ["chart_name"], + "properties": { + "chart_name": {"type": "string"}, + "filters": {"type": "object"}, + "refresh": {"type": "boolean", "default": False}, + }, + }, + ), + Tool( + name="frappe_render_document_pdf", + description=( + "Generate a PDF for a document using a Print Format. " + "Returns the PDF download URL on the Frappe server." + ), + inputSchema={ + "type": "object", + "required": ["doctype", "name"], + "properties": { + "doctype": {"type": "string"}, + "name": {"type": "string"}, + "print_format": {"type": "string", "description": "Print Format name (optional, uses default if empty)"}, + "letterhead": {"type": "string"}, + "language": {"type": "string", "default": "en"}, + }, + }, + ), + Tool( + name="frappe_get_number_card_value", + description="Get the current value of a Number Card (single KPI metric).", + inputSchema={ + "type": "object", + "required": ["card_name"], + "properties": { + "card_name": {"type": "string"}, + "filters": {"type": "object"}, + }, + }, + ), + ] + + +def handlers() -> dict: + return { + "frappe_aggregate_documents": _aggregate_documents, + "frappe_get_dashboard_data": _get_dashboard_data, + "frappe_render_document_pdf": _render_document_pdf, + "frappe_get_number_card_value": _get_number_card_value, + } + + +async def _aggregate_documents(args: dict) -> str: + client = FrappeClient() + func = args["function"].upper() + field = args["field"] + group_by = args.get("group_by") + filters = args.get("filters") + + if group_by: + sql = f"SELECT `{group_by}`, {func}(`{field}`) as value FROM `tab{args['doctype']}`" + conditions = [] + if filters and isinstance(filters, list): + for f in filters: + if len(f) >= 3: + conditions.append(f"`{f[0]}` {f[1]} '{f[2]}'") + if conditions: + sql += " WHERE " + " AND ".join(conditions) + sql += f" GROUP BY `{group_by}` ORDER BY value DESC LIMIT {args.get('limit', 50)}" + result = await client.call_method("frappe.client.get_list", doctype="__query__", query=sql) + else: + sql = f"SELECT {func}(`{field}`) as value FROM `tab{args['doctype']}`" + conditions = [] + if filters and isinstance(filters, list): + for f in filters: + if len(f) >= 3: + conditions.append(f"`{f[0]}` {f[1]} '{f[2]}'") + if conditions: + sql += " WHERE " + " AND ".join(conditions) + result = await client.call_method( + "frappe.desk.query_report.run", + report_name="__inline__", + filters={}, + ) + # Fallback: use get_list with aggregation via API + result = await client.call_method( + "frappe.client.get_value", + doctype=args["doctype"], + filters=filters, + fieldname=f"{func.lower()}({field})", + ) + return json.dumps({"function": func, "field": field, "group_by": group_by, "result": result}, indent=2) + + +async def _get_dashboard_data(args: dict) -> str: + client = FrappeClient() + result = await client.call_method( + "frappe.desk.doctype.dashboard_chart.dashboard_chart.get", + chart_name=args["chart_name"], + filters=json.dumps(args.get("filters", {})), + refresh=args.get("refresh", False), + ) + return json.dumps(result, indent=2) + + +async def _render_document_pdf(args: dict) -> str: + client = FrappeClient() + params = { + "doctype": args["doctype"], + "name": args["name"], + "print_format": args.get("print_format", ""), + "letterhead": args.get("letterhead", ""), + "lang": args.get("language", "en"), + } + pdf_url = ( + f"{client.base_url}/api/method/frappe.utils.pdf.download_pdf" + f"?doctype={params['doctype']}&name={params['name']}" + f"&format={params['print_format']}&lang={params['lang']}" + ) + return json.dumps({ + "pdf_url": pdf_url, + "doctype": args["doctype"], + "name": args["name"], + "print_format": args.get("print_format", "default"), + "note": "Open pdf_url in browser or download directly from Frappe server", + }, indent=2) + + +async def _get_number_card_value(args: dict) -> str: + client = FrappeClient() + result = await client.call_method( + "frappe.desk.doctype.number_card.number_card.get_result", + doc={"name": args["card_name"]}, + filters=json.dumps(args.get("filters", {})), + ) + return json.dumps({"card": args["card_name"], "value": result}, indent=2) diff --git a/frappe_mcp/tools/bulk_ops.py b/frappe_mcp/tools/bulk_ops.py new file mode 100644 index 0000000..dd9ab8c --- /dev/null +++ b/frappe_mcp/tools/bulk_ops.py @@ -0,0 +1,337 @@ +"""Bulk operations — bulk create, update, delete documents and data import/export.""" + +import json +from mcp.types import Tool +from frappe_mcp.client.frappe_api import FrappeClient +from frappe_mcp.config import get_settings + + +def tools() -> list[Tool]: + return [ + Tool( + name="frappe_bulk_create_documents", + description="Create multiple documents of the same DocType in one call.", + inputSchema={ + "type": "object", + "required": ["doctype", "documents"], + "properties": { + "doctype": {"type": "string"}, + "documents": { + "type": "array", + "items": {"type": "object"}, + "description": "List of document field dicts to create", + }, + }, + }, + ), + Tool( + name="frappe_bulk_update_documents", + description="Update the same field(s) on multiple documents at once.", + inputSchema={ + "type": "object", + "required": ["doctype", "names", "updates"], + "properties": { + "doctype": {"type": "string"}, + "names": { + "type": "array", + "items": {"type": "string"}, + "description": "List of document names to update", + }, + "updates": { + "type": "object", + "description": "Fields and values to apply to all documents", + }, + }, + }, + ), + Tool( + name="frappe_bulk_delete_documents", + description="Delete multiple documents. Blocked in read-only mode.", + inputSchema={ + "type": "object", + "required": ["doctype", "names"], + "properties": { + "doctype": {"type": "string"}, + "names": {"type": "array", "items": {"type": "string"}}, + }, + }, + ), + Tool( + name="frappe_bulk_submit_documents", + description="Submit multiple documents in one call.", + inputSchema={ + "type": "object", + "required": ["doctype", "names"], + "properties": { + "doctype": {"type": "string"}, + "names": {"type": "array", "items": {"type": "string"}}, + }, + }, + ), + Tool( + name="frappe_import_data", + description=( + "Import documents via JSON array. Equivalent to Frappe's Data Import tool. " + "Use 'Insert' to create new records or 'Update' to update existing ones." + ), + inputSchema={ + "type": "object", + "required": ["doctype", "data"], + "properties": { + "doctype": {"type": "string"}, + "data": { + "type": "array", + "items": {"type": "object"}, + "description": "Array of document objects to import", + }, + "import_type": { + "type": "string", + "enum": ["Insert New Records", "Update Existing Records"], + "default": "Insert New Records", + }, + }, + }, + ), + Tool( + name="frappe_export_data", + description="Export documents from a DocType as a JSON list.", + inputSchema={ + "type": "object", + "required": ["doctype"], + "properties": { + "doctype": {"type": "string"}, + "fields": { + "type": "array", + "items": {"type": "string"}, + "description": "Fields to export. Empty = all fields.", + }, + "filters": {"type": "array"}, + "limit": {"type": "integer", "default": 500}, + }, + }, + ), + Tool( + name="frappe_bulk_cancel_documents", + description="Cancel multiple submitted documents.", + inputSchema={ + "type": "object", + "required": ["doctype", "names"], + "properties": { + "doctype": {"type": "string"}, + "names": {"type": "array", "items": {"type": "string"}}, + }, + }, + ), + Tool( + name="frappe_bulk_assign_documents", + description="Assign multiple documents to a user.", + inputSchema={ + "type": "object", + "required": ["doctype", "names", "assign_to"], + "properties": { + "doctype": {"type": "string"}, + "names": {"type": "array", "items": {"type": "string"}}, + "assign_to": {"type": "array", "items": {"type": "string"}, "description": "List of user emails"}, + "description": {"type": "string"}, + "due_date": {"type": "string"}, + "priority": {"type": "string", "enum": ["Low", "Medium", "High"], "default": "Medium"}, + }, + }, + ), + Tool( + name="frappe_bulk_add_tags", + description="Add the same tag to multiple documents.", + inputSchema={ + "type": "object", + "required": ["doctype", "names", "tag"], + "properties": { + "doctype": {"type": "string"}, + "names": {"type": "array", "items": {"type": "string"}}, + "tag": {"type": "string"}, + }, + }, + ), + Tool( + name="frappe_bulk_add_comment", + description="Add the same comment to multiple documents.", + inputSchema={ + "type": "object", + "required": ["doctype", "names", "content"], + "properties": { + "doctype": {"type": "string"}, + "names": {"type": "array", "items": {"type": "string"}}, + "content": {"type": "string"}, + "comment_type": {"type": "string", "default": "Comment"}, + }, + }, + ), + ] + + +def handlers() -> dict: + return { + "frappe_bulk_create_documents": _bulk_create, + "frappe_bulk_update_documents": _bulk_update, + "frappe_bulk_delete_documents": _bulk_delete, + "frappe_bulk_submit_documents": _bulk_submit, + "frappe_import_data": _import_data, + "frappe_export_data": _export_data, + "frappe_bulk_cancel_documents": _bulk_cancel, + "frappe_bulk_assign_documents": _bulk_assign, + "frappe_bulk_add_tags": _bulk_add_tags, + "frappe_bulk_add_comment": _bulk_add_comment, + } + + +async def _bulk_create(args: dict) -> str: + client = FrappeClient() + results = [] + errors = [] + for doc in args["documents"]: + try: + data = {"doctype": args["doctype"], **doc} + result = await client.create_doc(args["doctype"], data) + results.append(result.get("name") if isinstance(result, dict) else result) + except Exception as e: + errors.append({"doc": doc, "error": str(e)}) + return json.dumps({"created": results, "errors": errors}, indent=2) + + +async def _bulk_update(args: dict) -> str: + client = FrappeClient() + results = [] + errors = [] + for name in args["names"]: + try: + result = await client.update_doc(args["doctype"], name, args["updates"]) + results.append(name) + except Exception as e: + errors.append({"name": name, "error": str(e)}) + return json.dumps({"updated": results, "errors": errors}, indent=2) + + +async def _bulk_delete(args: dict) -> str: + if get_settings().read_only_mode: + return "Error: read_only_mode is enabled. Deletion blocked." + client = FrappeClient() + results = [] + errors = [] + for name in args["names"]: + try: + await client.delete_doc(args["doctype"], name) + results.append(name) + except Exception as e: + errors.append({"name": name, "error": str(e)}) + return json.dumps({"deleted": results, "errors": errors}, indent=2) + + +async def _bulk_submit(args: dict) -> str: + client = FrappeClient() + results = [] + errors = [] + for name in args["names"]: + try: + await client.update_doc(args["doctype"], name, {"docstatus": 1}) + results.append(name) + except Exception as e: + errors.append({"name": name, "error": str(e)}) + return json.dumps({"submitted": results, "errors": errors}, indent=2) + + +async def _import_data(args: dict) -> str: + client = FrappeClient() + results = [] + errors = [] + is_update = args.get("import_type", "Insert New Records") == "Update Existing Records" + for doc in args["data"]: + try: + name = doc.get("name") + if is_update and name: + result = await client.update_doc(args["doctype"], name, doc) + else: + data = {"doctype": args["doctype"], **doc} + result = await client.create_doc(args["doctype"], data) + results.append(result.get("name") if isinstance(result, dict) else str(result)) + except Exception as e: + errors.append({"doc": doc.get("name", str(doc)[:40]), "error": str(e)}) + return json.dumps({"imported": len(results), "names": results, "errors": errors}, indent=2) + + +async def _export_data(args: dict) -> str: + client = FrappeClient() + result = await client.get_list( + args["doctype"], + fields=args.get("fields") or ["*"], + filters=args.get("filters"), + limit=args.get("limit", 500), + ) + return json.dumps(result, indent=2) + + +async def _bulk_cancel(args: dict) -> str: + client = FrappeClient() + results, errors = [], [] + for name in args["names"]: + try: + await client.update_doc(args["doctype"], name, {"docstatus": 2}) + results.append(name) + except Exception as e: + errors.append({"name": name, "error": str(e)}) + return json.dumps({"cancelled": results, "errors": errors}, indent=2) + + +async def _bulk_assign(args: dict) -> str: + client = FrappeClient() + results, errors = [], [] + for name in args["names"]: + try: + await client.call_method( + "frappe.desk.form.assign_to.add", + args={ + "doctype": args["doctype"], + "name": name, + "assign_to": args["assign_to"], + "description": args.get("description", ""), + "due_date": args.get("due_date", ""), + "priority": args.get("priority", "Medium"), + "notify": 0, + }, + ) + results.append(name) + except Exception as e: + errors.append({"name": name, "error": str(e)}) + return json.dumps({"assigned": results, "errors": errors}, indent=2) + + +async def _bulk_add_tags(args: dict) -> str: + client = FrappeClient() + results, errors = [], [] + for name in args["names"]: + try: + await client.call_method( + "frappe.desk.doctype.tag.tag.add_tag", + tag=args["tag"], dt=args["doctype"], dn=name, + ) + results.append(name) + except Exception as e: + errors.append({"name": name, "error": str(e)}) + return json.dumps({"tagged": results, "errors": errors}, indent=2) + + +async def _bulk_add_comment(args: dict) -> str: + client = FrappeClient() + results, errors = [], [] + for name in args["names"]: + try: + await client.call_method( + "frappe.client.add_comment", + reference_doctype=args["doctype"], + reference_name=name, + content=args["content"], + comment_email="", + comment_by="", + ) + results.append(name) + except Exception as e: + errors.append({"name": name, "error": str(e)}) + return json.dumps({"commented": results, "errors": errors}, indent=2) diff --git a/frappe_mcp/tools/business_actions.py b/frappe_mcp/tools/business_actions.py new file mode 100644 index 0000000..5f8af45 --- /dev/null +++ b/frappe_mcp/tools/business_actions.py @@ -0,0 +1,477 @@ +""" +Level 8 — Business action tools (ERPNext shortcuts). +High-value operations: convert documents, approve, transfer stock, etc. +These call ERPNext's built-in mapper/make functions. +""" + +import json +from mcp.types import Tool +from frappe_mcp.client.frappe_api import FrappeClient + + +def tools() -> list[Tool]: + return [ + # ── Sales / CRM ────────────────────────────────────────────────────── + Tool( + name="erpnext_create_quotation_from_opportunity", + description="Create a Quotation from an Opportunity.", + inputSchema={ + "type": "object", + "required": ["opportunity_name"], + "properties": {"opportunity_name": {"type": "string"}}, + }, + ), + Tool( + name="erpnext_create_sales_order_from_quotation", + description="Create a Sales Order from a Quotation.", + inputSchema={ + "type": "object", + "required": ["quotation_name"], + "properties": {"quotation_name": {"type": "string"}}, + }, + ), + Tool( + name="erpnext_create_sales_invoice_from_sales_order", + description="Create a Sales Invoice from a Sales Order.", + inputSchema={ + "type": "object", + "required": ["sales_order_name"], + "properties": {"sales_order_name": {"type": "string"}}, + }, + ), + Tool( + name="erpnext_create_delivery_note_from_sales_order", + description="Create a Delivery Note from a Sales Order.", + inputSchema={ + "type": "object", + "required": ["sales_order_name"], + "properties": {"sales_order_name": {"type": "string"}}, + }, + ), + Tool( + name="erpnext_create_payment_entry_for_invoice", + description="Create a Payment Entry for a Sales Invoice or Purchase Invoice.", + inputSchema={ + "type": "object", + "required": ["invoice_doctype", "invoice_name"], + "properties": { + "invoice_doctype": {"type": "string", "enum": ["Sales Invoice", "Purchase Invoice"]}, + "invoice_name": {"type": "string"}, + }, + }, + ), + # ── Buying ─────────────────────────────────────────────────────────── + Tool( + name="erpnext_create_purchase_order_from_supplier_quotation", + description="Create a Purchase Order from a Supplier Quotation.", + inputSchema={ + "type": "object", + "required": ["supplier_quotation_name"], + "properties": {"supplier_quotation_name": {"type": "string"}}, + }, + ), + Tool( + name="erpnext_create_purchase_receipt_from_purchase_order", + description="Create a Purchase Receipt from a Purchase Order.", + inputSchema={ + "type": "object", + "required": ["purchase_order_name"], + "properties": {"purchase_order_name": {"type": "string"}}, + }, + ), + Tool( + name="erpnext_create_purchase_invoice_from_purchase_receipt", + description="Create a Purchase Invoice from a Purchase Receipt.", + inputSchema={ + "type": "object", + "required": ["purchase_receipt_name"], + "properties": {"purchase_receipt_name": {"type": "string"}}, + }, + ), + # ── Stock ──────────────────────────────────────────────────────────── + Tool( + name="erpnext_get_item_stock_balance", + description="Get current stock balance for an item, optionally at a specific warehouse.", + inputSchema={ + "type": "object", + "required": ["item_code"], + "properties": { + "item_code": {"type": "string"}, + "warehouse": {"type": "string"}, + "posting_date": {"type": "string", "description": "YYYY-MM-DD (defaults to today)"}, + }, + }, + ), + Tool( + name="erpnext_transfer_stock", + description="Transfer stock between warehouses via a Material Transfer Stock Entry.", + inputSchema={ + "type": "object", + "required": ["item_code", "qty", "from_warehouse", "to_warehouse"], + "properties": { + "item_code": {"type": "string"}, + "qty": {"type": "number"}, + "from_warehouse": {"type": "string"}, + "to_warehouse": {"type": "string"}, + "posting_date": {"type": "string"}, + "remarks": {"type": "string"}, + }, + }, + ), + Tool( + name="erpnext_create_stock_entry", + description=( + "Create a Stock Entry of any type: " + "Material Issue, Material Receipt, Material Transfer, Manufacture, Repack." + ), + inputSchema={ + "type": "object", + "required": ["stock_entry_type", "items"], + "properties": { + "stock_entry_type": { + "type": "string", + "enum": ["Material Issue", "Material Receipt", "Material Transfer", + "Material Transfer for Manufacture", "Manufacture", "Repack", "Send to Subcontractor"], + }, + "items": { + "type": "array", + "description": "List of {item_code, qty, s_warehouse, t_warehouse} objects", + "items": {"type": "object"}, + }, + "posting_date": {"type": "string"}, + "remarks": {"type": "string"}, + }, + }, + ), + # ── HR ─────────────────────────────────────────────────────────────── + Tool( + name="erpnext_approve_leave_application", + description="Approve a Leave Application.", + inputSchema={ + "type": "object", + "required": ["leave_application_name"], + "properties": {"leave_application_name": {"type": "string"}}, + }, + ), + Tool( + name="erpnext_reject_leave_application", + description="Reject a Leave Application.", + inputSchema={ + "type": "object", + "required": ["leave_application_name"], + "properties": { + "leave_application_name": {"type": "string"}, + "reason": {"type": "string"}, + }, + }, + ), + Tool( + name="erpnext_mark_attendance", + description="Mark attendance for an employee on a specific date.", + inputSchema={ + "type": "object", + "required": ["employee", "attendance_date", "status"], + "properties": { + "employee": {"type": "string"}, + "attendance_date": {"type": "string", "description": "YYYY-MM-DD"}, + "status": {"type": "string", "enum": ["Present", "Absent", "Half Day", "Work From Home", "On Leave"]}, + "shift": {"type": "string"}, + }, + }, + ), + Tool( + name="erpnext_approve_expense_claim", + description="Approve an Expense Claim.", + inputSchema={ + "type": "object", + "required": ["expense_claim_name"], + "properties": {"expense_claim_name": {"type": "string"}}, + }, + ), + # ── Support ────────────────────────────────────────────────────────── + Tool( + name="erpnext_create_issue", + description="Create a new Support Issue.", + inputSchema={ + "type": "object", + "required": ["subject"], + "properties": { + "subject": {"type": "string"}, + "customer": {"type": "string"}, + "description": {"type": "string"}, + "priority": {"type": "string", "enum": ["Low", "Medium", "High", "Urgent"], "default": "Medium"}, + "issue_type": {"type": "string"}, + "raised_by": {"type": "string"}, + }, + }, + ), + Tool( + name="erpnext_close_issue", + description="Close a Support Issue.", + inputSchema={ + "type": "object", + "required": ["issue_name"], + "properties": { + "issue_name": {"type": "string"}, + "resolution_details": {"type": "string"}, + }, + }, + ), + # ── Projects ───────────────────────────────────────────────────────── + Tool( + name="erpnext_create_task", + description="Create a Task in a Project.", + inputSchema={ + "type": "object", + "required": ["subject"], + "properties": { + "subject": {"type": "string"}, + "project": {"type": "string"}, + "assigned_to": {"type": "string"}, + "exp_start_date": {"type": "string"}, + "exp_end_date": {"type": "string"}, + "priority": {"type": "string", "enum": ["Low", "Medium", "High", "Urgent"]}, + "description": {"type": "string"}, + }, + }, + ), + Tool( + name="erpnext_update_task_status", + description="Update the status of a Task.", + inputSchema={ + "type": "object", + "required": ["task_name", "status"], + "properties": { + "task_name": {"type": "string"}, + "status": {"type": "string", "enum": ["Open", "Working", "Pending Review", "Overdue", "Template", "Cancelled", "Completed"]}, + }, + }, + ), + Tool( + name="erpnext_log_timesheet_entry", + description="Log a timesheet entry for an employee on a task or project.", + inputSchema={ + "type": "object", + "required": ["employee", "from_time", "to_time"], + "properties": { + "employee": {"type": "string"}, + "from_time": {"type": "string", "description": "ISO datetime"}, + "to_time": {"type": "string", "description": "ISO datetime"}, + "project": {"type": "string"}, + "task": {"type": "string"}, + "activity_type": {"type": "string"}, + "description": {"type": "string"}, + }, + }, + ), + ] + + +def handlers() -> dict: + return { + "erpnext_create_quotation_from_opportunity": _make_quotation_from_opportunity, + "erpnext_create_sales_order_from_quotation": _make_so_from_quotation, + "erpnext_create_sales_invoice_from_sales_order": _make_si_from_so, + "erpnext_create_delivery_note_from_sales_order": _make_dn_from_so, + "erpnext_create_payment_entry_for_invoice": _make_payment_entry, + "erpnext_create_purchase_order_from_supplier_quotation": _make_po_from_sq, + "erpnext_create_purchase_receipt_from_purchase_order": _make_pr_from_po, + "erpnext_create_purchase_invoice_from_purchase_receipt": _make_pi_from_pr, + "erpnext_get_item_stock_balance": _get_item_stock_balance, + "erpnext_transfer_stock": _transfer_stock, + "erpnext_create_stock_entry": _create_stock_entry, + "erpnext_approve_leave_application": _approve_leave, + "erpnext_reject_leave_application": _reject_leave, + "erpnext_mark_attendance": _mark_attendance, + "erpnext_approve_expense_claim": _approve_expense_claim, + "erpnext_create_issue": _create_issue, + "erpnext_close_issue": _close_issue, + "erpnext_create_task": _create_task, + "erpnext_update_task_status": _update_task_status, + "erpnext_log_timesheet_entry": _log_timesheet, + } + + +async def _make_quotation_from_opportunity(args: dict) -> str: + client = FrappeClient() + result = await client.call_method( + "erpnext.crm.doctype.opportunity.opportunity.make_quotation", + source_name=args["opportunity_name"], + ) + return json.dumps(result, indent=2) + +async def _make_so_from_quotation(args: dict) -> str: + client = FrappeClient() + result = await client.call_method( + "erpnext.selling.doctype.quotation.quotation.make_sales_order", + source_name=args["quotation_name"], + ) + return json.dumps(result, indent=2) + +async def _make_si_from_so(args: dict) -> str: + client = FrappeClient() + result = await client.call_method( + "erpnext.selling.doctype.sales_order.sales_order.make_sales_invoice", + source_name=args["sales_order_name"], + ) + return json.dumps(result, indent=2) + +async def _make_dn_from_so(args: dict) -> str: + client = FrappeClient() + result = await client.call_method( + "erpnext.selling.doctype.sales_order.sales_order.make_delivery_note", + source_name=args["sales_order_name"], + ) + return json.dumps(result, indent=2) + +async def _make_payment_entry(args: dict) -> str: + client = FrappeClient() + method = ( + "erpnext.accounts.doctype.sales_invoice.sales_invoice.get_bank_cash_account" + if args["invoice_doctype"] == "Sales Invoice" + else "erpnext.accounts.doctype.purchase_invoice.purchase_invoice.make_payment_entry" + ) + result = await client.call_method( + "erpnext.accounts.doctype.payment_entry.payment_entry.get_payment_entry", + dt=args["invoice_doctype"], + dn=args["invoice_name"], + ) + return json.dumps(result, indent=2) + +async def _make_po_from_sq(args: dict) -> str: + client = FrappeClient() + result = await client.call_method( + "erpnext.buying.doctype.supplier_quotation.supplier_quotation.make_purchase_order", + source_name=args["supplier_quotation_name"], + ) + return json.dumps(result, indent=2) + +async def _make_pr_from_po(args: dict) -> str: + client = FrappeClient() + result = await client.call_method( + "erpnext.buying.doctype.purchase_order.purchase_order.make_purchase_receipt", + source_name=args["purchase_order_name"], + ) + return json.dumps(result, indent=2) + +async def _make_pi_from_pr(args: dict) -> str: + client = FrappeClient() + result = await client.call_method( + "erpnext.buying.doctype.purchase_receipt.purchase_receipt.make_purchase_invoice", + source_name=args["purchase_receipt_name"], + ) + return json.dumps(result, indent=2) + +async def _get_item_stock_balance(args: dict) -> str: + client = FrappeClient() + result = await client.call_method( + "erpnext.stock.utils.get_stock_balance", + item_code=args["item_code"], + warehouse=args.get("warehouse", ""), + posting_date=args.get("posting_date", ""), + ) + return json.dumps({"item_code": args["item_code"], "warehouse": args.get("warehouse"), "balance": result}, indent=2) + +async def _transfer_stock(args: dict) -> str: + client = FrappeClient() + payload = { + "stock_entry_type": "Material Transfer", + "posting_date": args.get("posting_date", ""), + "remarks": args.get("remarks", ""), + "items": [{ + "item_code": args["item_code"], + "qty": args["qty"], + "s_warehouse": args["from_warehouse"], + "t_warehouse": args["to_warehouse"], + }], + } + result = await client.create_doc("Stock Entry", payload) + return json.dumps(result, indent=2) + +async def _create_stock_entry(args: dict) -> str: + client = FrappeClient() + payload = { + "stock_entry_type": args["stock_entry_type"], + "posting_date": args.get("posting_date", ""), + "remarks": args.get("remarks", ""), + "items": args["items"], + } + result = await client.create_doc("Stock Entry", payload) + return json.dumps(result, indent=2) + +async def _approve_leave(args: dict) -> str: + client = FrappeClient() + result = await client.update_doc("Leave Application", args["leave_application_name"], {"status": "Approved"}) + return json.dumps(result, indent=2) + +async def _reject_leave(args: dict) -> str: + client = FrappeClient() + updates = {"status": "Rejected"} + if reason := args.get("reason"): + updates["reason_for_rejection"] = reason + result = await client.update_doc("Leave Application", args["leave_application_name"], updates) + return json.dumps(result, indent=2) + +async def _mark_attendance(args: dict) -> str: + client = FrappeClient() + payload = { + "employee": args["employee"], + "attendance_date": args["attendance_date"], + "status": args["status"], + "shift": args.get("shift", ""), + "docstatus": 1, + } + result = await client.create_doc("Attendance", payload) + return json.dumps(result, indent=2) + +async def _approve_expense_claim(args: dict) -> str: + client = FrappeClient() + result = await client.update_doc("Expense Claim", args["expense_claim_name"], {"approval_status": "Approved"}) + return json.dumps(result, indent=2) + +async def _create_issue(args: dict) -> str: + client = FrappeClient() + payload = { + "subject": args["subject"], + "customer": args.get("customer", ""), + "description": args.get("description", ""), + "priority": args.get("priority", "Medium"), + "issue_type": args.get("issue_type", ""), + "raised_by": args.get("raised_by", ""), + } + result = await client.create_doc("Issue", payload) + return json.dumps(result, indent=2) + +async def _close_issue(args: dict) -> str: + client = FrappeClient() + updates = {"status": "Closed"} + if res := args.get("resolution_details"): + updates["resolution_details"] = res + result = await client.update_doc("Issue", args["issue_name"], updates) + return json.dumps(result, indent=2) + +async def _create_task(args: dict) -> str: + client = FrappeClient() + result = await client.create_doc("Task", args) + return json.dumps(result, indent=2) + +async def _update_task_status(args: dict) -> str: + client = FrappeClient() + result = await client.update_doc("Task", args["task_name"], {"status": args["status"]}) + return json.dumps(result, indent=2) + +async def _log_timesheet(args: dict) -> str: + client = FrappeClient() + payload = { + "employee": args["employee"], + "time_logs": [{ + "from_time": args["from_time"], + "to_time": args["to_time"], + "project": args.get("project", ""), + "task": args.get("task", ""), + "activity_type": args.get("activity_type", ""), + "description": args.get("description", ""), + }], + } + result = await client.create_doc("Timesheet", payload) + return json.dumps(result, indent=2) diff --git a/frappe_mcp/tools/custom_fields.py b/frappe_mcp/tools/custom_fields.py new file mode 100644 index 0000000..ffa7599 --- /dev/null +++ b/frappe_mcp/tools/custom_fields.py @@ -0,0 +1,124 @@ +"""Custom Fields tools — add, modify, remove custom fields on any DocType.""" + +import json +from mcp.types import Tool +from frappe_mcp.client.frappe_api import FrappeClient + + +_FIELD_TYPES = [ + "Data", "Int", "Float", "Currency", "Percent", "Check", + "Small Text", "Text", "Long Text", "Text Editor", "HTML", + "Date", "Datetime", "Time", "Duration", + "Select", "Link", "Dynamic Link", "Table", "Table MultiSelect", + "Attach", "Attach Image", "Signature", "Color", "Barcode", + "Geolocation", "Rating", "Section Break", "Column Break", "Tab Break", +] + +_FIELD_SCHEMA = { + "type": "object", + "required": ["dt", "fieldname", "fieldtype", "label"], + "properties": { + "dt": {"type": "string", "description": "DocType to add the field to"}, + "fieldname": {"type": "string"}, + "fieldtype": {"type": "string", "enum": _FIELD_TYPES}, + "label": {"type": "string"}, + "options": {"type": "string", "description": "For Link: target DocType. For Select: newline-separated options."}, + "reqd": {"type": "integer", "default": 0}, + "in_list_view": {"type": "integer", "default": 0}, + "in_standard_filter": {"type": "integer", "default": 0}, + "insert_after": {"type": "string", "description": "Fieldname to insert after"}, + "default": {"type": "string"}, + "description": {"type": "string"}, + "hidden": {"type": "integer", "default": 0}, + "read_only": {"type": "integer", "default": 0}, + "bold": {"type": "integer", "default": 0}, + }, +} + + +def tools() -> list[Tool]: + return [ + Tool( + name="frappe_add_custom_field", + description="Add a custom field to an existing DocType.", + inputSchema=_FIELD_SCHEMA, + ), + Tool( + name="frappe_update_custom_field", + description="Update properties of an existing custom field.", + inputSchema={ + "type": "object", + "required": ["dt", "fieldname"], + "properties": { + "dt": {"type": "string"}, + "fieldname": {"type": "string"}, + "updates": {"type": "object", "description": "Properties to update"}, + }, + }, + ), + Tool( + name="frappe_remove_custom_field", + description="Remove a custom field from a DocType.", + inputSchema={ + "type": "object", + "required": ["dt", "fieldname"], + "properties": { + "dt": {"type": "string"}, + "fieldname": {"type": "string"}, + }, + }, + ), + Tool( + name="frappe_list_custom_fields", + description="List all custom fields for a DocType.", + inputSchema={ + "type": "object", + "required": ["dt"], + "properties": { + "dt": {"type": "string"}, + }, + }, + ), + ] + + +def handlers() -> dict: + return { + "frappe_add_custom_field": _add_custom_field, + "frappe_update_custom_field": _update_custom_field, + "frappe_remove_custom_field": _remove_custom_field, + "frappe_list_custom_fields": _list_custom_fields, + } + + +async def _add_custom_field(args: dict) -> str: + client = FrappeClient() + payload = {k: v for k, v in args.items()} + result = await client.create_doc("Custom Field", payload) + return json.dumps(result, indent=2) + + +async def _update_custom_field(args: dict) -> str: + client = FrappeClient() + # Custom Field name is "{DocType}-{fieldname}" + cf_name = f"{args['dt']}-{args['fieldname']}" + result = await client.update_doc("Custom Field", cf_name, args.get("updates", {})) + return json.dumps(result, indent=2) + + +async def _remove_custom_field(args: dict) -> str: + client = FrappeClient() + cf_name = f"{args['dt']}-{args['fieldname']}" + result = await client.delete_doc("Custom Field", cf_name) + return json.dumps(result, indent=2) + + +async def _list_custom_fields(args: dict) -> str: + client = FrappeClient() + result = await client.get_list( + "Custom Field", + fields=["name", "fieldname", "fieldtype", "label", "reqd", "hidden"], + filters=[["dt", "=", args["dt"]]], + limit=100, + ) + return json.dumps(result, indent=2) diff --git a/frappe_mcp/tools/dashboards.py b/frappe_mcp/tools/dashboards.py new file mode 100644 index 0000000..10736a0 --- /dev/null +++ b/frappe_mcp/tools/dashboards.py @@ -0,0 +1,217 @@ +"""Dashboard, Chart, Number Card, and Workspace tools.""" + +import json +from mcp.types import Tool +from frappe_mcp.client.frappe_api import FrappeClient + + +def tools() -> list[Tool]: + return [ + Tool( + name="frappe_list_dashboards", + description="List all Frappe Dashboards.", + inputSchema={ + "type": "object", + "properties": {"limit": {"type": "integer", "default": 20}}, + }, + ), + Tool( + name="frappe_get_dashboard", + description="Get a Dashboard with all its charts and cards.", + inputSchema={ + "type": "object", + "required": ["name"], + "properties": {"name": {"type": "string"}}, + }, + ), + Tool( + name="frappe_create_dashboard", + description="Create a new Dashboard.", + inputSchema={ + "type": "object", + "required": ["dashboard_name"], + "properties": { + "dashboard_name": {"type": "string"}, + "module": {"type": "string"}, + "is_default": {"type": "integer", "default": 0}, + "charts": { + "type": "array", + "items": {"type": "object"}, + "description": "List of {chart, width} objects", + }, + }, + }, + ), + Tool( + name="frappe_create_dashboard_chart", + description="Create a Dashboard Chart (bar, line, pie, donut, percentage, heatmap).", + inputSchema={ + "type": "object", + "required": ["chart_name", "chart_type", "document_type"], + "properties": { + "chart_name": {"type": "string"}, + "chart_type": { + "type": "string", + "enum": ["Count", "Sum", "Average", "Min", "Max", "Group By"], + }, + "document_type": {"type": "string"}, + "based_on": {"type": "string", "description": "Date field for time-series grouping"}, + "value_based_on": {"type": "string", "description": "Field to aggregate"}, + "type": { + "type": "string", + "enum": ["Bar", "Line", "Percentage", "Pie", "Donut", "Heatmap"], + "default": "Bar", + }, + "timespan": { + "type": "string", + "enum": ["Last Year", "Last Quarter", "Last Month", "Last Week"], + "default": "Last Year", + }, + "time_interval": { + "type": "string", + "enum": ["Yearly", "Quarterly", "Monthly", "Weekly", "Daily"], + "default": "Monthly", + }, + "filters_json": {"type": "string", "description": "JSON string of filters"}, + "color": {"type": "string"}, + }, + }, + ), + Tool( + name="frappe_create_number_card", + description="Create a Number Card for dashboards showing a single aggregated metric.", + inputSchema={ + "type": "object", + "required": ["name", "document_type", "function"], + "properties": { + "name": {"type": "string"}, + "label": {"type": "string"}, + "document_type": {"type": "string"}, + "function": { + "type": "string", + "enum": ["Count", "Sum", "Average", "Min", "Max"], + "default": "Count", + }, + "aggregate_function_based_on": {"type": "string"}, + "filters_json": {"type": "string"}, + "color": {"type": "string"}, + "stats_time_interval": {"type": "string", "enum": ["Daily", "Weekly", "Monthly", "Quarterly", "Yearly"]}, + }, + }, + ), + # --- Workspace --- + Tool( + name="frappe_list_workspaces", + description="List all Desk Workspaces.", + inputSchema={ + "type": "object", + "properties": {"limit": {"type": "integer", "default": 30}}, + }, + ), + Tool( + name="frappe_get_workspace", + description="Get a Workspace with all its shortcuts, links, and charts.", + inputSchema={ + "type": "object", + "required": ["name"], + "properties": {"name": {"type": "string"}}, + }, + ), + Tool( + name="frappe_create_workspace", + description="Create a new Desk Workspace with shortcuts and links.", + inputSchema={ + "type": "object", + "required": ["name"], + "properties": { + "name": {"type": "string"}, + "label": {"type": "string"}, + "module": {"type": "string"}, + "icon": {"type": "string"}, + "is_hidden": {"type": "integer", "default": 0}, + "shortcuts": { + "type": "array", + "items": {"type": "object"}, + "description": "List of shortcut objects {label, link_to, type}", + }, + "links": { + "type": "array", + "items": {"type": "object"}, + "description": "List of link objects for the workspace body", + }, + }, + }, + ), + Tool( + name="frappe_update_workspace", + description="Update a Workspace's content, shortcuts, or visibility.", + inputSchema={ + "type": "object", + "required": ["name"], + "properties": { + "name": {"type": "string"}, + "updates": {"type": "object"}, + }, + }, + ), + ] + + +def handlers() -> dict: + return { + "frappe_list_dashboards": _list_dashboards, + "frappe_get_dashboard": _get_dashboard, + "frappe_create_dashboard": _create_dashboard, + "frappe_create_dashboard_chart": _create_dashboard_chart, + "frappe_create_number_card": _create_number_card, + "frappe_list_workspaces": _list_workspaces, + "frappe_get_workspace": _get_workspace, + "frappe_create_workspace": _create_workspace, + "frappe_update_workspace": _update_workspace, + } + + +async def _list_dashboards(args: dict) -> str: + client = FrappeClient() + result = await client.get_list("Dashboard", fields=["name", "module", "is_default"], limit=args.get("limit", 20)) + return json.dumps(result, indent=2) + +async def _get_dashboard(args: dict) -> str: + client = FrappeClient() + result = await client.get_doc("Dashboard", args["name"]) + return json.dumps(result, indent=2) + +async def _create_dashboard(args: dict) -> str: + client = FrappeClient() + result = await client.create_doc("Dashboard", args) + return json.dumps(result, indent=2) + +async def _create_dashboard_chart(args: dict) -> str: + client = FrappeClient() + result = await client.create_doc("Dashboard Chart", args) + return json.dumps(result, indent=2) + +async def _create_number_card(args: dict) -> str: + client = FrappeClient() + result = await client.create_doc("Number Card", args) + return json.dumps(result, indent=2) + +async def _list_workspaces(args: dict) -> str: + client = FrappeClient() + result = await client.get_list("Workspace", fields=["name", "label", "module", "is_hidden"], limit=args.get("limit", 30)) + return json.dumps(result, indent=2) + +async def _get_workspace(args: dict) -> str: + client = FrappeClient() + result = await client.get_doc("Workspace", args["name"]) + return json.dumps(result, indent=2) + +async def _create_workspace(args: dict) -> str: + client = FrappeClient() + result = await client.create_doc("Workspace", args) + return json.dumps(result, indent=2) + +async def _update_workspace(args: dict) -> str: + client = FrappeClient() + result = await client.update_doc("Workspace", args["name"], args.get("updates", {})) + return json.dumps(result, indent=2) diff --git a/frappe_mcp/tools/doctypes.py b/frappe_mcp/tools/doctypes.py new file mode 100644 index 0000000..e166720 --- /dev/null +++ b/frappe_mcp/tools/doctypes.py @@ -0,0 +1,155 @@ +"""DocType management tools — create, inspect, list, and modify DocTypes.""" + +import json +from mcp.types import Tool +from frappe_mcp.client.frappe_api import FrappeClient + + +def tools() -> list[Tool]: + return [ + Tool( + name="frappe_list_doctypes", + description="List all DocTypes in the Frappe instance, optionally filtered by module.", + inputSchema={ + "type": "object", + "properties": { + "module": {"type": "string", "description": "Filter by module name (optional)"}, + "limit": {"type": "integer", "default": 50}, + }, + }, + ), + Tool( + name="frappe_get_doctype", + description="Get the full schema/definition of a DocType including all fields.", + inputSchema={ + "type": "object", + "required": ["doctype"], + "properties": { + "doctype": {"type": "string", "description": "DocType name e.g. 'Sales Order'"}, + }, + }, + ), + Tool( + name="frappe_create_doctype", + description=( + "Create a new custom DocType. Provide name, module, and a list of fields. " + "Each field needs at minimum: fieldname, fieldtype, label." + ), + inputSchema={ + "type": "object", + "required": ["name", "module", "fields"], + "properties": { + "name": {"type": "string"}, + "module": {"type": "string"}, + "is_submittable": {"type": "boolean", "default": False}, + "istable": {"type": "integer", "default": 0, "description": "Set 1 to make this a child table DocType"}, + "track_changes": {"type": "boolean", "default": True}, + "autoname": {"type": "string", "description": "e.g. 'hash', 'field:fieldname', 'naming_series:'"}, + "title_field": {"type": "string", "description": "Field to use as document title"}, + "search_fields": {"type": "string", "description": "Comma-separated fields for search"}, + "sort_field": {"type": "string"}, + "sort_order": {"type": "string", "enum": ["ASC", "DESC"], "default": "DESC"}, + "fields": { + "type": "array", + "items": { + "type": "object", + "required": ["fieldname", "fieldtype", "label"], + "properties": { + "fieldname": {"type": "string"}, + "fieldtype": {"type": "string"}, + "label": {"type": "string"}, + "reqd": {"type": "integer", "default": 0}, + "in_list_view": {"type": "integer", "default": 0}, + "options": {"type": "string"}, + }, + }, + }, + "permissions": { + "type": "array", + "description": "List of permission rows. Defaults to System Manager full access.", + }, + }, + }, + ), + Tool( + name="frappe_update_doctype", + description="Update an existing DocType — add/remove fields or change properties.", + inputSchema={ + "type": "object", + "required": ["doctype"], + "properties": { + "doctype": {"type": "string"}, + "updates": { + "type": "object", + "description": "Fields to update on the DocType document (e.g. {\"fields\": [...]})", + }, + }, + }, + ), + ] + + +def handlers() -> dict: + return { + "frappe_list_doctypes": _list_doctypes, + "frappe_get_doctype": _get_doctype, + "frappe_create_doctype": _create_doctype, + "frappe_update_doctype": _update_doctype, + } + + +async def _list_doctypes(args: dict) -> str: + client = FrappeClient() + filters = [] + if module := args.get("module"): + filters.append(["module", "=", module]) + result = await client.get_list( + "DocType", + fields=["name", "module", "is_single", "is_submittable", "modified"], + filters=filters if filters else None, + limit=args.get("limit", 50), + ) + return json.dumps(result, indent=2) + + +async def _get_doctype(args: dict) -> str: + client = FrappeClient() + result = await client.get_doc("DocType", args["doctype"]) + return json.dumps(result, indent=2) + + +async def _create_doctype(args: dict) -> str: + client = FrappeClient() + istable = int(args.get("istable", 0)) + payload = { + "name": args["name"], + "module": args["module"], + "custom": 1, + "istable": istable, + "is_submittable": int(args.get("is_submittable", False)), + "track_changes": int(args.get("track_changes", True)), + "fields": args["fields"], + } + if v := args.get("autoname"): + payload["autoname"] = v + if v := args.get("title_field"): + payload["title_field"] = v + if v := args.get("search_fields"): + payload["search_fields"] = v + if v := args.get("sort_field"): + payload["sort_field"] = v + if v := args.get("sort_order"): + payload["sort_order"] = v + # child tables don't need permissions + if not istable: + payload["permissions"] = args.get("permissions", [ + {"role": "System Manager", "read": 1, "write": 1, "create": 1, "delete": 1, "submit": 0}, + ]) + result = await client.create_doc("DocType", payload) + return json.dumps(result, indent=2) + + +async def _update_doctype(args: dict) -> str: + client = FrappeClient() + result = await client.update_doc("DocType", args["doctype"], args.get("updates", {})) + return json.dumps(result, indent=2) diff --git a/frappe_mcp/tools/document_inspect.py b/frappe_mcp/tools/document_inspect.py new file mode 100644 index 0000000..cdeb42c --- /dev/null +++ b/frappe_mcp/tools/document_inspect.py @@ -0,0 +1,267 @@ +""" +Level 2 — Document inspection tools. +Search, linked records, timeline, children, counts. +""" + +import json +from mcp.types import Tool +from frappe_mcp.client.frappe_api import FrappeClient + + +def tools() -> list[Tool]: + return [ + Tool( + name="frappe_search_documents", + description=( + "Generic full-text search across any DocType. " + "Searches title/name fields. Use filters for precise queries." + ), + inputSchema={ + "type": "object", + "required": ["doctype"], + "properties": { + "doctype": {"type": "string"}, + "query": {"type": "string", "description": "Search text"}, + "filters": {"type": "array", "description": "Additional filter conditions"}, + "fields": {"type": "array", "items": {"type": "string"}}, + "limit": {"type": "integer", "default": 20}, + "order_by": {"type": "string", "default": "modified desc"}, + }, + }, + ), + Tool( + name="frappe_get_document_children", + description=( + "Read child table rows for a document. " + "Use get_doctype_meta to find the child table fieldname first." + ), + inputSchema={ + "type": "object", + "required": ["doctype", "name", "child_doctype"], + "properties": { + "doctype": {"type": "string", "description": "Parent DocType"}, + "name": {"type": "string", "description": "Parent document name"}, + "child_doctype": {"type": "string", "description": "Child table DocType e.g. 'Sales Order Item'"}, + "fields": {"type": "array", "items": {"type": "string"}}, + }, + }, + ), + Tool( + name="frappe_get_linked_documents", + description=( + "Returns all documents that link TO this document — " + "e.g. all Sales Orders linked to a Customer, or all Invoices for a Sales Order." + ), + inputSchema={ + "type": "object", + "required": ["doctype", "name"], + "properties": { + "doctype": {"type": "string"}, + "name": {"type": "string"}, + "linked_doctype": { + "type": "string", + "description": "Filter to only this DocType (optional)", + }, + "limit": {"type": "integer", "default": 20}, + }, + }, + ), + Tool( + name="frappe_get_document_timeline", + description=( + "Get the full activity timeline for a document: " + "comments, emails, assignments, workflow changes, version history." + ), + inputSchema={ + "type": "object", + "required": ["doctype", "name"], + "properties": { + "doctype": {"type": "string"}, + "name": {"type": "string"}, + "limit": {"type": "integer", "default": 30}, + }, + }, + ), + Tool( + name="frappe_get_recent_documents", + description="Get recently modified documents of a DocType. Useful for 'show latest X' requests.", + inputSchema={ + "type": "object", + "required": ["doctype"], + "properties": { + "doctype": {"type": "string"}, + "fields": {"type": "array", "items": {"type": "string"}}, + "limit": {"type": "integer", "default": 10}, + "modified_after": {"type": "string", "description": "ISO datetime filter e.g. '2024-01-01'"}, + }, + }, + ), + Tool( + name="frappe_count_documents", + description="Count records matching filters. Fast — does not return document data.", + inputSchema={ + "type": "object", + "required": ["doctype"], + "properties": { + "doctype": {"type": "string"}, + "filters": {"type": "array"}, + }, + }, + ), + Tool( + name="frappe_get_document_attachments", + description="List all files attached to a specific document.", + inputSchema={ + "type": "object", + "required": ["doctype", "name"], + "properties": { + "doctype": {"type": "string"}, + "name": {"type": "string"}, + }, + }, + ), + Tool( + name="frappe_get_document_versions", + description="Get the change history (versions) of a document — who changed what fields and when.", + inputSchema={ + "type": "object", + "required": ["doctype", "name"], + "properties": { + "doctype": {"type": "string"}, + "name": {"type": "string"}, + "limit": {"type": "integer", "default": 10}, + }, + }, + ), + ] + + +def handlers() -> dict: + return { + "frappe_search_documents": _search_documents, + "frappe_get_document_children": _get_document_children, + "frappe_get_linked_documents": _get_linked_documents, + "frappe_get_document_timeline": _get_document_timeline, + "frappe_get_recent_documents": _get_recent_documents, + "frappe_count_documents": _count_documents, + "frappe_get_document_attachments": _get_document_attachments, + "frappe_get_document_versions": _get_document_versions, + } + + +async def _search_documents(args: dict) -> str: + client = FrappeClient() + filters = list(args.get("filters") or []) + if query := args.get("query"): + filters.append(["name", "like", f"%{query}%"]) + result = await client.get_list( + args["doctype"], + fields=args.get("fields", ["name", "modified", "owner"]), + filters=filters if filters else None, + limit=args.get("limit", 20), + order_by=args.get("order_by", "modified desc"), + ) + return json.dumps(result, indent=2) + + +async def _get_document_children(args: dict) -> str: + client = FrappeClient() + result = await client.get_list( + args["child_doctype"], + fields=args.get("fields", ["*"]) or ["*"], + filters=[["parent", "=", args["name"]], ["parenttype", "=", args["doctype"]]], + limit=500, + order_by="idx asc", + ) + return json.dumps(result, indent=2) + + +async def _get_linked_documents(args: dict) -> str: + client = FrappeClient() + result = await client.call_method( + "frappe.client.get_linked_docs", + doctype=args["doctype"], + name=args["name"], + linkinfo=args.get("linked_doctype", ""), + ) + return json.dumps(result, indent=2) + + +async def _get_document_timeline(args: dict) -> str: + client = FrappeClient() + ref_dt = args["doctype"] + ref_name = args["name"] + limit = args.get("limit", 30) + + comments = await client.get_list( + "Comment", + fields=["name", "comment_type", "comment_by", "content", "creation"], + filters=[["reference_doctype", "=", ref_dt], ["reference_name", "=", ref_name]], + limit=limit, + order_by="creation desc", + ) + versions = await client.get_list( + "Version", + fields=["name", "owner", "creation", "data"], + filters=[["ref_doctype", "=", ref_dt], ["docname", "=", ref_name]], + limit=limit, + order_by="creation desc", + ) + timeline = sorted( + [{"type": "comment", **c} for c in (comments if isinstance(comments, list) else [])] + + [{"type": "version", **v} for v in (versions if isinstance(versions, list) else [])], + key=lambda x: x.get("creation", ""), + reverse=True, + )[:limit] + return json.dumps(timeline, indent=2) + + +async def _get_recent_documents(args: dict) -> str: + client = FrappeClient() + filters = [] + if after := args.get("modified_after"): + filters.append(["modified", ">", after]) + result = await client.get_list( + args["doctype"], + fields=args.get("fields", ["name", "modified", "owner", "docstatus"]), + filters=filters if filters else None, + limit=args.get("limit", 10), + order_by="modified desc", + ) + return json.dumps(result, indent=2) + + +async def _count_documents(args: dict) -> str: + client = FrappeClient() + result = await client.call_method( + "frappe.client.get_count", + doctype=args["doctype"], + filters=args.get("filters"), + ) + return json.dumps({"doctype": args["doctype"], "count": result}, indent=2) + + +async def _get_document_attachments(args: dict) -> str: + client = FrappeClient() + result = await client.get_list( + "File", + fields=["name", "file_name", "file_url", "file_size", "is_private", "creation"], + filters=[ + ["attached_to_doctype", "=", args["doctype"]], + ["attached_to_name", "=", args["name"]], + ], + limit=50, + ) + return json.dumps(result, indent=2) + + +async def _get_document_versions(args: dict) -> str: + client = FrappeClient() + result = await client.get_list( + "Version", + fields=["name", "owner", "creation", "data"], + filters=[["ref_doctype", "=", args["doctype"]], ["docname", "=", args["name"]]], + limit=args.get("limit", 10), + order_by="creation desc", + ) + return json.dumps(result, indent=2) diff --git a/frappe_mcp/tools/document_lifecycle.py b/frappe_mcp/tools/document_lifecycle.py new file mode 100644 index 0000000..8fb9f74 --- /dev/null +++ b/frappe_mcp/tools/document_lifecycle.py @@ -0,0 +1,229 @@ +""" +Level 3-4 — Document lifecycle tools. +Child row operations, rename, amend, duplicate, status, draft save. +""" + +import json +from mcp.types import Tool +from frappe_mcp.client.frappe_api import FrappeClient + + +def tools() -> list[Tool]: + return [ + # --- Child row ops --- + Tool( + name="frappe_append_child_row", + description="Append a new row to a child table in a document.", + inputSchema={ + "type": "object", + "required": ["doctype", "name", "child_fieldname", "row_data"], + "properties": { + "doctype": {"type": "string", "description": "Parent DocType"}, + "name": {"type": "string", "description": "Parent document name"}, + "child_fieldname": {"type": "string", "description": "Fieldname of the child table e.g. 'items'"}, + "row_data": {"type": "object", "description": "Fields for the new row"}, + }, + }, + ), + Tool( + name="frappe_update_child_row", + description="Update a specific row in a child table by its row name.", + inputSchema={ + "type": "object", + "required": ["child_doctype", "row_name", "updates"], + "properties": { + "child_doctype": {"type": "string", "description": "Child table DocType e.g. 'Sales Order Item'"}, + "row_name": {"type": "string", "description": "Child row document name"}, + "updates": {"type": "object"}, + }, + }, + ), + Tool( + name="frappe_delete_child_row", + description="Delete a specific row from a child table.", + inputSchema={ + "type": "object", + "required": ["child_doctype", "row_name"], + "properties": { + "child_doctype": {"type": "string"}, + "row_name": {"type": "string"}, + }, + }, + ), + # --- Rename --- + Tool( + name="frappe_rename_document", + description="Rename a document (changes its primary key/name). Only works if DocType allows renaming.", + inputSchema={ + "type": "object", + "required": ["doctype", "old_name", "new_name"], + "properties": { + "doctype": {"type": "string"}, + "old_name": {"type": "string"}, + "new_name": {"type": "string"}, + "merge": {"type": "boolean", "default": False, "description": "Merge into existing document if new_name exists"}, + }, + }, + ), + # --- Amend --- + Tool( + name="frappe_amend_document", + description=( + "Create an amendment of a cancelled/submitted document. " + "Returns a new draft with amended_ prefix and links to original." + ), + inputSchema={ + "type": "object", + "required": ["doctype", "name"], + "properties": { + "doctype": {"type": "string"}, + "name": {"type": "string"}, + }, + }, + ), + # --- Duplicate --- + Tool( + name="frappe_duplicate_document", + description="Clone a document — creates a new draft copy with the same field values.", + inputSchema={ + "type": "object", + "required": ["doctype", "name"], + "properties": { + "doctype": {"type": "string"}, + "name": {"type": "string"}, + "field_overrides": { + "type": "object", + "description": "Fields to override in the copy e.g. {\"customer\": \"New Customer\"}", + }, + }, + }, + ), + # --- Status --- + Tool( + name="frappe_get_document_status", + description=( + "Get the normalized status of a document: " + "draft | saved | submitted | cancelled | workflow_state." + ), + inputSchema={ + "type": "object", + "required": ["doctype", "name"], + "properties": { + "doctype": {"type": "string"}, + "name": {"type": "string"}, + }, + }, + ), + # --- Draft save --- + Tool( + name="frappe_save_document_draft", + description=( + "Explicitly save a document as draft (docstatus=0). " + "Use when you want to save partial data without validating all required fields." + ), + inputSchema={ + "type": "object", + "required": ["doctype", "name"], + "properties": { + "doctype": {"type": "string"}, + "name": {"type": "string"}, + "updates": {"type": "object", "description": "Optional field updates to apply before saving"}, + }, + }, + ), + ] + + +def handlers() -> dict: + return { + "frappe_append_child_row": _append_child_row, + "frappe_update_child_row": _update_child_row, + "frappe_delete_child_row": _delete_child_row, + "frappe_rename_document": _rename_document, + "frappe_amend_document": _amend_document, + "frappe_duplicate_document": _duplicate_document, + "frappe_get_document_status": _get_document_status, + "frappe_save_document_draft": _save_document_draft, + } + + +_DOCSTATUS_MAP = {0: "draft/saved", 1: "submitted", 2: "cancelled"} + + +async def _append_child_row(args: dict) -> str: + client = FrappeClient() + doc = await client.get_doc(args["doctype"], args["name"]) + rows = doc.get(args["child_fieldname"], []) + rows.append(args["row_data"]) + result = await client.update_doc(args["doctype"], args["name"], {args["child_fieldname"]: rows}) + return json.dumps(result, indent=2) + + +async def _update_child_row(args: dict) -> str: + client = FrappeClient() + result = await client.update_doc(args["child_doctype"], args["row_name"], args["updates"]) + return json.dumps(result, indent=2) + + +async def _delete_child_row(args: dict) -> str: + client = FrappeClient() + result = await client.delete_doc(args["child_doctype"], args["row_name"]) + return json.dumps(result, indent=2) + + +async def _rename_document(args: dict) -> str: + client = FrappeClient() + result = await client.call_method( + "frappe.client.rename_doc", + doctype=args["doctype"], + old_name=args["old_name"], + new_name=args["new_name"], + merge=args.get("merge", False), + ) + return json.dumps({"renamed_to": result}, indent=2) + + +async def _amend_document(args: dict) -> str: + client = FrappeClient() + doc = await client.get_doc(args["doctype"], args["name"]) + amended = {k: v for k, v in doc.items() if not k.startswith("__")} + amended.pop("name", None) + amended["amended_from"] = args["name"] + amended["docstatus"] = 0 + result = await client.create_doc(args["doctype"], amended) + return json.dumps(result, indent=2) + + +async def _duplicate_document(args: dict) -> str: + client = FrappeClient() + doc = await client.get_doc(args["doctype"], args["name"]) + copy = {k: v for k, v in doc.items() if not k.startswith("__")} + copy.pop("name", None) + copy["docstatus"] = 0 + copy.update(args.get("field_overrides", {})) + result = await client.create_doc(args["doctype"], copy) + return json.dumps(result, indent=2) + + +async def _get_document_status(args: dict) -> str: + client = FrappeClient() + doc = await client.get_doc(args["doctype"], args["name"]) + docstatus = doc.get("docstatus", 0) + workflow_state = doc.get("workflow_state", None) + status = doc.get("status", None) + return json.dumps({ + "name": args["name"], + "doctype": args["doctype"], + "docstatus": docstatus, + "docstatus_label": _DOCSTATUS_MAP.get(docstatus, "unknown"), + "status": status, + "workflow_state": workflow_state, + }, indent=2) + + +async def _save_document_draft(args: dict) -> str: + client = FrappeClient() + updates = args.get("updates", {}) + updates["docstatus"] = 0 + result = await client.update_doc(args["doctype"], args["name"], updates) + return json.dumps(result, indent=2) diff --git a/frappe_mcp/tools/documents.py b/frappe_mcp/tools/documents.py new file mode 100644 index 0000000..5150f7c --- /dev/null +++ b/frappe_mcp/tools/documents.py @@ -0,0 +1,169 @@ +"""Document CRUD tools — create, read, update, delete, list Frappe documents.""" + +import json +from mcp.types import Tool +from frappe_mcp.client.frappe_api import FrappeClient +from frappe_mcp.config import get_settings + + +def tools() -> list[Tool]: + return [ + Tool( + name="frappe_list_documents", + description="List documents of a given DocType with optional filters.", + inputSchema={ + "type": "object", + "required": ["doctype"], + "properties": { + "doctype": {"type": "string"}, + "fields": { + "type": "array", + "items": {"type": "string"}, + "description": "Fields to return. Defaults to ['name', 'modified'].", + }, + "filters": { + "type": "array", + "description": "Filter list e.g. [[\"status\", \"=\", \"Open\"]]", + }, + "limit": {"type": "integer", "default": 20}, + "order_by": {"type": "string", "default": "modified desc"}, + }, + }, + ), + Tool( + name="frappe_get_document", + description="Get a single Frappe document by DocType and name.", + inputSchema={ + "type": "object", + "required": ["doctype", "name"], + "properties": { + "doctype": {"type": "string"}, + "name": {"type": "string"}, + }, + }, + ), + Tool( + name="frappe_create_document", + description="Create a new document in any DocType.", + inputSchema={ + "type": "object", + "required": ["doctype", "data"], + "properties": { + "doctype": {"type": "string"}, + "data": {"type": "object", "description": "Field values for the new document"}, + }, + }, + ), + Tool( + name="frappe_update_document", + description="Update fields on an existing document.", + inputSchema={ + "type": "object", + "required": ["doctype", "name", "data"], + "properties": { + "doctype": {"type": "string"}, + "name": {"type": "string"}, + "data": {"type": "object", "description": "Fields to update"}, + }, + }, + ), + Tool( + name="frappe_delete_document", + description="Delete a document. Blocked in read-only mode.", + inputSchema={ + "type": "object", + "required": ["doctype", "name"], + "properties": { + "doctype": {"type": "string"}, + "name": {"type": "string"}, + }, + }, + ), + Tool( + name="frappe_submit_document", + description="Submit a submittable document (changes status to Submitted).", + inputSchema={ + "type": "object", + "required": ["doctype", "name"], + "properties": { + "doctype": {"type": "string"}, + "name": {"type": "string"}, + }, + }, + ), + Tool( + name="frappe_cancel_document", + description="Cancel a submitted document.", + inputSchema={ + "type": "object", + "required": ["doctype", "name"], + "properties": { + "doctype": {"type": "string"}, + "name": {"type": "string"}, + }, + }, + ), + ] + + +def handlers() -> dict: + return { + "frappe_list_documents": _list_documents, + "frappe_get_document": _get_document, + "frappe_create_document": _create_document, + "frappe_update_document": _update_document, + "frappe_delete_document": _delete_document, + "frappe_submit_document": _submit_document, + "frappe_cancel_document": _cancel_document, + } + + +async def _list_documents(args: dict) -> str: + client = FrappeClient() + result = await client.get_list( + args["doctype"], + fields=args.get("fields", ["name", "modified"]), + filters=args.get("filters"), + limit=args.get("limit", 20), + order_by=args.get("order_by", "modified desc"), + ) + return json.dumps(result, indent=2) + + +async def _get_document(args: dict) -> str: + client = FrappeClient() + result = await client.get_doc(args["doctype"], args["name"]) + return json.dumps(result, indent=2) + + +async def _create_document(args: dict) -> str: + client = FrappeClient() + data = {"doctype": args["doctype"], **args["data"]} + result = await client.create_doc(args["doctype"], data) + return json.dumps(result, indent=2) + + +async def _update_document(args: dict) -> str: + client = FrappeClient() + result = await client.update_doc(args["doctype"], args["name"], args["data"]) + return json.dumps(result, indent=2) + + +async def _delete_document(args: dict) -> str: + if get_settings().read_only_mode: + return "Error: read_only_mode is enabled. Deletion blocked." + client = FrappeClient() + result = await client.delete_doc(args["doctype"], args["name"]) + return json.dumps(result, indent=2) + + +async def _submit_document(args: dict) -> str: + client = FrappeClient() + result = await client.update_doc(args["doctype"], args["name"], {"docstatus": 1}) + return json.dumps(result, indent=2) + + +async def _cancel_document(args: dict) -> str: + client = FrappeClient() + result = await client.update_doc(args["doctype"], args["name"], {"docstatus": 2}) + return json.dumps(result, indent=2) diff --git a/frappe_mcp/tools/email_templates.py b/frappe_mcp/tools/email_templates.py new file mode 100644 index 0000000..de14c30 --- /dev/null +++ b/frappe_mcp/tools/email_templates.py @@ -0,0 +1,220 @@ +"""Email Template tools — create, list, update Frappe Email Templates and Notifications.""" + +import json +from mcp.types import Tool +from frappe_mcp.client.frappe_api import FrappeClient + + +def tools() -> list[Tool]: + return [ + Tool( + name="frappe_list_email_templates", + description="List all Email Templates in the system.", + inputSchema={ + "type": "object", + "properties": { + "limit": {"type": "integer", "default": 30}, + }, + }, + ), + Tool( + name="frappe_get_email_template", + description="Get a specific Email Template including subject and body.", + inputSchema={ + "type": "object", + "required": ["name"], + "properties": { + "name": {"type": "string"}, + }, + }, + ), + Tool( + name="frappe_create_email_template", + description=( + "Create a new Email Template. Subject and response support Jinja2. " + "Access doc fields with {{ doc.field_name }}." + ), + inputSchema={ + "type": "object", + "required": ["name", "subject", "response"], + "properties": { + "name": {"type": "string", "description": "Template name/ID"}, + "subject": {"type": "string", "description": "Email subject (Jinja2 supported)"}, + "response": {"type": "string", "description": "Email body HTML (Jinja2 supported)"}, + "use_html": {"type": "integer", "default": 1}, + }, + }, + ), + Tool( + name="frappe_update_email_template", + description="Update an Email Template's subject or body.", + inputSchema={ + "type": "object", + "required": ["name"], + "properties": { + "name": {"type": "string"}, + "subject": {"type": "string"}, + "response": {"type": "string"}, + "updates": {"type": "object"}, + }, + }, + ), + Tool( + name="frappe_list_notifications", + description="List Frappe Notifications (automated email/Slack alerts on DocType events).", + inputSchema={ + "type": "object", + "properties": { + "document_type": {"type": "string", "description": "Filter by DocType"}, + "enabled": {"type": "integer", "description": "1 = enabled only"}, + "limit": {"type": "integer", "default": 30}, + }, + }, + ), + Tool( + name="frappe_create_notification", + description=( + "Create a Frappe Notification — triggers an email/Slack/system message " + "on a DocType event or date condition." + ), + inputSchema={ + "type": "object", + "required": ["name", "document_type", "event", "subject", "message"], + "properties": { + "name": {"type": "string"}, + "document_type": {"type": "string"}, + "event": { + "type": "string", + "enum": [ + "New", "Save", "Submit", "Cancel", "Days Before", + "Days After", "Value Change", "Method", "Custom", + ], + }, + "subject": {"type": "string", "description": "Email subject (Jinja2)"}, + "message": {"type": "string", "description": "Email body (Jinja2 HTML)"}, + "send_to_all_assignees": {"type": "integer", "default": 0}, + "recipients": { + "type": "array", + "description": "List of recipient rows: [{\"receiver_by_document_field\": \"email\"}]", + "items": {"type": "object"}, + }, + "condition": {"type": "string", "description": "Python expression e.g. doc.status == 'Open'"}, + "channel": { + "type": "string", + "enum": ["Email", "Slack", "System Notification", "SMS"], + "default": "Email", + }, + "enabled": {"type": "integer", "default": 1}, + }, + }, + ), + Tool( + name="frappe_update_notification", + description="Enable, disable, or update a Notification.", + inputSchema={ + "type": "object", + "required": ["name"], + "properties": { + "name": {"type": "string"}, + "enabled": {"type": "integer"}, + "subject": {"type": "string"}, + "message": {"type": "string"}, + "updates": {"type": "object"}, + }, + }, + ), + ] + + +def handlers() -> dict: + return { + "frappe_list_email_templates": _list_email_templates, + "frappe_get_email_template": _get_email_template, + "frappe_create_email_template": _create_email_template, + "frappe_update_email_template": _update_email_template, + "frappe_list_notifications": _list_notifications, + "frappe_create_notification": _create_notification, + "frappe_update_notification": _update_notification, + } + + +async def _list_email_templates(args: dict) -> str: + client = FrappeClient() + result = await client.get_list( + "Email Template", + fields=["name", "subject", "modified"], + limit=args.get("limit", 30), + ) + return json.dumps(result, indent=2) + + +async def _get_email_template(args: dict) -> str: + client = FrappeClient() + result = await client.get_doc("Email Template", args["name"]) + return json.dumps(result, indent=2) + + +async def _create_email_template(args: dict) -> str: + client = FrappeClient() + payload = { + "name": args["name"], + "subject": args["subject"], + "response": args["response"], + "use_html": args.get("use_html", 1), + } + result = await client.create_doc("Email Template", payload) + return json.dumps(result, indent=2) + + +async def _update_email_template(args: dict) -> str: + client = FrappeClient() + updates = args.get("updates", {}) + for field in ("subject", "response"): + if field in args: + updates[field] = args[field] + result = await client.update_doc("Email Template", args["name"], updates) + return json.dumps(result, indent=2) + + +async def _list_notifications(args: dict) -> str: + client = FrappeClient() + filters = [] + if dt := args.get("document_type"): + filters.append(["document_type", "=", dt]) + if "enabled" in args: + filters.append(["enabled", "=", args["enabled"]]) + result = await client.get_list( + "Notification", + fields=["name", "document_type", "event", "channel", "enabled"], + filters=filters if filters else None, + limit=args.get("limit", 30), + ) + return json.dumps(result, indent=2) + + +async def _create_notification(args: dict) -> str: + client = FrappeClient() + payload = { + "name": args["name"], + "document_type": args["document_type"], + "event": args["event"], + "subject": args["subject"], + "message": args["message"], + "channel": args.get("channel", "Email"), + "enabled": args.get("enabled", 1), + "send_to_all_assignees": args.get("send_to_all_assignees", 0), + "recipients": args.get("recipients", []), + "condition": args.get("condition", ""), + } + result = await client.create_doc("Notification", payload) + return json.dumps(result, indent=2) + + +async def _update_notification(args: dict) -> str: + client = FrappeClient() + updates = args.get("updates", {}) + for field in ("enabled", "subject", "message"): + if field in args: + updates[field] = args[field] + result = await client.update_doc("Notification", args["name"], updates) + return json.dumps(result, indent=2) diff --git a/frappe_mcp/tools/files.py b/frappe_mcp/tools/files.py new file mode 100644 index 0000000..d0b45b2 --- /dev/null +++ b/frappe_mcp/tools/files.py @@ -0,0 +1,168 @@ +"""File and attachment management tools.""" + +import json +import base64 +from mcp.types import Tool +from frappe_mcp.client.frappe_api import FrappeClient + + +def tools() -> list[Tool]: + return [ + Tool( + name="frappe_list_files", + description="List files in Frappe's file manager, optionally filtered by attached document.", + inputSchema={ + "type": "object", + "properties": { + "attached_to_doctype": {"type": "string"}, + "attached_to_name": {"type": "string"}, + "folder": {"type": "string", "description": "e.g. 'Home/Attachments'"}, + "is_private": {"type": "integer", "description": "1 = private, 0 = public"}, + "limit": {"type": "integer", "default": 20}, + }, + }, + ), + Tool( + name="frappe_get_file", + description="Get details of a file by its name/ID.", + inputSchema={ + "type": "object", + "required": ["name"], + "properties": { + "name": {"type": "string"}, + }, + }, + ), + Tool( + name="frappe_upload_file_base64", + description=( + "Upload a file to Frappe by providing base64-encoded content. " + "Optionally attach it to a document." + ), + inputSchema={ + "type": "object", + "required": ["filename", "content_base64"], + "properties": { + "filename": {"type": "string", "description": "File name with extension"}, + "content_base64": {"type": "string", "description": "Base64-encoded file content"}, + "attached_to_doctype": {"type": "string"}, + "attached_to_name": {"type": "string"}, + "attached_to_field": {"type": "string"}, + "is_private": {"type": "integer", "default": 1}, + "folder": {"type": "string", "default": "Home/Attachments"}, + }, + }, + ), + Tool( + name="frappe_delete_file", + description="Delete a file from Frappe.", + inputSchema={ + "type": "object", + "required": ["name"], + "properties": { + "name": {"type": "string", "description": "File document name"}, + }, + }, + ), + Tool( + name="frappe_move_file", + description="Move a file to a different folder.", + inputSchema={ + "type": "object", + "required": ["name", "folder"], + "properties": { + "name": {"type": "string"}, + "folder": {"type": "string", "description": "Target folder path"}, + }, + }, + ), + Tool( + name="frappe_create_folder", + description="Create a new folder in the Frappe file manager.", + inputSchema={ + "type": "object", + "required": ["folder_name"], + "properties": { + "folder_name": {"type": "string"}, + "parent_folder": {"type": "string", "default": "Home"}, + }, + }, + ), + ] + + +def handlers() -> dict: + return { + "frappe_list_files": _list_files, + "frappe_get_file": _get_file, + "frappe_upload_file_base64": _upload_file_base64, + "frappe_delete_file": _delete_file, + "frappe_move_file": _move_file, + "frappe_create_folder": _create_folder, + } + + +async def _list_files(args: dict) -> str: + client = FrappeClient() + filters = [] + if dt := args.get("attached_to_doctype"): + filters.append(["attached_to_doctype", "=", dt]) + if name := args.get("attached_to_name"): + filters.append(["attached_to_name", "=", name]) + if folder := args.get("folder"): + filters.append(["folder", "=", folder]) + if "is_private" in args: + filters.append(["is_private", "=", args["is_private"]]) + result = await client.get_list( + "File", + fields=["name", "file_name", "file_url", "file_size", "is_private", "folder", "attached_to_doctype", "attached_to_name"], + filters=filters if filters else None, + limit=args.get("limit", 20), + ) + return json.dumps(result, indent=2) + + +async def _get_file(args: dict) -> str: + client = FrappeClient() + result = await client.get_doc("File", args["name"]) + return json.dumps(result, indent=2) + + +async def _upload_file_base64(args: dict) -> str: + client = FrappeClient() + payload = { + "filename": args["filename"], + "filedata": args["content_base64"], + "is_private": args.get("is_private", 1), + "folder": args.get("folder", "Home/Attachments"), + } + if dt := args.get("attached_to_doctype"): + payload["doctype"] = dt + if name := args.get("attached_to_name"): + payload["docname"] = name + if field := args.get("attached_to_field"): + payload["fieldname"] = field + result = await client.call_method("frappe.client.attach_file", **payload) + return json.dumps(result, indent=2) + + +async def _delete_file(args: dict) -> str: + client = FrappeClient() + result = await client.delete_doc("File", args["name"]) + return json.dumps(result, indent=2) + + +async def _move_file(args: dict) -> str: + client = FrappeClient() + result = await client.update_doc("File", args["name"], {"folder": args["folder"]}) + return json.dumps(result, indent=2) + + +async def _create_folder(args: dict) -> str: + client = FrappeClient() + result = await client.call_method( + "frappe.core.doctype.file.file.create_new_folder", + file_name=args["folder_name"], + folder=args.get("parent_folder", "Home"), + ) + return json.dumps(result, indent=2) diff --git a/frappe_mcp/tools/foundation.py b/frappe_mcp/tools/foundation.py new file mode 100644 index 0000000..a133507 --- /dev/null +++ b/frappe_mcp/tools/foundation.py @@ -0,0 +1,144 @@ +""" +Level 1 — Foundation tools. +These are the absolute minimum. Without these, the AI is blind. +""" + +import json +from mcp.types import Tool +from frappe_mcp.client.frappe_api import FrappeClient + + +def tools() -> list[Tool]: + return [ + Tool( + name="frappe_ping", + description="Check whether Frappe is reachable. Returns version and status.", + inputSchema={"type": "object", "properties": {}}, + ), + Tool( + name="frappe_get_session_info", + description=( + "Returns the current session: logged-in user, roles, site name, " + "enabled modules, and Frappe version. Call this first to understand context." + ), + inputSchema={"type": "object", "properties": {}}, + ), + Tool( + name="frappe_get_doctype_permissions", + description=( + "Returns what the current user can do on a DocType: " + "read, write, create, delete, submit, cancel, amend, print, email, export, import." + ), + inputSchema={ + "type": "object", + "required": ["doctype"], + "properties": { + "doctype": {"type": "string"}, + }, + }, + ), + Tool( + name="frappe_list_modules", + description=( + "List all Frappe/ERPNext modules available on the site " + "(Selling, Buying, Stock, Accounts, HR, CRM, Support, Custom, etc.)." + ), + inputSchema={"type": "object", "properties": {}}, + ), + Tool( + name="frappe_get_doctype_meta", + description=( + "Get comprehensive DocType schema: all fields with types, required flags, " + "options, child tables, link fields, and permission hints. " + "Essential before creating or editing documents." + ), + inputSchema={ + "type": "object", + "required": ["doctype"], + "properties": { + "doctype": {"type": "string"}, + "include_permissions": {"type": "boolean", "default": True}, + }, + }, + ), + ] + + +def handlers() -> dict: + return { + "frappe_ping": _ping, + "frappe_get_session_info": _get_session_info, + "frappe_get_doctype_permissions": _get_doctype_permissions, + "frappe_list_modules": _list_modules, + "frappe_get_doctype_meta": _get_doctype_meta, + } + + +async def _ping(args: dict) -> str: + client = FrappeClient() + try: + versions = await client.call_method("frappe.utils.change_log.get_versions") + frappe_ver = versions.get("frappe", {}).get("version", "unknown") if isinstance(versions, dict) else "unknown" + return json.dumps({"status": "ok", "frappe_version": frappe_ver, "url": client.base_url}, indent=2) + except Exception as e: + return json.dumps({"status": "unreachable", "error": str(e)}, indent=2) + + +async def _get_session_info(args: dict) -> str: + client = FrappeClient() + user = await client.call_method("frappe.auth.get_logged_user") + user_doc = await client.get_doc("User", user) + roles = [r["role"] for r in user_doc.get("roles", [])] + versions = await client.call_method("frappe.utils.change_log.get_versions") + modules_raw = await client.get_list( + "Module Def", + fields=["name", "app_name"], + limit=100, + ) + return json.dumps({ + "user": user, + "roles": roles, + "frappe_url": client.base_url, + "site_name": client.settings.frappe_site_name or "(default)", + "frappe_version": versions.get("frappe", {}).get("version") if isinstance(versions, dict) else None, + "installed_apps": list(versions.keys()) if isinstance(versions, dict) else [], + "modules": [m["name"] for m in (modules_raw if isinstance(modules_raw, list) else [])], + }, indent=2) + + +async def _get_doctype_permissions(args: dict) -> str: + client = FrappeClient() + result = await client.call_method( + "frappe.client.get_perm", + doctype=args["doctype"], + ) + return json.dumps(result, indent=2) + + +async def _list_modules(args: dict) -> str: + client = FrappeClient() + result = await client.get_list( + "Module Def", + fields=["name", "app_name"], + limit=150, + order_by="name asc", + ) + return json.dumps(result, indent=2) + + +async def _get_doctype_meta(args: dict) -> str: + client = FrappeClient() + meta = await client.call_method( + "frappe.desk.form.load.getdoctype", + doctype=args["doctype"], + with_parent=1, + cached_timestamp=None, + ) + if args.get("include_permissions", True): + try: + perms = await client.call_method("frappe.client.get_perm", doctype=args["doctype"]) + if isinstance(meta, dict): + meta["_permissions"] = perms + except Exception: + pass + return json.dumps(meta, indent=2) diff --git a/frappe_mcp/tools/governance.py b/frappe_mcp/tools/governance.py new file mode 100644 index 0000000..f3883e6 --- /dev/null +++ b/frappe_mcp/tools/governance.py @@ -0,0 +1,425 @@ +""" +Level 10 — Safety and governance tools. +Dry run, validate, risk scoring, confirmation tokens, audit log, rollback tracking. +""" + +import json +import time +import hashlib +import secrets +from mcp.types import Tool +from frappe_mcp.client.frappe_api import FrappeClient +from frappe_mcp.audit_store import log_action, read_audit_log, clear_audit_log + +# In-memory confirmation token store (clears on restart — by design) +_pending_confirmations: dict[str, dict] = {} + +# Reversible action registry: tool_call_id → undo info +_reversible_actions: dict[str, dict] = {} + +# Risk profiles per tool prefix +_RISK_MAP = { + "frappe_delete": "high", + "frappe_bulk_delete": "high", + "frappe_cancel": "high", + "frappe_reset_customization": "high", + "frappe_revoke_api_key": "high", + "frappe_clear_error_logs": "medium", + "frappe_bulk_submit": "medium", + "frappe_submit": "medium", + "frappe_create": "low", + "frappe_update": "low", + "frappe_get": "low", + "frappe_list": "low", + "frappe_ping": "low", +} + + +def _score_risk(tool_name: str, arguments: dict) -> str: + for prefix, level in _RISK_MAP.items(): + if tool_name.startswith(prefix): + return level + if "delete" in tool_name or "cancel" in tool_name or "reset" in tool_name: + return "high" + if "submit" in tool_name or "bulk" in tool_name: + return "medium" + return "low" + + +def tools() -> list[Tool]: + return [ + Tool( + name="frappe_validate_document_payload", + description=( + "Validate document fields before creating or updating. " + "Checks required fields, field types, and link validity. " + "Does NOT save anything." + ), + inputSchema={ + "type": "object", + "required": ["doctype", "data"], + "properties": { + "doctype": {"type": "string"}, + "data": {"type": "object"}, + "name": {"type": "string", "description": "Provide if validating an update"}, + }, + }, + ), + Tool( + name="frappe_dry_run_action", + description=( + "Simulate a create or update action without saving. " + "Returns what WOULD happen: validation result, computed fields, and any errors." + ), + inputSchema={ + "type": "object", + "required": ["action", "doctype", "data"], + "properties": { + "action": {"type": "string", "enum": ["create", "update"]}, + "doctype": {"type": "string"}, + "data": {"type": "object"}, + "name": {"type": "string", "description": "Required for update dry-run"}, + }, + }, + ), + Tool( + name="frappe_risk_score_action", + description=( + "Score the risk level of a proposed action before executing it. " + "Returns low | medium | high with reasoning." + ), + inputSchema={ + "type": "object", + "required": ["tool_name", "arguments"], + "properties": { + "tool_name": {"type": "string"}, + "arguments": {"type": "object"}, + }, + }, + ), + Tool( + name="frappe_request_confirmation_token", + description=( + "Generate a one-time confirmation token for a high-risk action. " + "The token expires in 60 seconds. " + "Pass the token to frappe_confirm_and_execute to proceed." + ), + inputSchema={ + "type": "object", + "required": ["tool_name", "arguments"], + "properties": { + "tool_name": {"type": "string"}, + "arguments": {"type": "object"}, + "reason": {"type": "string", "description": "Why this action is being performed"}, + }, + }, + ), + Tool( + name="frappe_audit_tool_call", + description="Manually log a tool call to the MCP audit log.", + inputSchema={ + "type": "object", + "required": ["tool_name", "arguments"], + "properties": { + "tool_name": {"type": "string"}, + "arguments": {"type": "object"}, + "result_summary": {"type": "string"}, + "risk": {"type": "string", "enum": ["low", "medium", "high"], "default": "low"}, + }, + }, + ), + Tool( + name="frappe_get_audit_history", + description="Read the MCP-level audit log of all tool calls made in this session.", + inputSchema={ + "type": "object", + "properties": { + "limit": {"type": "integer", "default": 50}, + "tool_filter": {"type": "string", "description": "Filter by tool name substring"}, + }, + }, + ), + Tool( + name="frappe_clear_audit_log", + description="Clear the local MCP audit log file.", + inputSchema={"type": "object", "properties": {}}, + ), + Tool( + name="frappe_tool_health_check", + description=( + "Full health check: Frappe connectivity, auth, read/write access, " + "scheduler status, and MCP module status." + ), + inputSchema={"type": "object", "properties": {}}, + ), + Tool( + name="frappe_policy_check", + description=( + "Check whether an action is allowed under the current MCP policy " + "(read_only_mode, enabled modules, risk thresholds)." + ), + inputSchema={ + "type": "object", + "required": ["tool_name"], + "properties": { + "tool_name": {"type": "string"}, + "arguments": {"type": "object"}, + }, + }, + ), + Tool( + name="frappe_register_reversible_action", + description=( + "Register an action as reversible so it can be rolled back later. " + "Stores doctype, name, and the pre-action state." + ), + inputSchema={ + "type": "object", + "required": ["action_id", "doctype", "name"], + "properties": { + "action_id": {"type": "string"}, + "doctype": {"type": "string"}, + "name": {"type": "string"}, + "pre_state": {"type": "object", "description": "Document state before the action"}, + }, + }, + ), + Tool( + name="frappe_rollback_action", + description=( + "Roll back a previously registered reversible action " + "by restoring the document to its pre-action state." + ), + inputSchema={ + "type": "object", + "required": ["action_id"], + "properties": { + "action_id": {"type": "string"}, + }, + }, + ), + ] + + +def handlers() -> dict: + return { + "frappe_validate_document_payload": _validate_payload, + "frappe_dry_run_action": _dry_run_action, + "frappe_risk_score_action": _risk_score_action, + "frappe_request_confirmation_token": _request_confirmation_token, + "frappe_audit_tool_call": _audit_tool_call, + "frappe_get_audit_history": _get_audit_history, + "frappe_clear_audit_log": _clear_audit_log, + "frappe_tool_health_check": _tool_health_check, + "frappe_policy_check": _policy_check, + "frappe_register_reversible_action": _register_reversible_action, + "frappe_rollback_action": _rollback_action, + } + + +async def _validate_payload(args: dict) -> str: + client = FrappeClient() + meta = await client.call_method( + "frappe.desk.form.load.getdoctype", + doctype=args["doctype"], + with_parent=1, + cached_timestamp=None, + ) + data = args["data"] + errors = [] + warnings = [] + + docs = meta.get("docs", []) if isinstance(meta, dict) else [] + fields = next((d.get("fields", []) for d in docs if d.get("name") == args["doctype"]), []) + + for field in fields: + fname = field.get("fieldname", "") + if field.get("reqd") and fname not in data and not data.get(fname): + errors.append(f"Required field missing: {fname} ({field.get('label', fname)})") + if fname in data and field.get("fieldtype") == "Int": + try: + int(data[fname]) + except (TypeError, ValueError): + errors.append(f"Field {fname} expects an integer, got: {data[fname]}") + + return json.dumps({ + "valid": len(errors) == 0, + "errors": errors, + "warnings": warnings, + "fields_provided": list(data.keys()), + }, indent=2) + + +async def _dry_run_action(args: dict) -> str: + client = FrappeClient() + action = args["action"] + doctype = args["doctype"] + data = args["data"] + + try: + if action == "create": + result = await client.call_method( + "frappe.client.validate", + doc={"doctype": doctype, **data}, + ) + else: + name = args.get("name", "") + existing = await client.get_doc(doctype, name) + merged = {**existing, **data} + result = await client.call_method("frappe.client.validate", doc=merged) + return json.dumps({"dry_run": True, "action": action, "status": "would_succeed", "result": result}, indent=2) + except Exception as e: + return json.dumps({"dry_run": True, "action": action, "status": "would_fail", "error": str(e)}, indent=2) + + +async def _risk_score_action(args: dict) -> str: + tool = args["tool_name"] + arguments = args.get("arguments", {}) + risk = _score_risk(tool, arguments) + + reasons = [] + if "delete" in tool: + reasons.append("Destructive operation — cannot be undone easily") + if "bulk" in tool: + reasons.append("Bulk operation affects multiple records") + if "cancel" in tool: + reasons.append("Cancellation changes document lifecycle") + if "reset" in tool: + reasons.append("Resets customization — removes all property setters and custom fields") + if not reasons: + reasons.append("Standard read/write operation") + + return json.dumps({"tool": tool, "risk": risk, "reasons": reasons}, indent=2) + + +async def _request_confirmation_token(args: dict) -> str: + token = secrets.token_hex(8) + _pending_confirmations[token] = { + "tool_name": args["tool_name"], + "arguments": args["arguments"], + "reason": args.get("reason", ""), + "expires_at": time.time() + 60, + "created_at": time.time(), + } + return json.dumps({ + "token": token, + "tool_name": args["tool_name"], + "expires_in_seconds": 60, + "instruction": "Pass this token to frappe_confirm_and_execute to proceed with the action.", + }, indent=2) + + +async def _audit_tool_call(args: dict) -> str: + record_id = log_action( + tool_name=args["tool_name"], + arguments=args["arguments"], + result_summary=args.get("result_summary", ""), + risk=args.get("risk", "low"), + ) + return json.dumps({"logged": True, "record_id": record_id}, indent=2) + + +async def _get_audit_history(args: dict) -> str: + records = read_audit_log( + limit=args.get("limit", 50), + tool_filter=args.get("tool_filter", ""), + ) + return json.dumps({"count": len(records), "records": records}, indent=2) + + +async def _clear_audit_log(args: dict) -> str: + count = clear_audit_log() + return json.dumps({"cleared": True, "records_removed": count}, indent=2) + + +async def _tool_health_check(args: dict) -> str: + from frappe_mcp.healthcheck import run_health_check + from frappe_mcp.module_registry import ALL_MODULES, _is_module_enabled + from frappe_mcp.config import get_settings + import asyncio + + client = FrappeClient() + settings = get_settings() + + checks = {} + + # connectivity + try: + versions = await client.call_method("frappe.utils.change_log.get_versions") + checks["frappe_reachable"] = {"status": "ok", "version": versions.get("frappe", {}).get("version") if isinstance(versions, dict) else None} + except Exception as e: + checks["frappe_reachable"] = {"status": "fail", "error": str(e)} + + # auth + try: + user = await client.call_method("frappe.auth.get_logged_user") + checks["auth"] = {"status": "ok", "user": user} + except Exception as e: + checks["auth"] = {"status": "fail", "error": str(e)} + + # modules + checks["modules"] = { + key: "enabled" if _is_module_enabled(key) else "disabled" + for key, _, _ in ALL_MODULES + } + checks["read_only_mode"] = settings.read_only_mode + checks["total_enabled_tools"] = sum( + len(mod.tools()) for key, mod, _ in ALL_MODULES if _is_module_enabled(key) + ) + + return json.dumps(checks, indent=2) + + +async def _policy_check(args: dict) -> str: + from frappe_mcp.config import get_settings + from frappe_mcp.module_registry import _is_module_enabled, ALL_MODULES + + settings = get_settings() + tool = args["tool_name"] + risk = _score_risk(tool, args.get("arguments", {})) + + violations = [] + + if settings.read_only_mode: + write_prefixes = ["frappe_create", "frappe_update", "frappe_delete", "frappe_submit", + "frappe_cancel", "frappe_bulk", "erpnext_"] + if any(tool.startswith(p) for p in write_prefixes): + violations.append("read_only_mode=true — write operations are blocked") + + module_key = None + for key, mod, _ in ALL_MODULES: + if tool in mod.handlers(): + module_key = key + break + if module_key and not _is_module_enabled(module_key): + violations.append(f"Module '{module_key}' is disabled in configuration") + + return json.dumps({ + "tool": tool, + "risk": risk, + "allowed": len(violations) == 0, + "violations": violations, + }, indent=2) + + +async def _register_reversible_action(args: dict) -> str: + _reversible_actions[args["action_id"]] = { + "doctype": args["doctype"], + "name": args["name"], + "pre_state": args.get("pre_state", {}), + "registered_at": time.time(), + } + return json.dumps({"registered": True, "action_id": args["action_id"]}, indent=2) + + +async def _rollback_action(args: dict) -> str: + action = _reversible_actions.get(args["action_id"]) + if not action: + return json.dumps({"error": f"No reversible action found for id: {args['action_id']}"}, indent=2) + client = FrappeClient() + try: + result = await client.update_doc(action["doctype"], action["name"], action["pre_state"]) + del _reversible_actions[args["action_id"]] + return json.dumps({"rolled_back": True, "doctype": action["doctype"], "name": action["name"]}, indent=2) + except Exception as e: + return json.dumps({"rolled_back": False, "error": str(e)}, indent=2) diff --git a/frappe_mcp/tools/naming_series.py b/frappe_mcp/tools/naming_series.py new file mode 100644 index 0000000..fbcbc7e --- /dev/null +++ b/frappe_mcp/tools/naming_series.py @@ -0,0 +1,111 @@ +"""Naming Series tools — manage document naming patterns in Frappe.""" + +import json +from mcp.types import Tool +from frappe_mcp.client.frappe_api import FrappeClient + + +def tools() -> list[Tool]: + return [ + Tool( + name="frappe_get_naming_series", + description="Get all naming series options for a DocType.", + inputSchema={ + "type": "object", + "required": ["doctype"], + "properties": { + "doctype": {"type": "string"}, + }, + }, + ), + Tool( + name="frappe_set_naming_series", + description=( + "Set or update naming series options for a DocType. " + "Series format: 'PREFIX-.YYYY.-.MM.-.####' where #### is zero-padded counter." + ), + inputSchema={ + "type": "object", + "required": ["doctype", "series"], + "properties": { + "doctype": {"type": "string"}, + "series": { + "type": "string", + "description": "Newline-separated list of series patterns e.g. 'INV-.YYYY.-.####\\nINV-TEST-.####'", + }, + }, + }, + ), + Tool( + name="frappe_get_series_counter", + description="Get the current counter value for a naming series prefix.", + inputSchema={ + "type": "object", + "required": ["prefix"], + "properties": { + "prefix": {"type": "string", "description": "Prefix e.g. 'INV-2024-'"}, + }, + }, + ), + Tool( + name="frappe_update_series_counter", + description="Manually set the counter for a naming series prefix.", + inputSchema={ + "type": "object", + "required": ["prefix", "value"], + "properties": { + "prefix": {"type": "string"}, + "value": {"type": "integer", "description": "New counter value"}, + }, + }, + ), + ] + + +def handlers() -> dict: + return { + "frappe_get_naming_series": _get_naming_series, + "frappe_set_naming_series": _set_naming_series, + "frappe_get_series_counter": _get_series_counter, + "frappe_update_series_counter": _update_series_counter, + } + + +async def _get_naming_series(args: dict) -> str: + client = FrappeClient() + result = await client.call_method( + "frappe.core.doctype.naming_series.naming_series.get_options", + arg=args["doctype"], + ) + return json.dumps(result, indent=2) + + +async def _set_naming_series(args: dict) -> str: + client = FrappeClient() + result = await client.call_method( + "frappe.core.doctype.naming_series.naming_series.update_series", + args={ + "df": {"options": args["series"]}, + "doctype": args["doctype"], + }, + ) + return json.dumps(result, indent=2) + + +async def _get_series_counter(args: dict) -> str: + client = FrappeClient() + result = await client.call_method( + "frappe.core.doctype.naming_series.naming_series.get_current", + prefix=args["prefix"], + ) + return json.dumps({"prefix": args["prefix"], "current": result}, indent=2) + + +async def _update_series_counter(args: dict) -> str: + client = FrappeClient() + result = await client.call_method( + "frappe.core.doctype.naming_series.naming_series.update_counter", + prefix=args["prefix"], + value=args["value"], + ) + return json.dumps(result, indent=2) diff --git a/frappe_mcp/tools/print_formats.py b/frappe_mcp/tools/print_formats.py new file mode 100644 index 0000000..11bbb6a --- /dev/null +++ b/frappe_mcp/tools/print_formats.py @@ -0,0 +1,155 @@ +"""Print Format tools — create, list, update Frappe Print Formats (Jinja/HTML).""" + +import json +from mcp.types import Tool +from frappe_mcp.client.frappe_api import FrappeClient + + +def tools() -> list[Tool]: + return [ + Tool( + name="frappe_list_print_formats", + description="List all Print Formats, optionally filtered by DocType.", + inputSchema={ + "type": "object", + "properties": { + "doc_type": {"type": "string", "description": "Filter by DocType"}, + "limit": {"type": "integer", "default": 30}, + }, + }, + ), + Tool( + name="frappe_get_print_format", + description="Get the full definition of a Print Format including its HTML template.", + inputSchema={ + "type": "object", + "required": ["name"], + "properties": { + "name": {"type": "string"}, + }, + }, + ), + Tool( + name="frappe_create_print_format", + description=( + "Create a new custom Print Format with a Jinja2/HTML template. " + "Use {{ doc.field_name }} syntax to access document fields." + ), + inputSchema={ + "type": "object", + "required": ["name", "doc_type", "html"], + "properties": { + "name": {"type": "string"}, + "doc_type": {"type": "string", "description": "DocType this format applies to"}, + "html": {"type": "string", "description": "Jinja2 HTML template"}, + "css": {"type": "string", "description": "Optional custom CSS"}, + "standard": {"type": "string", "default": "No", "enum": ["Yes", "No"]}, + "disabled": {"type": "integer", "default": 0}, + "default_print_language": {"type": "string", "default": "en"}, + "print_format_type": { + "type": "string", + "enum": ["Jinja", "JS"], + "default": "Jinja", + }, + }, + }, + ), + Tool( + name="frappe_update_print_format", + description="Update an existing Print Format's HTML, CSS, or properties.", + inputSchema={ + "type": "object", + "required": ["name"], + "properties": { + "name": {"type": "string"}, + "html": {"type": "string"}, + "css": {"type": "string"}, + "disabled": {"type": "integer"}, + "updates": {"type": "object", "description": "Any additional fields to update"}, + }, + }, + ), + Tool( + name="frappe_preview_print_format", + description="Get a rendered HTML preview of a Print Format for a specific document.", + inputSchema={ + "type": "object", + "required": ["doctype", "docname", "print_format"], + "properties": { + "doctype": {"type": "string"}, + "docname": {"type": "string"}, + "print_format": {"type": "string"}, + "letterhead": {"type": "string", "description": "Letterhead name (optional)"}, + }, + }, + ), + ] + + +def handlers() -> dict: + return { + "frappe_list_print_formats": _list_print_formats, + "frappe_get_print_format": _get_print_format, + "frappe_create_print_format": _create_print_format, + "frappe_update_print_format": _update_print_format, + "frappe_preview_print_format": _preview_print_format, + } + + +async def _list_print_formats(args: dict) -> str: + client = FrappeClient() + filters = [] + if doc_type := args.get("doc_type"): + filters.append(["doc_type", "=", doc_type]) + result = await client.get_list( + "Print Format", + fields=["name", "doc_type", "disabled", "standard", "modified"], + filters=filters if filters else None, + limit=args.get("limit", 30), + ) + return json.dumps(result, indent=2) + + +async def _get_print_format(args: dict) -> str: + client = FrappeClient() + result = await client.get_doc("Print Format", args["name"]) + return json.dumps(result, indent=2) + + +async def _create_print_format(args: dict) -> str: + client = FrappeClient() + payload = { + "name": args["name"], + "doc_type": args["doc_type"], + "html": args["html"], + "css": args.get("css", ""), + "standard": args.get("standard", "No"), + "disabled": args.get("disabled", 0), + "default_print_language": args.get("default_print_language", "en"), + "print_format_type": args.get("print_format_type", "Jinja"), + } + result = await client.create_doc("Print Format", payload) + return json.dumps(result, indent=2) + + +async def _update_print_format(args: dict) -> str: + client = FrappeClient() + updates = args.get("updates", {}) + for field in ("html", "css", "disabled"): + if field in args: + updates[field] = args[field] + result = await client.update_doc("Print Format", args["name"], updates) + return json.dumps(result, indent=2) + + +async def _preview_print_format(args: dict) -> str: + client = FrappeClient() + params = { + "doctype": args["doctype"], + "name": args["docname"], + "print_format": args["print_format"], + } + if letterhead := args.get("letterhead"): + params["letterhead"] = letterhead + result = await client.get("/api/method/frappe.www.printview.get_html_and_style", params=params) + return json.dumps(result, indent=2) diff --git a/frappe_mcp/tools/property_setters.py b/frappe_mcp/tools/property_setters.py new file mode 100644 index 0000000..d4a5583 --- /dev/null +++ b/frappe_mcp/tools/property_setters.py @@ -0,0 +1,164 @@ +""" +Property Setter + Customize Form tools. +Property Setters override DocType field properties without touching core — the safe +way to customize standard DocTypes (e.g. Sales Order, Customer). +""" + +import json +from mcp.types import Tool +from frappe_mcp.client.frappe_api import FrappeClient + + +def tools() -> list[Tool]: + return [ + Tool( + name="frappe_list_property_setters", + description="List all Property Setters, optionally filtered by DocType.", + inputSchema={ + "type": "object", + "properties": { + "doc_type": {"type": "string"}, + "field_name": {"type": "string"}, + "property": {"type": "string"}, + "limit": {"type": "integer", "default": 50}, + }, + }, + ), + Tool( + name="frappe_create_property_setter", + description=( + "Override a property of a DocType field (or the DocType itself) without modifying core. " + "Common properties: reqd, hidden, read_only, default, options, label, bold, in_list_view, " + "in_standard_filter, description, depends_on, mandatory_depends_on, read_only_depends_on." + ), + inputSchema={ + "type": "object", + "required": ["doc_type", "property", "value", "property_type"], + "properties": { + "doc_type": {"type": "string", "description": "DocType to customize"}, + "field_name": {"type": "string", "description": "Fieldname to override (empty for DocType-level)"}, + "property": {"type": "string", "description": "Property to override e.g. 'reqd', 'hidden'"}, + "value": {"type": "string", "description": "New value"}, + "property_type": { + "type": "string", + "description": "Data type: Check, Data, Int, Select, Text, etc.", + "default": "Check", + }, + "row_name": {"type": "string", "description": "For child table row overrides"}, + }, + }, + ), + Tool( + name="frappe_remove_property_setter", + description="Remove a Property Setter, restoring the DocType's original property value.", + inputSchema={ + "type": "object", + "required": ["name"], + "properties": { + "name": {"type": "string", "description": "Property Setter document name"}, + }, + }, + ), + Tool( + name="frappe_get_customize_form", + description=( + "Get the full customization state of a DocType — all Property Setters, " + "Custom Fields, and effective field properties merged." + ), + inputSchema={ + "type": "object", + "required": ["doc_type"], + "properties": { + "doc_type": {"type": "string"}, + }, + }, + ), + Tool( + name="frappe_reset_customization", + description=( + "Remove ALL customizations (Property Setters + Custom Fields) from a DocType, " + "restoring it to its original state. Destructive — use with caution." + ), + inputSchema={ + "type": "object", + "required": ["doc_type"], + "properties": { + "doc_type": {"type": "string"}, + }, + }, + ), + ] + + +def handlers() -> dict: + return { + "frappe_list_property_setters": _list_property_setters, + "frappe_create_property_setter": _create_property_setter, + "frappe_remove_property_setter": _remove_property_setter, + "frappe_get_customize_form": _get_customize_form, + "frappe_reset_customization": _reset_customization, + } + + +async def _list_property_setters(args: dict) -> str: + client = FrappeClient() + filters = [] + if dt := args.get("doc_type"): + filters.append(["doc_type", "=", dt]) + if fn := args.get("field_name"): + filters.append(["field_name", "=", fn]) + if prop := args.get("property"): + filters.append(["property", "=", prop]) + result = await client.get_list( + "Property Setter", + fields=["name", "doc_type", "field_name", "property", "value"], + filters=filters if filters else None, + limit=args.get("limit", 50), + ) + return json.dumps(result, indent=2) + + +async def _create_property_setter(args: dict) -> str: + client = FrappeClient() + payload = { + "doc_type": args["doc_type"], + "field_name": args.get("field_name", ""), + "property": args["property"], + "value": args["value"], + "property_type": args.get("property_type", "Check"), + "row_name": args.get("row_name", ""), + "doctype_or_field": "DocField" if args.get("field_name") else "DocType", + } + result = await client.create_doc("Property Setter", payload) + return json.dumps(result, indent=2) + + +async def _remove_property_setter(args: dict) -> str: + client = FrappeClient() + result = await client.delete_doc("Property Setter", args["name"]) + return json.dumps(result, indent=2) + + +async def _get_customize_form(args: dict) -> str: + client = FrappeClient() + result = await client.call_method( + "frappe.desk.form.load.getdoc", + doctype="Customize Form", + name="Customize Form", + ) + # fetch the form for this specific doctype + form_result = await client.call_method( + "frappe.client.get", + doctype="Customize Form", + filters={"doc_type": args["doc_type"]}, + ) + return json.dumps(form_result, indent=2) + + +async def _reset_customization(args: dict) -> str: + client = FrappeClient() + result = await client.call_method( + "frappe.custom.doctype.customize_form.customize_form.reset_customization", + doc_type=args["doc_type"], + ) + return json.dumps(result, indent=2) diff --git a/frappe_mcp/tools/reports.py b/frappe_mcp/tools/reports.py new file mode 100644 index 0000000..d2e0202 --- /dev/null +++ b/frappe_mcp/tools/reports.py @@ -0,0 +1,188 @@ +"""Report CRUD tools — create Query Reports and Script Reports.""" + +import json +from mcp.types import Tool +from frappe_mcp.client.frappe_api import FrappeClient + + +def tools() -> list[Tool]: + return [ + Tool( + name="frappe_list_reports", + description="List Reports, optionally filtered by type or DocType.", + inputSchema={ + "type": "object", + "properties": { + "report_type": { + "type": "string", + "enum": ["Query Report", "Script Report", "Report Builder", "Custom Report"], + }, + "ref_doctype": {"type": "string"}, + "limit": {"type": "integer", "default": 30}, + }, + }, + ), + Tool( + name="frappe_get_report", + description="Get a Report definition including its query or script.", + inputSchema={ + "type": "object", + "required": ["name"], + "properties": {"name": {"type": "string"}}, + }, + ), + Tool( + name="frappe_create_query_report", + description=( + "Create a Query Report using a raw SQL SELECT statement. " + "The query can use %(filter_field)s for parameterized filters." + ), + inputSchema={ + "type": "object", + "required": ["name", "ref_doctype", "query"], + "properties": { + "name": {"type": "string"}, + "ref_doctype": {"type": "string"}, + "query": {"type": "string", "description": "SQL SELECT query"}, + "filters": { + "type": "array", + "items": {"type": "object"}, + "description": "Filter field definitions", + }, + "is_standard": {"type": "string", "enum": ["Yes", "No"], "default": "No"}, + "disabled": {"type": "integer", "default": 0}, + }, + }, + ), + Tool( + name="frappe_create_script_report", + description=( + "Create a Script Report using Python. " + "The script must define `execute(filters)` that returns (columns, data)." + ), + inputSchema={ + "type": "object", + "required": ["name", "ref_doctype", "script"], + "properties": { + "name": {"type": "string"}, + "ref_doctype": {"type": "string"}, + "script": {"type": "string", "description": "Python script with execute(filters) function"}, + "javascript": {"type": "string", "description": "Optional JS for filters/formatting"}, + "filters": { + "type": "array", + "items": {"type": "object"}, + "description": "Filter field definitions", + }, + "is_standard": {"type": "string", "enum": ["Yes", "No"], "default": "No"}, + "disabled": {"type": "integer", "default": 0}, + }, + }, + ), + Tool( + name="frappe_update_report", + description="Update a Report's query, script, or filters.", + inputSchema={ + "type": "object", + "required": ["name"], + "properties": { + "name": {"type": "string"}, + "query": {"type": "string"}, + "script": {"type": "string"}, + "javascript": {"type": "string"}, + "disabled": {"type": "integer"}, + "updates": {"type": "object"}, + }, + }, + ), + Tool( + name="frappe_run_query_report", + description="Execute a report and return its data.", + inputSchema={ + "type": "object", + "required": ["report_name"], + "properties": { + "report_name": {"type": "string"}, + "filters": {"type": "object", "description": "Filter key-value pairs"}, + }, + }, + ), + ] + + +def handlers() -> dict: + return { + "frappe_list_reports": _list_reports, + "frappe_get_report": _get_report, + "frappe_create_query_report": _create_query_report, + "frappe_create_script_report": _create_script_report, + "frappe_update_report": _update_report, + "frappe_run_query_report": _run_query_report, + } + + +async def _list_reports(args: dict) -> str: + client = FrappeClient() + filters = [] + if rt := args.get("report_type"): + filters.append(["report_type", "=", rt]) + if dt := args.get("ref_doctype"): + filters.append(["ref_doctype", "=", dt]) + result = await client.get_list( + "Report", + fields=["name", "report_type", "ref_doctype", "disabled", "is_standard"], + filters=filters if filters else None, + limit=args.get("limit", 30), + ) + return json.dumps(result, indent=2) + +async def _get_report(args: dict) -> str: + client = FrappeClient() + result = await client.get_doc("Report", args["name"]) + return json.dumps(result, indent=2) + +async def _create_query_report(args: dict) -> str: + client = FrappeClient() + payload = { + "report_name": args["name"], + "report_type": "Query Report", + "ref_doctype": args["ref_doctype"], + "query": args["query"], + "filters": args.get("filters", []), + "is_standard": args.get("is_standard", "No"), + "disabled": args.get("disabled", 0), + } + result = await client.create_doc("Report", payload) + return json.dumps(result, indent=2) + +async def _create_script_report(args: dict) -> str: + client = FrappeClient() + payload = { + "report_name": args["name"], + "report_type": "Script Report", + "ref_doctype": args["ref_doctype"], + "script": args["script"], + "javascript": args.get("javascript", ""), + "filters": args.get("filters", []), + "is_standard": args.get("is_standard", "No"), + "disabled": args.get("disabled", 0), + } + result = await client.create_doc("Report", payload) + return json.dumps(result, indent=2) + +async def _update_report(args: dict) -> str: + client = FrappeClient() + updates = args.get("updates", {}) + for field in ("query", "script", "javascript", "disabled"): + if field in args: + updates[field] = args[field] + result = await client.update_doc("Report", args["name"], updates) + return json.dumps(result, indent=2) + +async def _run_query_report(args: dict) -> str: + client = FrappeClient() + result = await client.call_method( + "frappe.desk.query_report.run", + report_name=args["report_name"], + filters=args.get("filters", {}), + ) + return json.dumps(result, indent=2) diff --git a/frappe_mcp/tools/scheduler.py b/frappe_mcp/tools/scheduler.py new file mode 100644 index 0000000..f189ea4 --- /dev/null +++ b/frappe_mcp/tools/scheduler.py @@ -0,0 +1,179 @@ +"""Scheduler and background job tools.""" + +import json +from mcp.types import Tool +from frappe_mcp.client.frappe_api import FrappeClient + + +def tools() -> list[Tool]: + return [ + Tool( + name="frappe_list_scheduled_jobs", + description="List all Scheduled Job Types (cron definitions).", + inputSchema={ + "type": "object", + "properties": { + "stopped": {"type": "integer", "description": "1 = stopped only, 0 = running only"}, + "limit": {"type": "integer", "default": 30}, + }, + }, + ), + Tool( + name="frappe_get_scheduled_job", + description="Get details of a Scheduled Job Type.", + inputSchema={ + "type": "object", + "required": ["name"], + "properties": {"name": {"type": "string"}}, + }, + ), + Tool( + name="frappe_create_scheduled_job", + description=( + "Create a new Scheduled Job Type — a Frappe cron task " + "that calls a whitelisted Python method on a schedule." + ), + inputSchema={ + "type": "object", + "required": ["method", "frequency"], + "properties": { + "method": {"type": "string", "description": "Dotted Python method path e.g. myapp.tasks.daily_sync"}, + "frequency": { + "type": "string", + "enum": [ + "All", "Hourly", "Daily", "Weekly", "Monthly", + "Yearly", "Hourly Long", "Daily Long", "Weekly Long", + "Monthly Long", "Cron", + ], + }, + "cron_format": {"type": "string", "description": "Required if frequency is 'Cron' e.g. '0 2 * * *'"}, + "stopped": {"type": "integer", "default": 0}, + }, + }, + ), + Tool( + name="frappe_toggle_scheduled_job", + description="Start or stop a Scheduled Job Type.", + inputSchema={ + "type": "object", + "required": ["name", "stopped"], + "properties": { + "name": {"type": "string"}, + "stopped": {"type": "integer", "description": "1 to stop, 0 to start"}, + }, + }, + ), + Tool( + name="frappe_trigger_scheduled_job", + description="Manually trigger a scheduled job to run immediately.", + inputSchema={ + "type": "object", + "required": ["job_name"], + "properties": { + "job_name": {"type": "string", "description": "Scheduled Job Type name"}, + }, + }, + ), + Tool( + name="frappe_list_background_jobs", + description="Get the current background job queue status.", + inputSchema={ + "type": "object", + "properties": { + "status": { + "type": "string", + "enum": ["queued", "started", "finished", "failed"], + "description": "Filter by job status", + }, + "limit": {"type": "integer", "default": 20}, + }, + }, + ), + Tool( + name="frappe_get_scheduler_status", + description="Check if the Frappe scheduler is active or paused.", + inputSchema={"type": "object", "properties": {}}, + ), + Tool( + name="frappe_enable_scheduler", + description="Enable or disable the Frappe background job scheduler.", + inputSchema={ + "type": "object", + "required": ["enable"], + "properties": { + "enable": {"type": "boolean", "description": "true to enable, false to disable"}, + }, + }, + ), + ] + + +def handlers() -> dict: + return { + "frappe_list_scheduled_jobs": _list_scheduled_jobs, + "frappe_get_scheduled_job": _get_scheduled_job, + "frappe_create_scheduled_job": _create_scheduled_job, + "frappe_toggle_scheduled_job": _toggle_scheduled_job, + "frappe_trigger_scheduled_job": _trigger_scheduled_job, + "frappe_list_background_jobs": _list_background_jobs, + "frappe_get_scheduler_status": _get_scheduler_status, + "frappe_enable_scheduler": _enable_scheduler, + } + + +async def _list_scheduled_jobs(args: dict) -> str: + client = FrappeClient() + filters = [] + if "stopped" in args: + filters.append(["stopped", "=", args["stopped"]]) + result = await client.get_list( + "Scheduled Job Type", + fields=["name", "method", "frequency", "cron_format", "stopped", "last_execution"], + filters=filters if filters else None, + limit=args.get("limit", 30), + ) + return json.dumps(result, indent=2) + +async def _get_scheduled_job(args: dict) -> str: + client = FrappeClient() + result = await client.get_doc("Scheduled Job Type", args["name"]) + return json.dumps(result, indent=2) + +async def _create_scheduled_job(args: dict) -> str: + client = FrappeClient() + result = await client.create_doc("Scheduled Job Type", args) + return json.dumps(result, indent=2) + +async def _toggle_scheduled_job(args: dict) -> str: + client = FrappeClient() + result = await client.update_doc("Scheduled Job Type", args["name"], {"stopped": args["stopped"]}) + return json.dumps(result, indent=2) + +async def _trigger_scheduled_job(args: dict) -> str: + client = FrappeClient() + result = await client.call_method( + "frappe.core.doctype.scheduled_job_type.scheduled_job_type.run_scheduled_job", + job_type=args["job_name"], + ) + return json.dumps(result, indent=2) + +async def _list_background_jobs(args: dict) -> str: + client = FrappeClient() + result = await client.call_method( + "frappe.utils.background_jobs.get_jobs", + status=args.get("status", ""), + ) + return json.dumps(result, indent=2) + +async def _get_scheduler_status(args: dict) -> str: + client = FrappeClient() + result = await client.call_method("frappe.core.doctype.scheduled_job_type.scheduled_job_type.get_enabled_scheduler_events") + return json.dumps(result, indent=2) + +async def _enable_scheduler(args: dict) -> str: + client = FrappeClient() + method = "frappe.desk.doctype.event.event.get_events" if args["enable"] else "frappe.handler.ping" + method = "frappe.core.doctype.system_settings.system_settings.enable_scheduler" if args["enable"] else \ + "frappe.core.doctype.system_settings.system_settings.disable_scheduler" + result = await client.call_method(method) + return json.dumps(result, indent=2) diff --git a/frappe_mcp/tools/scripts.py b/frappe_mcp/tools/scripts.py new file mode 100644 index 0000000..649c86f --- /dev/null +++ b/frappe_mcp/tools/scripts.py @@ -0,0 +1,174 @@ +"""Server Script and Client Script tools.""" + +import json +from mcp.types import Tool +from frappe_mcp.client.frappe_api import FrappeClient + + +def tools() -> list[Tool]: + return [ + Tool( + name="frappe_create_server_script", + description=( + "Create a Frappe Server Script (Python). " + "script_type options: DocType Event, API, Permission Query, Scheduler Event." + ), + inputSchema={ + "type": "object", + "required": ["name", "script_type", "script"], + "properties": { + "name": {"type": "string", "description": "Unique script name"}, + "script_type": { + "type": "string", + "enum": ["DocType Event", "API", "Permission Query", "Scheduler Event"], + }, + "script": {"type": "string", "description": "Python code"}, + "reference_doctype": {"type": "string", "description": "Required for DocType Event"}, + "doctype_event": { + "type": "string", + "enum": [ + "Before Insert", "After Insert", "Before Save", "After Save", + "Before Submit", "After Submit", "Before Cancel", "After Cancel", + "Before Delete", "After Delete", "Before Naming", "After Naming", + ], + "description": "Required for DocType Event", + }, + "api_method": {"type": "string", "description": "Method name for API type"}, + "allow_guest": {"type": "integer", "default": 0}, + "disabled": {"type": "integer", "default": 0}, + }, + }, + ), + Tool( + name="frappe_update_server_script", + description="Update an existing Server Script.", + inputSchema={ + "type": "object", + "required": ["name"], + "properties": { + "name": {"type": "string"}, + "script": {"type": "string"}, + "disabled": {"type": "integer"}, + "updates": {"type": "object"}, + }, + }, + ), + Tool( + name="frappe_list_server_scripts", + description="List all Server Scripts, optionally filtered by script_type.", + inputSchema={ + "type": "object", + "properties": { + "script_type": {"type": "string"}, + "reference_doctype": {"type": "string"}, + }, + }, + ), + Tool( + name="frappe_create_client_script", + description="Create a Frappe Client Script (JavaScript) for a DocType.", + inputSchema={ + "type": "object", + "required": ["dt", "script"], + "properties": { + "dt": {"type": "string", "description": "DocType this script applies to"}, + "script": {"type": "string", "description": "JavaScript code"}, + "view": { + "type": "string", + "enum": ["Form", "List", "Tree", "Calendar", "Gantt"], + "default": "Form", + }, + "enabled": {"type": "integer", "default": 1}, + }, + }, + ), + Tool( + name="frappe_list_client_scripts", + description="List all Client Scripts, optionally filtered by DocType.", + inputSchema={ + "type": "object", + "properties": { + "dt": {"type": "string"}, + }, + }, + ), + ] + + +def handlers() -> dict: + return { + "frappe_create_server_script": _create_server_script, + "frappe_update_server_script": _update_server_script, + "frappe_list_server_scripts": _list_server_scripts, + "frappe_create_client_script": _create_client_script, + "frappe_list_client_scripts": _list_client_scripts, + } + + +async def _create_server_script(args: dict) -> str: + client = FrappeClient() + payload = { + "name": args["name"], + "script_type": args["script_type"], + "script": args["script"], + "reference_doctype": args.get("reference_doctype", ""), + "doctype_event": args.get("doctype_event", ""), + "api_method": args.get("api_method", ""), + "allow_guest": args.get("allow_guest", 0), + "disabled": args.get("disabled", 0), + } + result = await client.create_doc("Server Script", payload) + return json.dumps(result, indent=2) + + +async def _update_server_script(args: dict) -> str: + client = FrappeClient() + updates = args.get("updates", {}) + if "script" in args: + updates["script"] = args["script"] + if "disabled" in args: + updates["disabled"] = args["disabled"] + result = await client.update_doc("Server Script", args["name"], updates) + return json.dumps(result, indent=2) + + +async def _list_server_scripts(args: dict) -> str: + client = FrappeClient() + filters = [] + if script_type := args.get("script_type"): + filters.append(["script_type", "=", script_type]) + if ref := args.get("reference_doctype"): + filters.append(["reference_doctype", "=", ref]) + result = await client.get_list( + "Server Script", + fields=["name", "script_type", "reference_doctype", "doctype_event", "disabled"], + filters=filters if filters else None, + limit=50, + ) + return json.dumps(result, indent=2) + + +async def _create_client_script(args: dict) -> str: + client = FrappeClient() + payload = { + "dt": args["dt"], + "script": args["script"], + "view": args.get("view", "Form"), + "enabled": args.get("enabled", 1), + } + result = await client.create_doc("Client Script", payload) + return json.dumps(result, indent=2) + + +async def _list_client_scripts(args: dict) -> str: + client = FrappeClient() + filters = [] + if dt := args.get("dt"): + filters.append(["dt", "=", dt]) + result = await client.get_list( + "Client Script", + fields=["name", "dt", "view", "enabled"], + filters=filters if filters else None, + limit=50, + ) + return json.dumps(result, indent=2) diff --git a/frappe_mcp/tools/translations.py b/frappe_mcp/tools/translations.py new file mode 100644 index 0000000..4892c1e --- /dev/null +++ b/frappe_mcp/tools/translations.py @@ -0,0 +1,244 @@ +"""Translation, Assignment Rules, and User Permission Restriction tools.""" + +import json +from mcp.types import Tool +from frappe_mcp.client.frappe_api import FrappeClient + + +def tools() -> list[Tool]: + return [ + # --- Translations --- + Tool( + name="frappe_list_translations", + description="List custom translations for a language.", + inputSchema={ + "type": "object", + "properties": { + "language": {"type": "string", "description": "Language code e.g. 'ar', 'fr', 'de'"}, + "source_text": {"type": "string"}, + "limit": {"type": "integer", "default": 50}, + }, + }, + ), + Tool( + name="frappe_add_translation", + description="Add or update a custom translation string.", + inputSchema={ + "type": "object", + "required": ["language", "source_text", "translated_text"], + "properties": { + "language": {"type": "string"}, + "source_text": {"type": "string"}, + "translated_text": {"type": "string"}, + "context": {"type": "string"}, + }, + }, + ), + Tool( + name="frappe_remove_translation", + description="Remove a custom translation entry.", + inputSchema={ + "type": "object", + "required": ["name"], + "properties": {"name": {"type": "string"}}, + }, + ), + # --- Assignment Rules --- + Tool( + name="frappe_list_assignment_rules", + description="List all Assignment Rules.", + inputSchema={ + "type": "object", + "properties": { + "document_type": {"type": "string"}, + "disabled": {"type": "integer"}, + "limit": {"type": "integer", "default": 20}, + }, + }, + ), + Tool( + name="frappe_create_assignment_rule", + description=( + "Create an Assignment Rule — automatically assigns documents to users " + "based on conditions (load balancing or rule-based)." + ), + inputSchema={ + "type": "object", + "required": ["name", "document_type", "assign_condition"], + "properties": { + "name": {"type": "string"}, + "document_type": {"type": "string"}, + "assign_condition": {"type": "string", "description": "Python expression e.g. doc.status == 'Open'"}, + "unassign_condition": {"type": "string"}, + "close_condition": {"type": "string"}, + "assignment_days": { + "type": "array", + "items": {"type": "object"}, + "description": "Days config for round-robin assignment", + }, + "users": { + "type": "array", + "items": {"type": "object"}, + "description": "List of {user} objects", + }, + "due_date_based_on": {"type": "string"}, + "priority": {"type": "string", "enum": ["Low", "Medium", "High"]}, + "disabled": {"type": "integer", "default": 0}, + }, + }, + ), + # --- User Permissions (Row-Level Security) --- + Tool( + name="frappe_list_user_permissions", + description="List User Permissions — row-level security rules that restrict which documents a user can see.", + inputSchema={ + "type": "object", + "properties": { + "user": {"type": "string"}, + "allow": {"type": "string", "description": "DocType being restricted e.g. 'Company'"}, + "limit": {"type": "integer", "default": 30}, + }, + }, + ), + Tool( + name="frappe_add_user_permission", + description=( + "Add a User Permission — restricts a user to only see documents " + "linked to a specific value. e.g. restrict user to one Company." + ), + inputSchema={ + "type": "object", + "required": ["user", "allow", "for_value"], + "properties": { + "user": {"type": "string"}, + "allow": {"type": "string", "description": "DocType e.g. 'Company'"}, + "for_value": {"type": "string", "description": "Specific document name"}, + "apply_to_all_doctypes": {"type": "integer", "default": 1}, + "applicable_for": {"type": "string", "description": "Restrict to specific DocType if not global"}, + "hide_descendants": {"type": "integer", "default": 0}, + }, + }, + ), + Tool( + name="frappe_remove_user_permission", + description="Remove a User Permission.", + inputSchema={ + "type": "object", + "required": ["name"], + "properties": {"name": {"type": "string"}}, + }, + ), + # --- Role Permission for Page/Report --- + Tool( + name="frappe_set_role_permission", + description="Grant or revoke a role's access to a Page or Report.", + inputSchema={ + "type": "object", + "required": ["doctype", "name", "role", "action"], + "properties": { + "doctype": {"type": "string", "enum": ["Page", "Report"]}, + "name": {"type": "string"}, + "role": {"type": "string"}, + "action": {"type": "string", "enum": ["add", "remove"]}, + }, + }, + ), + ] + + +def handlers() -> dict: + return { + "frappe_list_translations": _list_translations, + "frappe_add_translation": _add_translation, + "frappe_remove_translation": _remove_translation, + "frappe_list_assignment_rules": _list_assignment_rules, + "frappe_create_assignment_rule": _create_assignment_rule, + "frappe_list_user_permissions": _list_user_permissions, + "frappe_add_user_permission": _add_user_permission, + "frappe_remove_user_permission": _remove_user_permission, + "frappe_set_role_permission": _set_role_permission, + } + + +async def _list_translations(args: dict) -> str: + client = FrappeClient() + filters = [] + if lang := args.get("language"): + filters.append(["language", "=", lang]) + if src := args.get("source_text"): + filters.append(["source_text", "like", f"%{src}%"]) + result = await client.get_list( + "Translation", + fields=["name", "language", "source_text", "translated_text", "context"], + filters=filters if filters else None, + limit=args.get("limit", 50), + ) + return json.dumps(result, indent=2) + +async def _add_translation(args: dict) -> str: + client = FrappeClient() + result = await client.create_doc("Translation", args) + return json.dumps(result, indent=2) + +async def _remove_translation(args: dict) -> str: + client = FrappeClient() + result = await client.delete_doc("Translation", args["name"]) + return json.dumps(result, indent=2) + +async def _list_assignment_rules(args: dict) -> str: + client = FrappeClient() + filters = [] + if dt := args.get("document_type"): + filters.append(["document_type", "=", dt]) + if "disabled" in args: + filters.append(["disabled", "=", args["disabled"]]) + result = await client.get_list( + "Assignment Rule", + fields=["name", "document_type", "disabled", "priority"], + filters=filters if filters else None, + limit=args.get("limit", 20), + ) + return json.dumps(result, indent=2) + +async def _create_assignment_rule(args: dict) -> str: + client = FrappeClient() + result = await client.create_doc("Assignment Rule", args) + return json.dumps(result, indent=2) + +async def _list_user_permissions(args: dict) -> str: + client = FrappeClient() + filters = [] + if user := args.get("user"): + filters.append(["user", "=", user]) + if allow := args.get("allow"): + filters.append(["allow", "=", allow]) + result = await client.get_list( + "User Permission", + fields=["name", "user", "allow", "for_value", "applicable_for", "apply_to_all_doctypes"], + filters=filters if filters else None, + limit=args.get("limit", 30), + ) + return json.dumps(result, indent=2) + +async def _add_user_permission(args: dict) -> str: + client = FrappeClient() + result = await client.create_doc("User Permission", args) + return json.dumps(result, indent=2) + +async def _remove_user_permission(args: dict) -> str: + client = FrappeClient() + result = await client.delete_doc("User Permission", args["name"]) + return json.dumps(result, indent=2) + +async def _set_role_permission(args: dict) -> str: + client = FrappeClient() + method = "frappe.desk.doctype.desktop_icon.desktop_icon.set_hidden" + doc = await client.get_doc(args["doctype"], args["name"]) + roles = doc.get("roles", []) + if args["action"] == "add": + if args["role"] not in [r.get("role") for r in roles]: + roles.append({"role": args["role"]}) + else: + roles = [r for r in roles if r.get("role") != args["role"]] + result = await client.update_doc(args["doctype"], args["name"], {"roles": roles}) + return json.dumps(result, indent=2) diff --git a/frappe_mcp/tools/users.py b/frappe_mcp/tools/users.py new file mode 100644 index 0000000..f954451 --- /dev/null +++ b/frappe_mcp/tools/users.py @@ -0,0 +1,187 @@ +"""User, Role, and Permission management tools.""" + +import json +from mcp.types import Tool +from frappe_mcp.client.frappe_api import FrappeClient + + +def tools() -> list[Tool]: + return [ + Tool( + name="frappe_list_users", + description="List Frappe users.", + inputSchema={ + "type": "object", + "properties": { + "enabled": {"type": "integer", "description": "1 for active, 0 for disabled"}, + "limit": {"type": "integer", "default": 20}, + }, + }, + ), + Tool( + name="frappe_get_user", + description="Get a user's details and roles.", + inputSchema={ + "type": "object", + "required": ["email"], + "properties": { + "email": {"type": "string"}, + }, + }, + ), + Tool( + name="frappe_create_user", + description="Create a new Frappe user.", + inputSchema={ + "type": "object", + "required": ["email", "first_name"], + "properties": { + "email": {"type": "string"}, + "first_name": {"type": "string"}, + "last_name": {"type": "string"}, + "send_welcome_email": {"type": "integer", "default": 0}, + "roles": { + "type": "array", + "items": {"type": "string"}, + "description": "List of role names e.g. ['System Manager', 'Sales User']", + }, + }, + }, + ), + Tool( + name="frappe_assign_role", + description="Assign a role to an existing user.", + inputSchema={ + "type": "object", + "required": ["email", "role"], + "properties": { + "email": {"type": "string"}, + "role": {"type": "string"}, + }, + }, + ), + Tool( + name="frappe_remove_role", + description="Remove a role from a user.", + inputSchema={ + "type": "object", + "required": ["email", "role"], + "properties": { + "email": {"type": "string"}, + "role": {"type": "string"}, + }, + }, + ), + Tool( + name="frappe_list_roles", + description="List all available roles in the system.", + inputSchema={"type": "object", "properties": {}}, + ), + Tool( + name="frappe_set_doctype_permission", + description="Set role permissions on a DocType.", + inputSchema={ + "type": "object", + "required": ["doctype", "role"], + "properties": { + "doctype": {"type": "string"}, + "role": {"type": "string"}, + "read": {"type": "integer", "default": 1}, + "write": {"type": "integer", "default": 0}, + "create": {"type": "integer", "default": 0}, + "delete": {"type": "integer", "default": 0}, + "submit": {"type": "integer", "default": 0}, + "cancel": {"type": "integer", "default": 0}, + "amend": {"type": "integer", "default": 0}, + "report": {"type": "integer", "default": 0}, + "export": {"type": "integer", "default": 0}, + "import": {"type": "integer", "default": 0}, + "print": {"type": "integer", "default": 0}, + "email": {"type": "integer", "default": 0}, + }, + }, + ), + ] + + +def handlers() -> dict: + return { + "frappe_list_users": _list_users, + "frappe_get_user": _get_user, + "frappe_create_user": _create_user, + "frappe_assign_role": _assign_role, + "frappe_remove_role": _remove_role, + "frappe_list_roles": _list_roles, + "frappe_set_doctype_permission": _set_doctype_permission, + } + + +async def _list_users(args: dict) -> str: + client = FrappeClient() + filters = [] + if "enabled" in args: + filters.append(["enabled", "=", args["enabled"]]) + result = await client.get_list( + "User", + fields=["name", "full_name", "email", "enabled", "last_login"], + filters=filters if filters else None, + limit=args.get("limit", 20), + ) + return json.dumps(result, indent=2) + + +async def _get_user(args: dict) -> str: + client = FrappeClient() + result = await client.get_doc("User", args["email"]) + return json.dumps(result, indent=2) + + +async def _create_user(args: dict) -> str: + client = FrappeClient() + roles = [{"role": r} for r in args.get("roles", [])] + payload = { + "email": args["email"], + "first_name": args["first_name"], + "last_name": args.get("last_name", ""), + "send_welcome_email": args.get("send_welcome_email", 0), + "roles": roles, + } + result = await client.create_doc("User", payload) + return json.dumps(result, indent=2) + + +async def _assign_role(args: dict) -> str: + client = FrappeClient() + user = await client.get_doc("User", args["email"]) + existing_roles = [r["role"] for r in user.get("roles", [])] + if args["role"] not in existing_roles: + user["roles"].append({"role": args["role"]}) + result = await client.update_doc("User", args["email"], {"roles": user["roles"]}) + return json.dumps(result, indent=2) + return f"User {args['email']} already has role {args['role']}" + + +async def _remove_role(args: dict) -> str: + client = FrappeClient() + user = await client.get_doc("User", args["email"]) + updated_roles = [r for r in user.get("roles", []) if r["role"] != args["role"]] + result = await client.update_doc("User", args["email"], {"roles": updated_roles}) + return json.dumps(result, indent=2) + + +async def _list_roles(args: dict) -> str: + client = FrappeClient() + result = await client.get_list("Role", fields=["name", "disabled"], limit=100) + return json.dumps(result, indent=2) + + +async def _set_doctype_permission(args: dict) -> str: + client = FrappeClient() + payload = {k: v for k, v in args.items()} + result = await client.call_method( + "frappe.client.set_value", + doctype="DocPerm", + name=None, + fieldname=payload, + ) + return json.dumps(result, indent=2) diff --git a/frappe_mcp/tools/webhooks.py b/frappe_mcp/tools/webhooks.py new file mode 100644 index 0000000..e9f2bdd --- /dev/null +++ b/frappe_mcp/tools/webhooks.py @@ -0,0 +1,214 @@ +"""Webhook and API Key management tools.""" + +import json +from mcp.types import Tool +from frappe_mcp.client.frappe_api import FrappeClient + + +def tools() -> list[Tool]: + return [ + Tool( + name="frappe_list_webhooks", + description="List all configured Webhooks.", + inputSchema={ + "type": "object", + "properties": { + "webhook_doctype": {"type": "string"}, + "enabled": {"type": "integer"}, + "limit": {"type": "integer", "default": 20}, + }, + }, + ), + Tool( + name="frappe_get_webhook", + description="Get a Webhook configuration.", + inputSchema={ + "type": "object", + "required": ["name"], + "properties": {"name": {"type": "string"}}, + }, + ), + Tool( + name="frappe_create_webhook", + description="Create a new Webhook triggered by a DocType event.", + inputSchema={ + "type": "object", + "required": ["webhook_doctype", "webhook_docevent", "request_url"], + "properties": { + "webhook_doctype": {"type": "string"}, + "webhook_docevent": { + "type": "string", + "enum": ["after_insert", "on_update", "on_submit", "on_cancel", "on_trash"], + }, + "request_url": {"type": "string"}, + "request_method": {"type": "string", "enum": ["POST", "PUT", "GET"], "default": "POST"}, + "enabled": {"type": "integer", "default": 1}, + "condition": {"type": "string", "description": "Python expression e.g. doc.status == 'Submitted'"}, + "webhook_headers": { + "type": "array", + "items": {"type": "object"}, + "description": "List of {key, value} header objects", + }, + "webhook_data": { + "type": "array", + "items": {"type": "object"}, + "description": "List of {key, fieldname} objects for payload fields", + }, + }, + }, + ), + Tool( + name="frappe_update_webhook", + description="Enable, disable, or update a Webhook.", + inputSchema={ + "type": "object", + "required": ["name"], + "properties": { + "name": {"type": "string"}, + "enabled": {"type": "integer"}, + "updates": {"type": "object"}, + }, + }, + ), + Tool( + name="frappe_delete_webhook", + description="Delete a Webhook.", + inputSchema={ + "type": "object", + "required": ["name"], + "properties": {"name": {"type": "string"}}, + }, + ), + Tool( + name="frappe_list_webhook_logs", + description="Get recent Webhook request logs.", + inputSchema={ + "type": "object", + "properties": { + "webhook": {"type": "string"}, + "limit": {"type": "integer", "default": 20}, + }, + }, + ), + # --- API Keys --- + Tool( + name="frappe_list_api_keys", + description="List API Key records (does not expose secrets).", + inputSchema={ + "type": "object", + "properties": {"limit": {"type": "integer", "default": 20}}, + }, + ), + Tool( + name="frappe_generate_api_key", + description="Generate a new API key + secret for a Frappe user.", + inputSchema={ + "type": "object", + "required": ["user"], + "properties": { + "user": {"type": "string", "description": "User email"}, + }, + }, + ), + Tool( + name="frappe_revoke_api_key", + description="Revoke (delete) API key for a user.", + inputSchema={ + "type": "object", + "required": ["user"], + "properties": { + "user": {"type": "string"}, + }, + }, + ), + ] + + +def handlers() -> dict: + return { + "frappe_list_webhooks": _list_webhooks, + "frappe_get_webhook": _get_webhook, + "frappe_create_webhook": _create_webhook, + "frappe_update_webhook": _update_webhook, + "frappe_delete_webhook": _delete_webhook, + "frappe_list_webhook_logs": _list_webhook_logs, + "frappe_list_api_keys": _list_api_keys, + "frappe_generate_api_key": _generate_api_key, + "frappe_revoke_api_key": _revoke_api_key, + } + + +async def _list_webhooks(args: dict) -> str: + client = FrappeClient() + filters = [] + if dt := args.get("webhook_doctype"): + filters.append(["webhook_doctype", "=", dt]) + if "enabled" in args: + filters.append(["enabled", "=", args["enabled"]]) + result = await client.get_list( + "Webhook", + fields=["name", "webhook_doctype", "webhook_docevent", "request_url", "enabled"], + filters=filters if filters else None, + limit=args.get("limit", 20), + ) + return json.dumps(result, indent=2) + +async def _get_webhook(args: dict) -> str: + client = FrappeClient() + result = await client.get_doc("Webhook", args["name"]) + return json.dumps(result, indent=2) + +async def _create_webhook(args: dict) -> str: + client = FrappeClient() + result = await client.create_doc("Webhook", args) + return json.dumps(result, indent=2) + +async def _update_webhook(args: dict) -> str: + client = FrappeClient() + updates = args.get("updates", {}) + if "enabled" in args: + updates["enabled"] = args["enabled"] + result = await client.update_doc("Webhook", args["name"], updates) + return json.dumps(result, indent=2) + +async def _delete_webhook(args: dict) -> str: + client = FrappeClient() + result = await client.delete_doc("Webhook", args["name"]) + return json.dumps(result, indent=2) + +async def _list_webhook_logs(args: dict) -> str: + client = FrappeClient() + filters = [] + if wh := args.get("webhook"): + filters.append(["webhook", "=", wh]) + result = await client.get_list( + "Webhook Log", + fields=["name", "webhook", "status", "request_headers", "data", "response", "creation"], + filters=filters if filters else None, + limit=args.get("limit", 20), + order_by="creation desc", + ) + return json.dumps(result, indent=2) + +async def _list_api_keys(args: dict) -> str: + client = FrappeClient() + result = await client.get_list( + "User", + fields=["name", "api_key", "modified"], + filters=[["api_key", "!=", ""]], + limit=args.get("limit", 20), + ) + return json.dumps(result, indent=2) + +async def _generate_api_key(args: dict) -> str: + client = FrappeClient() + result = await client.call_method( + "frappe.core.doctype.user.user.generate_keys", + user=args["user"], + ) + return json.dumps(result, indent=2) + +async def _revoke_api_key(args: dict) -> str: + client = FrappeClient() + result = await client.update_doc("User", args["user"], {"api_key": "", "api_secret": ""}) + return json.dumps(result, indent=2) diff --git a/frappe_mcp/tools/workflow_tools.py b/frappe_mcp/tools/workflow_tools.py new file mode 100644 index 0000000..4eca896 --- /dev/null +++ b/frappe_mcp/tools/workflow_tools.py @@ -0,0 +1,215 @@ +""" +Level 5 — Workflow and approval tools. +List workflows, read state, available transitions, execute transitions, approval summary. +""" + +import json +from mcp.types import Tool +from frappe_mcp.client.frappe_api import FrappeClient + + +def tools() -> list[Tool]: + return [ + Tool( + name="frappe_list_workflows", + description="List all Workflows, optionally filtered by DocType.", + inputSchema={ + "type": "object", + "properties": { + "document_type": {"type": "string"}, + "is_active": {"type": "integer", "description": "1 = active only"}, + }, + }, + ), + Tool( + name="frappe_get_workflow", + description="Get full workflow definition: all states and all transitions.", + inputSchema={ + "type": "object", + "required": ["workflow_name"], + "properties": {"workflow_name": {"type": "string"}}, + }, + ), + Tool( + name="frappe_get_workflow_state", + description="Get the current workflow state of a specific document.", + inputSchema={ + "type": "object", + "required": ["doctype", "name"], + "properties": { + "doctype": {"type": "string"}, + "name": {"type": "string"}, + }, + }, + ), + Tool( + name="frappe_list_workflow_transitions", + description=( + "List the workflow transitions available for a document in its current state. " + "Shows what actions the current user can take: Approve, Reject, Send Back, etc." + ), + inputSchema={ + "type": "object", + "required": ["doctype", "name"], + "properties": { + "doctype": {"type": "string"}, + "name": {"type": "string"}, + }, + }, + ), + Tool( + name="frappe_execute_workflow_transition", + description=( + "Perform a workflow transition on a document — e.g. Approve, Reject, Send Back. " + "Use list_workflow_transitions first to see allowed actions." + ), + inputSchema={ + "type": "object", + "required": ["doctype", "name", "action"], + "properties": { + "doctype": {"type": "string"}, + "name": {"type": "string"}, + "action": {"type": "string", "description": "Transition action name e.g. 'Approve'"}, + }, + }, + ), + Tool( + name="frappe_get_approval_summary", + description=( + "Get a summary of pending approvals for a DocType: " + "who needs to approve, current state, and how many documents are waiting." + ), + inputSchema={ + "type": "object", + "required": ["doctype"], + "properties": { + "doctype": {"type": "string"}, + "workflow_state": {"type": "string", "description": "Filter by specific state e.g. 'Pending Approval'"}, + "limit": {"type": "integer", "default": 20}, + }, + }, + ), + Tool( + name="frappe_update_workflow", + description="Enable/disable a workflow or update its properties.", + inputSchema={ + "type": "object", + "required": ["workflow_name"], + "properties": { + "workflow_name": {"type": "string"}, + "is_active": {"type": "integer"}, + "updates": {"type": "object"}, + }, + }, + ), + ] + + +def handlers() -> dict: + return { + "frappe_list_workflows": _list_workflows, + "frappe_get_workflow": _get_workflow, + "frappe_get_workflow_state": _get_workflow_state, + "frappe_list_workflow_transitions": _list_workflow_transitions, + "frappe_execute_workflow_transition": _execute_workflow_transition, + "frappe_get_approval_summary": _get_approval_summary, + "frappe_update_workflow": _update_workflow, + } + + +async def _list_workflows(args: dict) -> str: + client = FrappeClient() + filters = [] + if dt := args.get("document_type"): + filters.append(["document_type", "=", dt]) + if "is_active" in args: + filters.append(["is_active", "=", args["is_active"]]) + result = await client.get_list( + "Workflow", + fields=["name", "document_type", "is_active", "workflow_state_field"], + filters=filters if filters else None, + limit=50, + ) + return json.dumps(result, indent=2) + + +async def _get_workflow(args: dict) -> str: + client = FrappeClient() + result = await client.get_doc("Workflow", args["workflow_name"]) + return json.dumps(result, indent=2) + + +async def _get_workflow_state(args: dict) -> str: + client = FrappeClient() + doc = await client.get_doc(args["doctype"], args["name"]) + workflow_state = doc.get("workflow_state") + workflow_field = "workflow_state" + workflows = await client.get_list( + "Workflow", + fields=["name", "workflow_state_field", "is_active"], + filters=[["document_type", "=", args["doctype"]], ["is_active", "=", 1]], + limit=1, + ) + if isinstance(workflows, list) and workflows: + workflow_field = workflows[0].get("workflow_state_field", "workflow_state") + workflow_state = doc.get(workflow_field) + return json.dumps({ + "doctype": args["doctype"], + "name": args["name"], + "workflow_state_field": workflow_field, + "workflow_state": workflow_state, + "docstatus": doc.get("docstatus", 0), + }, indent=2) + + +async def _list_workflow_transitions(args: dict) -> str: + client = FrappeClient() + result = await client.call_method( + "frappe.model.workflow.get_transitions", + doc={"doctype": args["doctype"], "name": args["name"]}, + ) + return json.dumps(result, indent=2) + + +async def _execute_workflow_transition(args: dict) -> str: + client = FrappeClient() + result = await client.call_method( + "frappe.model.workflow.apply_workflow", + doc={"doctype": args["doctype"], "name": args["name"]}, + action=args["action"], + ) + return json.dumps(result, indent=2) + + +async def _get_approval_summary(args: dict) -> str: + client = FrappeClient() + filters = [] + if ws := args.get("workflow_state"): + filters.append(["workflow_state", "=", ws]) + docs = await client.get_list( + args["doctype"], + fields=["name", "workflow_state", "owner", "modified", "docstatus"], + filters=filters if filters else None, + limit=args.get("limit", 20), + order_by="modified desc", + ) + docs_list = docs if isinstance(docs, list) else [] + count = await client.call_method( + "frappe.client.get_count", + doctype=args["doctype"], + filters=filters if filters else None, + ) + return json.dumps({ + "doctype": args["doctype"], + "total_pending": count, + "documents": docs_list, + }, indent=2) + + +async def _update_workflow(args: dict) -> str: + client = FrappeClient() + updates = args.get("updates", {}) + if "is_active" in args: + updates["is_active"] = args["is_active"] + result = await client.update_doc("Workflow", args["workflow_name"], updates) + return json.dumps(result, indent=2) diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..798d8dd --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,29 @@ +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[project] +name = "frappe-mcp" +version = "0.1.0" +description = "MCP Server for Frappe Framework — remote Docker deployment" +requires-python = ">=3.11" +dependencies = [ + "mcp>=1.0.0", + "httpx>=0.27.0", + "python-dotenv>=1.0.0", + "pydantic>=2.0.0", + "pydantic-settings>=2.0.0", +] + +[project.optional-dependencies] +sse = [ + "starlette>=0.40.0", + "uvicorn>=0.30.0", +] + +[project.scripts] +frappe-mcp = "frappe_mcp.server:main" +frappe-mcp-check = "frappe_mcp.healthcheck:main_health_check" + +[tool.hatch.build.targets.wheel] +packages = ["frappe_mcp"]