├── .gitignore ├── README.md ├── RECONCILIATION_STATUS.md ├── REFACTORING.md ├── claude_desktop_config.json.example ├── direct-auth-test.js ├── docs ├── custom_app_introspection.md ├── static_hints_design.md └── usage_info_enhancement.md ├── examples └── claude-integration.js ├── frappe-server-write-operations.ts ├── package-lock.json ├── package.json ├── src ├── api-client.ts ├── app-introspection.ts ├── auth.ts ├── document-api.ts ├── document-operations.ts ├── errors.ts ├── frappe-api.ts ├── frappe-helpers.ts ├── frappe-instructions.ts ├── index-helpers.ts ├── index.ts ├── schema-api.ts ├── schema-operations.ts ├── server_hints │ ├── customer_hints.json │ └── sales_hints.json └── static-hints.ts ├── test-usage-info.js └── tsconfig.json /.gitignore: -------------------------------------------------------------------------------- 1 | frappe-mcp-server.log 2 | 3 | build/ 4 | node_modules/ 5 | .env 6 | dist/ 7 | .DS_Store 8 | RECONCILIATION_STATUS.md 9 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Frappe MCP Server 2 | 3 | A Model Context Protocol (MCP) server for Frappe Framework that exposes Frappe's functionality to AI assistants through the official REST API, with a focus on document CRUD operations, schema handling, and detailed API instructions. 4 | 5 | ## Overview 6 | 7 | This MCP server allows AI assistants to interact with Frappe applications through a standardized interface using the official Frappe REST API. It provides tools for: 8 | 9 | - Document operations (create, read, update, delete, list) 10 | - Schema and metadata handling 11 | - DocType discovery and exploration 12 | - Detailed API usage instructions and examples 13 | 14 | The server includes comprehensive error handling, validation, and helpful responses to make it easier for AI assistants to work with Frappe. 15 | 16 | ## Installation 17 | 18 | ### Prerequisites 19 | 20 | - Node.js 18 or higher 21 | - A running Frappe instance (version 15 or higher) 22 | - API key and secret from Frappe (**required**) 23 | 24 | ### Setup 25 | 26 | 1. Install via npm: 27 | 28 | ```bash 29 | npm install -g frappe-mcp-server 30 | ``` 31 | 32 | Alternatively, run directly with npx: 33 | 34 | ```bash 35 | npx frappe-mcp-server 36 | ``` 37 | 38 | (no installation needed) 39 | 40 | ## Configuration 41 | 42 | The server is configured using environment variables: 43 | 44 | - `FRAPPE_URL`: The URL of your Frappe instance (default: `http://localhost:8000`) 45 | - `FRAPPE_API_KEY`: Your Frappe API key (**required**) 46 | - `FRAPPE_API_SECRET`: Your Frappe API secret (**required**) 47 | 48 | > **Important**: API key/secret authentication is the only supported authentication method. Both `FRAPPE_API_KEY` and `FRAPPE_API_SECRET` must be provided for the server to function properly. Username/password authentication is not supported. 49 | 50 | ### Authentication 51 | 52 | This MCP server **only supports API key/secret authentication** via the Frappe REST API. Username/password authentication is not supported. 53 | 54 | #### Getting API Credentials 55 | 56 | To get API credentials from your Frappe instance: 57 | 58 | 1. Go to User > API Access > New API Key 59 | 2. Select the user for whom you want to create the key 60 | 3. Click "Generate Keys" 61 | 4. Copy the API Key and API Secret 62 | 63 | #### Authentication Troubleshooting 64 | 65 | If you encounter authentication errors: 66 | 67 | 1. Verify that both `FRAPPE_API_KEY` and `FRAPPE_API_SECRET` environment variables are set correctly 68 | 2. Ensure the API key is active and not expired in your Frappe instance 69 | 3. Check that the user associated with the API key has the necessary permissions 70 | 4. Verify the Frappe URL is correct and accessible 71 | 72 | The server provides detailed error messages to help diagnose authentication issues. 73 | 74 | ## Usage 75 | 76 | ### Starting the Server 77 | 78 | ```bash 79 | npx frappe-mcp-server 80 | ``` 81 | 82 | Or with environment variables: 83 | 84 | ```bash 85 | FRAPPE_URL=https://your-frappe-instance.com FRAPPE_API_KEY=your_api_key FRAPPE_API_SECRET=your_api_secret npx frappe-mcp-server 86 | ``` 87 | 88 | ### Integrating with AI Assistants 89 | 90 | To use this MCP server with an AI assistant, you need to configure the assistant to connect to this server. The exact configuration depends on the AI assistant platform you're using. 91 | 92 | For Claude, add the following to your MCP settings configuration file: 93 | 94 | ```json 95 | { 96 | "mcpServers": { 97 | "frappe": { 98 | "command": "npx", 99 | "args": ["frappe-mcp-server"], // Assumes frappe-mcp-server is in MCP server path 100 | "env": { 101 | "FRAPPE_URL": "https://your-frappe-instance.com", 102 | "FRAPPE_API_KEY": "your_api_key", // REQUIRED 103 | "FRAPPE_API_SECRET": "your_api_secret" // REQUIRED 104 | }, 105 | "disabled": false, 106 | "alwaysAllow": [] 107 | } 108 | } 109 | } 110 | ``` 111 | 112 | > **Note**: Both `FRAPPE_API_KEY` and `FRAPPE_API_SECRET` environment variables are required. The server will start without them but most operations will fail with authentication errors. 113 | 114 | ## Available Tools 115 | 116 | ### Document Operations 117 | 118 | - `create_document`: Create a new document in Frappe 119 | - `get_document`: Retrieve a document from Frappe 120 | - `update_document`: Update an existing document in Frappe 121 | - `delete_document`: Delete a document from Frappe 122 | - `list_documents`: List documents from Frappe with filters 123 | 124 | ### Schema Operations 125 | 126 | - `get_doctype_schema`: Get the complete schema for a DocType including field definitions, validations, and linked DocTypes 127 | - `get_field_options`: Get available options for a Link or Select field 128 | - `get_frappe_usage_info`: Get combined information about a DocType or workflow, including schema metadata, static hints, and app-provided usage guidance 129 | 130 | ### Helper Tools 131 | 132 | - `find_doctypes`: Find DocTypes in the system matching a search term 133 | - `get_module_list`: Get a list of all modules in the system 134 | - `get_doctypes_in_module`: Get a list of DocTypes in a specific module 135 | - `check_doctype_exists`: Check if a DocType exists in the system 136 | - `check_document_exists`: Check if a document exists 137 | - `get_document_count`: Get a count of documents matching filters 138 | - `get_naming_info`: Get the naming series information for a DocType 139 | - `get_required_fields`: Get a list of required fields for a DocType 140 | - `get_api_instructions`: Get detailed instructions for using the Frappe API 141 | 142 | ## Available Resources 143 | 144 | ### Schema Resources 145 | 146 | - `schema://{doctype}`: Schema information for a DocType 147 | - `schema://{doctype}/{fieldname}/options`: Available options for a Link or Select field 148 | - `schema://modules`: List of all modules in the system 149 | - `schema://doctypes`: List of all DocTypes in the system 150 | 151 | ## Features 152 | 153 | ### Usage Information Enhancement 154 | 155 | The server provides comprehensive usage information by combining three sources: 156 | 157 | 1. **Frappe Metadata**: Schema information retrieved directly from the Frappe API 158 | 2. **Static Hints**: Supplementary context stored in JSON files within the `static_hints/` directory 159 | 3. **Custom App Introspection**: Usage instructions provided directly by custom Frappe apps 160 | 161 | This enhancement enables AI assistants to better understand Frappe modules, making them more effective at assisting users with Frappe-based applications. 162 | 163 | For more details, see [Usage Information Enhancement](docs/usage_info_enhancement.md). 164 | 165 | ## Examples 166 | 167 | ### Creating a Document 168 | 169 | ```javascript 170 | // Example of using the create_document tool 171 | const result = await useToolWithMcp("frappe", "create_document", { 172 | doctype: "Customer", 173 | values: { 174 | customer_name: "John Doe", 175 | customer_type: "Individual", 176 | customer_group: "All Customer Groups", 177 | territory: "All Territories", 178 | }, 179 | }); 180 | ``` 181 | 182 | ### Getting a Document 183 | 184 | ```javascript 185 | // Example of using the get_document tool 186 | const customer = await useToolWithMcp("frappe", "get_document", { 187 | doctype: "Customer", 188 | name: "CUST-00001", 189 | fields: ["customer_name", "customer_type", "email_id"], // Optional: specific fields 190 | }); 191 | ``` 192 | 193 | ### Listing Documents with Filters 194 | 195 | ```javascript 196 | // Example of using the list_documents tool with filters 197 | const customers = await useToolWithMcp("frappe", "list_documents", { 198 | doctype: "Customer", 199 | filters: { 200 | customer_type: "Individual", 201 | territory: "United States", 202 | }, 203 | fields: ["name", "customer_name", "email_id"], 204 | limit: 10, 205 | order_by: "creation desc", 206 | }); 207 | ``` 208 | 209 | ### Finding DocTypes 210 | 211 | ```javascript 212 | // Example of using the find_doctypes tool 213 | const salesDocTypes = await useToolWithMcp("frappe", "find_doctypes", { 214 | search_term: "Sales", 215 | module: "Selling", 216 | is_table: false, 217 | }); 218 | ``` 219 | 220 | ### Getting Required Fields 221 | 222 | ```javascript 223 | // Example of using the get_required_fields tool 224 | const requiredFields = await useToolWithMcp("frappe", "get_required_fields", { 225 | doctype: "Sales Order", 226 | }); 227 | ``` 228 | 229 | ### Getting API Instructions 230 | 231 | ```javascript 232 | // Example of using the get_api_instructions tool 233 | const instructions = await useToolWithMcp("frappe", "get_api_instructions", { 234 | category: "DOCUMENT_OPERATIONS", 235 | operation: "CREATE", 236 | }); 237 | ``` 238 | 239 | ### Getting Usage Information 240 | 241 | ```javascript 242 | // Example of using the get_frappe_usage_info tool 243 | const salesOrderInfo = await useToolWithMcp("frappe", "get_frappe_usage_info", { 244 | doctype: "Sales Order", 245 | }); 246 | 247 | // Example of getting workflow information 248 | const workflowInfo = await useToolWithMcp("frappe", "get_frappe_usage_info", { 249 | workflow: "Quote to Sales Order Conversion", 250 | }); 251 | ``` 252 | 253 | ## Error Handling 254 | 255 | The server provides detailed error messages with context to help diagnose issues: 256 | 257 | - Missing required parameters 258 | - Invalid field values 259 | - Permission errors 260 | - Network issues 261 | - Server errors 262 | 263 | Each error includes: 264 | 265 | - A descriptive message 266 | - HTTP status code (when applicable) 267 | - Endpoint information 268 | - Additional details from the Frappe server 269 | 270 | ## Best Practices 271 | 272 | 1. **Check DocType Schema First**: Before creating or updating documents, get the schema to understand required fields and validations. 273 | 274 | 2. **Use Pagination**: When listing documents, use `limit` and `limit_start` parameters to paginate results. 275 | 276 | 3. **Specify Fields**: Only request the fields you need to improve performance. 277 | 278 | 4. **Validate Before Creating**: Use `get_required_fields` to ensure you have all required fields before creating a document. 279 | 280 | 5. **Check Existence**: Use `check_document_exists` before updating or deleting to ensure the document exists. 281 | 282 | ## License 283 | 284 | ISC 285 | -------------------------------------------------------------------------------- /RECONCILIATION_STATUS.md: -------------------------------------------------------------------------------- 1 | # Chase Business Checking Account Reconciliation Status 2 | 3 | ## Overview 4 | - **Account**: Chase Business Checking 5 | - **Current Progress**: Working on remaining 2025 transactions (Jan-May) 6 | - **Data Source**: All YTD transactions uploaded to Bank Transactions 7 | - **Next Focus**: Remaining unreconciled GUSTO transactions 8 | 9 | ## Reconciliation Process Guidelines 10 | 1. **Reference 2024 Transactions**: When in doubt about how to process a journal entry for a specific payee, check 2024 transactions for precedent. 11 | 2. **Double-Check All Numbers**: Verify all amounts before finalizing entries. 12 | 3. **Document Decisions**: Record reasoning for any non-standard categorizations. 13 | 14 | ## Current Status 15 | - **Completed**: 16 | - January 9 GUSTO transaction (ACC-BTN-2025-00381) - Reconciled with Journal Entry ACC-JV-2025-00014 17 | - March 17 SBA EIDL LOAN transaction (ACC-BTN-2025-00346) - Reconciled with Journal Entry ACC-JV-2025-00015 18 | - SBA EIDL Loan Payment ACC-BTN-2025-00375 (Jan 17, 2025, $86.10) - Reconciled with Journal Entry ACC-JV-2025-00057. Accounting: Dr: "SBA EIDL Loan - AR", Cr: Bank "Chase Business Checking" (Cost Center: "000 - General - AR"). Status: Reconciled. 19 | - SBA EIDL Loan Payment ACC-BTN-2025-00363 (Feb 18, 2025, $86.10) - Reconciled with Journal Entry ACC-JV-2025-00058. Accounting: Dr: "SBA EIDL Loan - AR", Cr: Bank "Chase Business Checking" (Cost Center: "000 - General - AR"). Status: Reconciled. 20 | - March 28 GUSTO TAX transaction (ACC-BTN-2025-00345) - Reconciled with Journal Entry ACC-JV-2025-00018 21 | - March 28 GUSTO NET transaction (ACC-BTN-2025-00344) - Reconciled with Payment Entry ACC-PAY-2025-00070 22 | - April 10 T-Mobile transaction (ACC-BTN-2025-00339) - Reconciled with Journal Entry ACC-JV-2025-00016 23 | - May 2 GUSTO transaction (ACC-BTN-2025-00330) - Reconciled with Payment Entry ACC-PAY-2025-00069 24 | - May 5 GUSTO transaction (ACC-BTN-2025-00328) - Reconciled with Payment Entry ACC-PAY-2025-00068 25 | - May 5 Service Charges transaction (ACC-BTN-2025-00329) - Reconciled with Journal Entry ACC-JV-2025-00017 26 | - May 15 GUSTO NET transaction (ACC-BTN-2025-00386) - Reconciled with Payment Entry ACC-PAY-2025-00071 27 | - May 15 GUSTO TAX transaction (ACC-BTN-2025-00387) - Reconciled with Journal Entry ACC-JV-2025-00019 28 | - January 4, 2024 GUSTO transaction (ACC-BTN-2024-00300) - Reconciled with Journal Entry ACC-JV-2025-00020 29 | - January 4, 2024 GUSTO transaction (ACC-BTN-2024-00512) - Reconciled with Journal Entry ACC-JV-2025-00021 30 | - April 2, 2024 GUSTO transaction (ACC-BTN-2024-00623) - Reconciled with Journal Entry ACC-JV-2025-00022 31 | - March 5, 2024 GUSTO transaction (ACC-BTN-2024-00633) - Reconciled with Journal Entry ACC-JV-2025-00023 32 | - March 4, 2024 GUSTO NET transaction (ACC-BTN-2024-00635) - Reconciled with Payment Entry ACC-PAY-2025-00074 33 | - March 4, 2024 GUSTO TAX transaction (ACC-BTN-2024-00636) - Reconciled with Journal Entry ACC-JV-2025-00024 34 | - March 4, 2024 GUSTO NET transaction (ACC-BTN-2024-00637) - Reconciled with Payment Entry ACC-PAY-2025-00073 35 | - March 4, 2024 GUSTO NET transaction (ACC-BTN-2024-00638) - Reconciled with Payment Entry ACC-PAY-2025-00075 36 | - March 4, 2024 GUSTO TAX transaction (ACC-BTN-2024-00639) - Reconciled with Journal Entry ACC-JV-2025-00025 37 | - March 4, 2024 GUSTO TAX transaction (ACC-BTN-2024-00640) - Reconciled with Journal Entry ACC-JV-2025-00026 38 | - April 5, 2024 GUSTO NET transaction (ACC-BTN-2024-00616) - Reconciled with Payment Entry ACC-PAY-2025-00076 39 | - April 5, 2024 GUSTO TAX transaction (ACC-BTN-2024-00617) - Reconciled with Journal Entry ACC-JV-2025-00027 40 | - May 3, 2024 GUSTO FEE transaction (ACC-BTN-2024-00606) - Reconciled with Payment Entry ACC-PAY-2025-00077 41 | - May 3, 2024 GUSTO TAX transaction (ACC-BTN-2024-00604) - Reconciled with Journal Entry ACC-JV-2025-00028 42 | - May 3, 2024 GUSTO NET transaction (ACC-BTN-2024-00603) - Reconciled with Payment Entry ACC-PAY-2025-00078 43 | - May 14, 2024 GUSTO NET transaction (ACC-BTN-2024-00592) - Reconciled with Payment Entry ACC-PAY-2025-00079 44 | - May 14, 2024 GUSTO TAX transaction (ACC-BTN-2024-00593) - Reconciled with Journal Entry ACC-JV-2025-00029 45 | - Bank Service Charge ACC-BTN-2025-00371 ($32.50, Jan 2025) - Reconciled with Journal Entry ACC-JV-2025-00053 (Cost Center: 000 - General - AR) 46 | - Bank Service Charge ACC-BTN-2025-00356 ($134.50, Feb 2025) - Reconciled with Journal Entry ACC-JV-2025-00055 (Cost Center: 000 - General - AR) 47 | - T-Mobile Payment ACC-BTN-2025-00367 ($223.20, Feb 2025) - Reconciled with Journal Entry ACC-JV-2025-00054 (Cost Center: 000 - General - AR) 48 | - T-Mobile Payment ACC-BTN-2025-00349 ($223.20, Mar 2025) - Reconciled with Journal Entry ACC-JV-2025-00056 (Cost Center: 000 - General - AR) 49 | - Owner Draw ACC-BTN-2025-00359 (March/April 2025) - Reconciled with Journal Entry ACC-JV-2025-00059. Status: Reconciled. 50 | - Rent Payment ACC-BTN-2025-00353 (March/April 2025) - Reconciled with Journal Entry ACC-JV-2025-00060. Status: Reconciled. 51 | - Bank Service Charge ACC-BTN-2025-00343 (March/April 2025) - Reconciled with Journal Entry ACC-JV-2025-00061. Status: Reconciled. 52 | - Owner Draw ACC-BTN-2025-00342 (March/April 2025) - Reconciled with Journal Entry ACC-JV-2025-00062. Status: Reconciled. 53 | - Deposit (May 2025): Bank Transaction `ACC-BTN-2025-00334` ($8000) reconciled with Journal Entry `ACC-JV-2025-00063`. Status: Reconciled. 54 | - Zelle Payment (May 2025): Bank Transaction `ACC-BTN-2025-00335` ($1500) to Thomas Everitt reconciled with Journal Entry `ACC-JV-2025-00064`. Status: Reconciled. 55 | - Owner Draw (May 2025): Bank Transaction `ACC-BTN-2025-00327` ($500) reconciled with Journal Entry `ACC-JV-2025-00065`. Status: Reconciled. 56 | - **May 15, 2025: GUSTO Jan-May 2025 Reconciliation** 57 | - Successfully reconciled 14 GUSTO bank transactions (Jan-May 2025). 58 | - Corresponding Journal Entries: ACC-JV-2025-00030 through ACC-JV-2025-00043. 59 | - All 14 Bank Transactions are now marked as 'Reconciled' in Frappe. 60 | 61 | ### Chase Business Checking - 2025 Unreconciled Transactions 62 | 63 | * **Date:** 2025-02-03 64 | * **Description/Payee:** Zelle payment to Thomas Everitt 23583653828 (Thomas Everitt) 65 | * **Amount:** -500.00 USD 66 | * **Transaction ID:** ACC-BTN-2025-00374 67 | * **Date:** 2025-02-10 68 | * **Description/Payee:** ORIG CO NAME:UNITEDHEALTHONE ORIG ID:9005900018 DESC DATE:250 69 | * **Amount:** -554.34 USD 70 | * **Transaction ID:** ACC-BTN-2025-00366 71 | * **Date:** 2025-02-14 72 | * **Description/Payee:** Online Transfer from CHK ...9950 transaction#: 23725803465 73 | * **Amount:** 200.00 USD 74 | * **Transaction ID:** ACC-BTN-2025-00364 75 | * **Date:** 2025-02-14 76 | * **Description/Payee:** ORIG CO NAME:AMERICAN EXPRESS ORIG ID:9493560001 DESC DATE:250 (American Express) 77 | * **Amount:** -684.54 USD 78 | * **Transaction ID:** ACC-BTN-2025-00365 79 | * **Date:** 2025-02-25 80 | * **Description/Payee:** Online Transfer from SAV ...5184 transaction#: 23846243431 81 | * **Amount:** 5000.00 USD 82 | * **Transaction ID:** ACC-BTN-2025-00362 83 | * **Date:** 2025-02-26 84 | * **Description/Payee:** Online ACH Payment 11163377261 T o Maria (_#########4888) 85 | * **Amount:** -2000.00 USD 86 | * **Transaction ID:** ACC-BTN-2025-00361 87 | * **Date:** 2025-02-28 88 | * **Description/Payee:** Zelle payment to Grace Gionet 23 889282418 89 | * **Amount:** -248.43 USD 90 | * **Transaction ID:** ACC-BTN-2025-00360 91 | * **Date:** 2025-03-06 92 | * **Description/Payee:** Online Transfer from SAV ...5184 transaction#: 23964364553 93 | * **Amount:** 16000.00 USD 94 | * **Transaction ID:** ACC-BTN-2025-00352 95 | * **Date:** 2025-03-06 96 | * **Description/Payee:** Online Transfer to CHK ...9950 t ransaction#: 23964383885 97 | * **Amount:** -500.00 USD 98 | * **Transaction ID:** ACC-BTN-2025-00354 99 | * **Date:** 2025-03-10 100 | * **Description/Payee:** ORIG CO NAME:UNITEDHEALTHONE ORIG ID:9005900018 DESC DATE:250 101 | * **Amount:** -554.34 USD 102 | * **Transaction ID:** ACC-BTN-2025-00348 103 | * **Date:** 2025-03-17 104 | * **Description/Payee:** ORIG CO NAME:AMERICAN EXPRESS ORIG ID:9493560001 DESC DATE:250 (American Express) 105 | * **Amount:** -815.46 USD 106 | * **Transaction ID:** ACC-BTN-2025-00347 107 | * **Date:** 2025-04-07 108 | * **Description/Payee:** ORIG CO NAME:CHASE CREDIT CRD ORIG ID:4760039224 DESC DATE:250 109 | * **Amount:** -29.95 USD 110 | * **Transaction ID:** ACC-BTN-2025-00340 111 | * **Date:** 2025-04-15 112 | * **Description/Payee:** Online Payment 24424324997 To Gr egory Law Firm, P.L. 113 | * **Amount:** -300.00 USD 114 | * **Transaction ID:** ACC-BTN-2025-00338 115 | * **Date:** 2025-04-17 116 | * **Description/Payee:** Online Transfer from CHK ...9950 transaction#: 24444355862 117 | * **Amount:** 1000.00 USD 118 | * **Transaction ID:** ACC-BTN-2025-00336 119 | * **Date:** 2025-04-17 120 | * **Description/Payee:** ORIG CO NAME:AMERICAN EXPRESS ORIG ID:9493560001 DESC DATE:250 (American Express) 121 | * **Amount:** -1506.58 USD 122 | * **Transaction ID:** ACC-BTN-2025-00337 123 | - **Pending**: 124 | - 8+ unreconciled GUSTO transactions from December 2024 125 | - **Note**: December 2024 GUSTO transactions could not be found in the available data files (Chase4336_Activity_20250515.CSV and Chase4336_Activity_20250515.QBO). 126 | 127 | ## Transaction Types & Treatment Patterns 128 | Based on analysis of previously reconciled transactions: 129 | 130 | ### GUSTO Transactions 131 | - **Transaction IDs**: Usually contain "ORIG CO NAME:GUSTO ORIG ID:9138864001" or "ORIG CO NAME:GUSTO ORIG ID:9138864007" 132 | - **Treatment for Fee Transactions**: Create Payment Entry with Supplier "Gusto" 133 | - **Treatment for Tax Transactions**: Create Journal Entry to "Salary - AR" 134 | - **Treatment for Net Payments**: Create Payment Entry with Supplier "Gusto" 135 | - **Examples**: 136 | - ACC-BTN-2025-00328 → Payment Entry ACC-PAY-2025-00068 137 | - ACC-BTN-2025-00381 → Journal Entry ACC-JV-2025-00014 138 | 139 | ### Transfer Transactions 140 | - **Transaction IDs**: Usually contain "Online Transfer" or "BOOK TRANSFER" 141 | - **Treatment**: Create Journal Entry (Bank Entry) 142 | - **For Withdrawals to Personal**: Debit "Owner Drawings - AR", Credit "Chase Business Checking" 143 | 144 | ### SBA EIDL Loan Payments 145 | - **Transaction IDs**: Usually contain "ORIG CO NAME:SBA EIDL LOAN ORIG ID:7300000118" 146 | - **Treatment**: Create Journal Entry (Bank Entry) 147 | - **Accounts**: Debit "SBA EIDL Loan - AR", Credit "Chase Business Checking" 148 | - **Party**: "SBA EIDL Loan Provider" 149 | - **Example**: ACC-BTN-2025-00346 → Journal Entry ACC-JV-2025-00015 150 | 151 | ### T-Mobile Payments 152 | - **Transaction IDs**: Usually contain "ORIG CO NAME:T-MOBILE ORIG ID:0000450304" 153 | - **Treatment**: Create Journal Entry (Bank Entry) 154 | - **Accounts**: Debit "Telephone and Internet Expenses - AR", Credit "Chase Business Checking" 155 | - **Example**: ACC-BTN-2025-00339 → Journal Entry ACC-JV-2025-00016 156 | 157 | ### Bank Service Charges 158 | - **Transaction IDs**: Usually contain "SERVICE CHARGES FOR THE MONTH OF" 159 | - **Treatment**: Create Journal Entry (Bank Entry) 160 | - **Accounts**: Debit "Bank Fees and Charges - AR", Credit "Chase Business Checking" 161 | - **Example**: ACC-BTN-2025-00329 → Journal Entry ACC-JV-2025-00017 162 | 163 | ## Next Steps and Recommendations 164 | 1. Reconcile the remaining unreconciled GUSTO transactions from December 2024 in chronological order 165 | 2. Follow established patterns for transaction types: 166 | - GUSTO fee transactions → Payment Entries with supplier "Gusto" 167 | - GUSTO payroll tax transactions → Journal Entries to "Salary - AR" 168 | - GUSTO net payments → Payment Entries with supplier "Gusto" 169 | - T-Mobile payments → Journal Entries to "Telephone and Internet Expenses - AR" 170 | - Bank service charges → Journal Entries to "Bank Fees and Charges - AR" 171 | - SBA EIDL loan payments → Journal Entries to "SBA EIDL Loan - AR" 172 | - Transfers to personal → Journal Entries to "Owner Drawings - AR" 173 | 3. Update this document as reconciliation progresses 174 | 175 | *Last Updated: May 15, 2025* 176 | 177 | ## Bank Account Names for Reconciliation: 178 | - Chase Business Checking (Correct name to be used for querying Bank Transactions) 179 | -------------------------------------------------------------------------------- /REFACTORING.md: -------------------------------------------------------------------------------- 1 | # Frappe MCP Server Refactoring 2 | 3 | ## Overview 4 | 5 | This refactoring improves the structure and error handling of the Frappe MCP Server codebase. The main goals were: 6 | 7 | 1. Break up the large `frappe-api.ts` file into smaller, more manageable modules 8 | 2. Improve error handling, especially for authentication errors 9 | 3. Make the code more maintainable and easier to understand 10 | 11 | ## File Structure Changes 12 | 13 | The original `frappe-api.ts` file (1186 lines) has been split into the following modules: 14 | 15 | - `errors.ts` - Contains the FrappeApiError class and error handling logic 16 | - `api-client.ts` - Contains the Frappe API client setup 17 | - `auth.ts` - Contains authentication-related functions 18 | - `document-api.ts` - Contains document operations 19 | - `schema-api.ts` - Contains schema operations 20 | - `frappe-api.ts` - Now just re-exports from the other modules 21 | 22 | ## Error Handling Improvements 23 | 24 | The error handling has been improved to better detect and report authentication errors: 25 | 26 | - Better detection of invalid API key/secret errors 27 | - Better handling of connection errors that might be related to authentication 28 | - More detailed error messages and context 29 | - Improved error reporting in the health check function 30 | 31 | ## Testing 32 | 33 | A test script (`test-auth-error.js`) has been created to verify that the error handling works correctly with invalid credentials. 34 | 35 | ## Future Improvements 36 | 37 | Some potential future improvements: 38 | 39 | 1. Add more comprehensive error handling for other types of errors 40 | 2. Add more unit tests for different error scenarios 41 | 3. Improve the documentation of the API 42 | 4. Add more logging to help with debugging 43 | -------------------------------------------------------------------------------- /claude_desktop_config.json.example: -------------------------------------------------------------------------------- 1 | { 2 | "mcpServers": { 3 | "frappe": { 4 | "command": "npx", 5 | "args": [ 6 | "frappe-mcp-server" 7 | ], 8 | "env": { 9 | "FRAPPE_URL": "http://localhost:8000", 10 | "FRAPPE_API_KEY": "your_api_key_here", 11 | "FRAPPE_API_SECRET": "your_api_secret_here" 12 | }, 13 | "disabled": false, 14 | "alwaysAllow": [] 15 | } 16 | } 17 | } -------------------------------------------------------------------------------- /direct-auth-test.js: -------------------------------------------------------------------------------- 1 | import axios from 'axios'; 2 | 3 | // Function to make a direct API call with invalid credentials 4 | async function testInvalidAuth() { 5 | console.log('Testing direct API call with invalid credentials...'); 6 | 7 | try { 8 | const response = await axios({ 9 | method: 'get', 10 | url: 'http://localhost:8000/api/method/frappe.auth.get_logged_user', 11 | headers: { 12 | 'Authorization': 'token invalid_key:invalid_secret', 13 | 'Accept': 'application/json' 14 | } 15 | }); 16 | 17 | console.log('Response status:', response.status); 18 | console.log('Response data:', JSON.stringify(response.data, null, 2)); 19 | } catch (error) { 20 | console.log('Error caught:'); 21 | 22 | if (error.response) { 23 | // The request was made and the server responded with a status code 24 | // that falls out of the range of 2xx 25 | console.log('Status:', error.response.status); 26 | console.log('Status Text:', error.response.statusText); 27 | console.log('Headers:', JSON.stringify(error.response.headers, null, 2)); 28 | console.log('Data:', JSON.stringify(error.response.data, null, 2)); 29 | 30 | // Analyze the error data 31 | const data = error.response.data; 32 | if (data) { 33 | console.log('\n--- FRAPPE ERROR DETAILS ---'); 34 | if (data.exc_type) console.log('Exception type:', data.exc_type); 35 | if (data.exception) console.log('Exception:', data.exception); 36 | if (data._server_messages) console.log('Server messages:', data._server_messages); 37 | if (data.message) console.log('Message:', data.message); 38 | } 39 | } else if (error.request) { 40 | // The request was made but no response was received 41 | console.log('No response received. Request:', error.request); 42 | } else { 43 | // Something happened in setting up the request that triggered an Error 44 | console.log('Error setting up request:', error.message); 45 | } 46 | } 47 | } 48 | 49 | // Run the test 50 | testInvalidAuth().catch(err => { 51 | console.error('Unexpected error:', err); 52 | }); -------------------------------------------------------------------------------- /docs/custom_app_introspection.md: -------------------------------------------------------------------------------- 1 | # Custom App Introspection for frappe-mcp-server 2 | 3 | **Version:** 1.0 4 | **Date:** 2025-04-18 5 | 6 | ## 1. Introduction 7 | 8 | This document describes the custom app introspection mechanism for `frappe-mcp-server`. This feature allows custom Frappe apps to provide their own usage instructions directly to the MCP server, enabling app developers to include LLM-friendly instructions within their apps rather than requiring updates to the static hints in `frappe-mcp-server`. 9 | 10 | ## 2. Overview 11 | 12 | The custom app introspection mechanism works as follows: 13 | 14 | 1. When the `get_frappe_usage_info` tool is called with a DocType name, the server checks if the DocType belongs to a custom app. 15 | 2. If it does, the server attempts to call a standardized API endpoint in the app to retrieve usage instructions. 16 | 3. If the app implements this endpoint, the returned instructions are combined with static hints and schema metadata. 17 | 4. If the app doesn't implement the endpoint, the server falls back to static hints and schema metadata. 18 | 19 | ## 3. Implementing the API in Custom Apps 20 | 21 | To provide custom usage instructions for your Frappe app, follow these steps: 22 | 23 | ### 3.1. Create the API File 24 | 25 | Create a file named `api_usage.py` in your app's Python package directory. For example: 26 | 27 | ``` 28 | your_app/ 29 | your_app/ 30 | api_usage.py # Create this file 31 | ``` 32 | 33 | ### 3.2. Implement the API Endpoint 34 | 35 | In `api_usage.py`, implement a whitelisted method called `get_usage_instructions` that accepts an optional `doctype` parameter: 36 | 37 | ```python 38 | import frappe 39 | 40 | @frappe.whitelist() 41 | def get_usage_instructions(doctype=None): 42 | """ 43 | Returns usage instructions for your app or a specific DocType. 44 | 45 | Args: 46 | doctype (str, optional): The DocType name to get instructions for. 47 | If not provided, returns app-level instructions. 48 | 49 | Returns: 50 | dict: A dictionary containing usage instructions 51 | """ 52 | if not doctype: 53 | # Return app-level instructions 54 | return { 55 | "app_name": "Your App Name", 56 | "app_description": "Description of your app's purpose and functionality", 57 | "main_workflows": [ 58 | { 59 | "name": "Workflow Name", 60 | "description": "Description of the workflow", 61 | "steps": [ 62 | "Step 1 description", 63 | "Step 2 description", 64 | # ... 65 | ], 66 | "related_doctypes": ["DocType1", "DocType2"] 67 | } 68 | ], 69 | "key_concepts": [ 70 | { 71 | "name": "Concept Name", 72 | "description": "Description of the concept" 73 | } 74 | ] 75 | } 76 | 77 | # DocType-specific instructions 78 | doctype_instructions = { 79 | "YourDocType": { 80 | "description": "Description of the DocType's purpose", 81 | "usage_guidance": "Guidance on how to use this DocType effectively", 82 | "key_fields": [ 83 | {"name": "field_name", "description": "Description of the field's purpose"}, 84 | # ... 85 | ], 86 | "common_workflows": [ 87 | "Workflow step 1", 88 | "Workflow step 2", 89 | # ... 90 | ] 91 | } 92 | } 93 | 94 | if doctype in doctype_instructions: 95 | return { 96 | "doctype": doctype, 97 | "instructions": doctype_instructions[doctype] 98 | } 99 | 100 | # If the requested DocType doesn't have specific instructions 101 | return { 102 | "doctype": doctype, 103 | "instructions": { 104 | "description": f"Generic description for {doctype}", 105 | "usage_guidance": "Generic usage guidance" 106 | } 107 | } 108 | ``` 109 | 110 | ### 3.3. Response Format 111 | 112 | #### App-Level Instructions (when `doctype` is not provided) 113 | 114 | ```json 115 | { 116 | "app_name": "Your App Name", 117 | "app_description": "Description of your app's purpose and functionality", 118 | "main_workflows": [ 119 | { 120 | "name": "Workflow Name", 121 | "description": "Description of the workflow", 122 | "steps": ["Step 1 description", "Step 2 description"], 123 | "related_doctypes": ["DocType1", "DocType2"] 124 | } 125 | ], 126 | "key_concepts": [ 127 | { 128 | "name": "Concept Name", 129 | "description": "Description of the concept" 130 | } 131 | ] 132 | } 133 | ``` 134 | 135 | #### DocType-Specific Instructions (when `doctype` is provided) 136 | 137 | ```json 138 | { 139 | "doctype": "YourDocType", 140 | "instructions": { 141 | "description": "Description of the DocType's purpose", 142 | "usage_guidance": "Guidance on how to use this DocType effectively", 143 | "key_fields": [ 144 | { 145 | "name": "field_name", 146 | "description": "Description of the field's purpose" 147 | } 148 | ], 149 | "common_workflows": ["Workflow step 1", "Workflow step 2"] 150 | } 151 | } 152 | ``` 153 | 154 | ## 4. How It Works 155 | 156 | ### 4.1. DocType to App Mapping 157 | 158 | The server determines which app a DocType belongs to by: 159 | 160 | 1. Querying the DocType to get its module 161 | 2. Querying the module to get its app name 162 | 163 | This mapping is cached for performance. 164 | 165 | ### 4.2. API Discovery 166 | 167 | The server checks if an app implements the usage instructions API by attempting to call the `get_usage_instructions` method. If the method exists, the app is considered to have implemented the API. 168 | 169 | ### 4.3. Caching 170 | 171 | For performance reasons, the server caches: 172 | 173 | - DocType to app mappings 174 | - App-level usage instructions 175 | - DocType-specific usage instructions 176 | 177 | The cache is cleared periodically to ensure fresh data. 178 | 179 | ## 5. Example Implementation 180 | 181 | See the `epistart` app for a reference implementation: 182 | 183 | ``` 184 | frappe-bench/apps/epistart/epistart/api_usage.py 185 | ``` 186 | 187 | This implementation provides usage instructions for the EpiStart app and its key DocTypes related to lean startup methodology. 188 | 189 | ## 6. Best Practices 190 | 191 | 1. **Focus on LLM-Friendly Content**: Write instructions that help LLMs understand how to use your app effectively. 192 | 2. **Provide Context**: Include descriptions of your app's purpose, key concepts, and workflows. 193 | 3. **Be Specific**: For DocType instructions, include specific guidance on how to use the DocType effectively. 194 | 4. **Keep Updated**: Update your instructions as your app evolves. 195 | 5. **Prioritize Important DocTypes**: Focus on providing detailed instructions for the most important DocTypes in your app. 196 | 197 | ## 7. Troubleshooting 198 | 199 | If your app's instructions are not being picked up: 200 | 201 | 1. Ensure the `api_usage.py` file is in the correct location 202 | 2. Verify that the `get_usage_instructions` method is properly whitelisted 203 | 3. Check that the method returns data in the expected format 204 | 4. Look for errors in the frappe-mcp-server logs 205 | -------------------------------------------------------------------------------- /docs/static_hints_design.md: -------------------------------------------------------------------------------- 1 | # Design: Static Hint Mechanism for frappe-mcp-server 2 | 3 | **Version:** 1.0 4 | **Date:** 2025-04-18 5 | 6 | ## 1. Introduction 7 | 8 | This document outlines the design for a static hint mechanism within the `frappe-mcp-server`. These hints augment the metadata retrieved dynamically via the `get_doctype_schema` tool, providing supplementary context, instructions, and workflow guidance specifically tailored for Large Language Model (LLM) consumption. 9 | 10 | ## 2. Goals 11 | 12 | - Provide additional, LLM-friendly context for specific Frappe DocTypes. 13 | - Describe common or complex multi-DocType workflows. 14 | - Offer guidance for modules or scenarios where standard descriptions might be insufficient. 15 | - Ensure the hint system is maintainable and easy to update. 16 | 17 | ## 3. Storage Mechanism 18 | 19 | - **Location:** Hints will be stored within a dedicated directory in the `frappe-mcp-server` project: `frappe-mcp-server/static_hints/`. 20 | - **Format:** Each hint or logical group of hints (e.g., all hints for a specific module or complex workflow) will be stored in a separate JSON file within this directory (e.g., `sales_hints.json`, `hr_onboarding_workflow.json`). This approach facilitates modularity, maintainability, and easier version control tracking. 21 | - **Naming Convention:** Filenames should be descriptive (e.g., `doctype_item.json`, `workflow_quote_to_cash.json`). 22 | 23 | ## 4. Hint Structure / JSON Schema 24 | 25 | We will define a common structure for hints. Each JSON file in the `static_hints/` directory will contain an array of hint objects. 26 | 27 | ```json 28 | // Example File: static_hints/sales_hints.json 29 | [ 30 | { 31 | "type": "doctype", 32 | "target": "Sales Order", 33 | "hint": "A Sales Order confirms a sale to a customer. It typically follows a Quotation and precedes a Delivery Note and Sales Invoice. Key fields include 'customer', 'transaction_date', 'delivery_date', and the 'items' table detailing products/services, quantities, and rates. Use this document to lock in the terms of a sale before fulfillment." 34 | }, 35 | { 36 | "type": "doctype", 37 | "target": "Quotation", 38 | "hint": "A Quotation is an offer sent to a potential customer for goods or services. If accepted, it can be converted into a Sales Order. Focus on accurately capturing customer requirements and pricing." 39 | }, 40 | { 41 | "type": "workflow", 42 | "target": "Quote to Sales Order Conversion", 43 | "id": "WF-SAL-001", // Optional unique ID for the workflow hint 44 | "description": "Guidance on converting an accepted Quotation into a Sales Order.", 45 | "steps": [ 46 | "Ensure the Quotation status is 'Accepted' or relevant.", 47 | "Use the 'Create > Sales Order' button/action directly from the accepted Quotation form.", 48 | "Verify the details automatically copied to the new Sales Order, especially items, quantities, rates, and customer information.", 49 | "Save and Submit the Sales Order to confirm it." 50 | ], 51 | "related_doctypes": ["Quotation", "Sales Order"] 52 | } 53 | ] 54 | ``` 55 | 56 | **Schema Definition:** 57 | 58 | - **`type`** (string, required): Specifies the hint type. Allowed values: 59 | - `"doctype"`: The hint applies to a specific DocType. 60 | - `"workflow"`: The hint describes a business process or task, potentially involving multiple DocTypes. 61 | - **`target`** (string, required): Identifies what the hint applies to. 62 | - If `type` is `"doctype"`, this is the exact name of the DocType (e.g., `"Sales Order"`). 63 | - If `type` is `"workflow"`, this is a descriptive name for the workflow (e.g., `"Quote to Sales Order Conversion"`). 64 | - **`hint`** (string, required if `type` is `"doctype"`): The instructional text or supplementary description for the DocType, aimed at the LLM. 65 | - **`id`** (string, optional, recommended if `type` is `"workflow"`): A unique identifier for the workflow hint (e.g., `"WF-SAL-001"`). 66 | - **`description`** (string, optional, recommended if `type` is `"workflow"`): A brief description of the workflow's purpose. 67 | - **`steps`** (array of strings, required if `type` is `"workflow"`): An ordered list of steps or instructions describing the workflow. 68 | - **`related_doctypes`** (array of strings, optional, recommended if `type` is `"workflow"`): A list of DocType names relevant to this workflow. 69 | 70 | ## 5. Access Logic 71 | 72 | 1. **Loading:** On server startup, `frappe-mcp-server` will scan the `static_hints/` directory. 73 | 2. **Parsing & Indexing:** It will parse all `*.json` files found within the directory. The hints will be loaded into memory and indexed for efficient retrieval. A potential in-memory structure could be: 74 | 75 | ```typescript 76 | interface Hint { 77 | type: "doctype" | "workflow"; 78 | target: string; 79 | hint?: string; 80 | id?: string; 81 | description?: string; 82 | steps?: string[]; 83 | related_doctypes?: string[]; 84 | } 85 | 86 | // Indexed structure 87 | const staticHints: { 88 | doctype: Map; // Keyed by DocType name 89 | workflow: Map; // Keyed by workflow name or ID 90 | } = { 91 | doctype: new Map(), 92 | workflow: new Map(), 93 | }; 94 | ``` 95 | 96 | Hints would be added to the appropriate map based on their `type` and `target`. Multiple hints can exist for the same target. 97 | 98 | 3. **Retrieval:** When the server needs context (e.g., before calling `get_doctype_schema` or when asked about a workflow), it will query this in-memory index using the DocType name or a potential workflow identifier/keywords. 99 | 4. **Merging:** The retrieved static hints can then be combined with the dynamic information obtained from `get_doctype_schema` to provide a richer context to the LLM. For workflows, the static hint might be the primary source of information. 100 | 101 | ## 6. Example Hint (Illustrative) 102 | 103 | **File:** `static_hints/manufacturing_hints.json` 104 | 105 | ```json 106 | [ 107 | { 108 | "type": "doctype", 109 | "target": "Bill Of Materials", 110 | "hint": "A Bill Of Materials (BOM) defines the raw materials, sub-assemblies, intermediate assemblies, sub-components, parts, and the quantities of each needed to manufacture an end product. It is crucial for production planning, costing, and inventory management. Ensure the quantities are accurate for the specified manufacturing quantity (usually 1 unit of the final product)." 111 | }, 112 | { 113 | "type": "workflow", 114 | "target": "Basic Production Cycle", 115 | "id": "WF-MFG-001", 116 | "description": "Simplified workflow for creating a finished good using a Work Order based on a Bill of Materials.", 117 | "steps": [ 118 | "Ensure a 'Bill Of Materials' exists for the item to be produced.", 119 | "Create a 'Work Order' specifying the item code and quantity to produce. The system should fetch the required materials from the BOM.", 120 | "Issue raw materials against the Work Order using a 'Stock Entry' of type 'Material Issue'.", 121 | "Once production is complete, create another 'Stock Entry' of type 'Manufacture' to receive the finished goods into inventory. This consumes the issued raw materials based on the Work Order.", 122 | "Complete the 'Work Order'." 123 | ], 124 | "related_doctypes": [ 125 | "Bill Of Materials", 126 | "Work Order", 127 | "Stock Entry", 128 | "Item" 129 | ] 130 | } 131 | ] 132 | ``` 133 | 134 | ## 7. Considerations 135 | 136 | - **Maintainability:** As Frappe/ERPNext evolves, these hints may need updates. The file-based approach aids this. 137 | - **Scope:** Hints should focus on _how_ to use Frappe effectively, not replicate basic field descriptions readily available via schema introspection, unless adding significant LLM-specific value. 138 | - **Discovery:** The server might need logic to intelligently match user queries to relevant workflow hints (e.g., using keywords from the query against workflow targets, descriptions, or related DocTypes). 139 | -------------------------------------------------------------------------------- /docs/usage_info_enhancement.md: -------------------------------------------------------------------------------- 1 | # Usage Information Enhancement for frappe-mcp-server 2 | 3 | **Version:** 1.0 4 | **Date:** 2025-04-18 5 | 6 | ## 1. Introduction 7 | 8 | This document describes the Usage Information Enhancement for `frappe-mcp-server`, which combines three sources of information to provide comprehensive, LLM-friendly context about Frappe DocTypes and workflows: 9 | 10 | 1. **Frappe Metadata**: Schema information retrieved directly from the Frappe API 11 | 2. **Static Hints**: Supplementary context stored in JSON files within the MCP server 12 | 3. **Custom App Introspection**: Usage instructions provided directly by custom Frappe apps 13 | 14 | This enhancement enables Large Language Models (LLMs) to better understand Frappe modules, making them more effective at assisting users with Frappe-based applications. 15 | 16 | ## 2. Overall Architecture 17 | 18 | The Usage Information Enhancement is implemented as a layered system that combines information from multiple sources: 19 | 20 | ``` 21 | ┌─────────────────────────────────────────────────────────────┐ 22 | │ get_frappe_usage_info Tool │ 23 | └───────────────────────────┬─────────────────────────────────┘ 24 | │ 25 | ▼ 26 | ┌─────────────────────────────────────────────────────────────┐ 27 | │ Information Sources │ 28 | ├─────────────────┬─────────────────────┬─────────────────────┤ 29 | │ Frappe Metadata │ Static Hints │ App Introspection │ 30 | │ (Schema API) │ (JSON files in │ (Custom app API │ 31 | │ │ static_hints/) │ endpoints) │ 32 | └─────────────────┴─────────────────────┴─────────────────────┘ 33 | ``` 34 | 35 | ### 2.1 Component Responsibilities 36 | 37 | 1. **Frappe Metadata (Schema API)** 38 | 39 | - Provides structural information about DocTypes 40 | - Includes field definitions, validations, and relationships 41 | - Source of truth for technical details 42 | 43 | 2. **Static Hints** 44 | 45 | - Provides supplementary context and usage guidance 46 | - Describes workflows that span multiple DocTypes 47 | - Maintained within the MCP server codebase 48 | 49 | 3. **Custom App Introspection** 50 | 51 | - Allows apps to provide their own usage instructions 52 | - Enables app-specific guidance without modifying the MCP server 53 | - Maintained by app developers 54 | 55 | 4. **Integration Layer (`get_frappe_usage_info` Tool)** 56 | - Combines information from all three sources 57 | - Formats the combined information in a consistent, LLM-friendly way 58 | - Handles fallbacks when certain sources are unavailable 59 | 60 | ## 3. Information Flow 61 | 62 | When the `get_frappe_usage_info` tool is called: 63 | 64 | 1. If a DocType is specified: 65 | 66 | - The system attempts to retrieve schema information from the Frappe API 67 | - It looks for static hints related to the DocType 68 | - It checks if the DocType belongs to a custom app and retrieves app-provided instructions 69 | - It combines all available information into a comprehensive response 70 | 71 | 2. If a workflow is specified: 72 | - The system looks for static hints related to the workflow 73 | - It formats the workflow information in a consistent way 74 | 75 | ## 4. Implementation Details 76 | 77 | ### 4.1 Static Hints 78 | 79 | Static hints are stored as JSON files in the `static_hints/` directory. Each file contains an array of hint objects with the following structure: 80 | 81 | ```json 82 | { 83 | "type": "doctype", 84 | "target": "Sales Order", 85 | "hint": "A Sales Order confirms a sale to a customer..." 86 | } 87 | ``` 88 | 89 | or for workflows: 90 | 91 | ```json 92 | { 93 | "type": "workflow", 94 | "target": "Quote to Sales Order Conversion", 95 | "description": "Guidance on converting an accepted Quotation into a Sales Order.", 96 | "steps": [ 97 | "Ensure the Quotation status is 'Accepted' or relevant.", 98 | "Use the 'Create > Sales Order' button/action directly from the accepted Quotation form.", 99 | "..." 100 | ], 101 | "related_doctypes": ["Quotation", "Sales Order"] 102 | } 103 | ``` 104 | 105 | The system loads these hints at startup and indexes them for efficient retrieval. 106 | 107 | ### 4.2 Custom App Introspection 108 | 109 | Custom apps can provide their own usage instructions by implementing a standardized API endpoint: 110 | 111 | ```python 112 | @frappe.whitelist() 113 | def get_usage_instructions(doctype=None): 114 | # Return app-level or DocType-specific instructions 115 | ``` 116 | 117 | The MCP server discovers these endpoints dynamically and calls them when needed. 118 | 119 | ### 4.3 Integration 120 | 121 | The integration happens in the `get_frappe_usage_info` tool implementation in `schema-operations.ts`. This tool: 122 | 123 | 1. Retrieves information from all available sources 124 | 2. Formats the combined information in a consistent, markdown-based format 125 | 3. Handles errors and fallbacks gracefully 126 | 127 | ## 5. When to Use Each Approach 128 | 129 | ### 5.1 Frappe Metadata (Schema API) 130 | 131 | Use the schema API for: 132 | 133 | - Technical details about DocTypes and fields 134 | - Field validations and constraints 135 | - Relationships between DocTypes 136 | 137 | This is the source of truth for structural information and should not be duplicated in other sources. 138 | 139 | ### 5.2 Static Hints 140 | 141 | Use static hints for: 142 | 143 | - General usage guidance that applies to all installations 144 | - Descriptions of common workflows that span multiple DocTypes 145 | - Supplementary context that helps LLMs understand Frappe concepts 146 | 147 | Static hints are maintained within the MCP server codebase and are appropriate for information that is common across all Frappe installations. 148 | 149 | ### 5.3 Custom App Introspection 150 | 151 | Use custom app introspection for: 152 | 153 | - App-specific usage guidance 154 | - Instructions for custom DocTypes 155 | - Workflows specific to a particular app 156 | 157 | This approach allows app developers to provide their own guidance without modifying the MCP server. 158 | 159 | ## 6. Examples for LLM Usage 160 | 161 | ### 6.1 Understanding a DocType 162 | 163 | When an LLM needs to understand a DocType, it can use the combined information to: 164 | 165 | 1. Understand the technical structure from the schema 166 | 2. Learn about common usage patterns from static hints 167 | 3. Get app-specific guidance from custom app introspection 168 | 169 | For example, for a "Sales Order" DocType: 170 | 171 | - The schema provides field definitions and validations 172 | - Static hints explain its role in the sales process 173 | - App introspection might provide company-specific policies 174 | 175 | ### 6.2 Guiding a User Through a Workflow 176 | 177 | When an LLM needs to guide a user through a workflow, it can: 178 | 179 | 1. Retrieve the workflow steps from static hints 180 | 2. Understand the involved DocTypes from their schemas 181 | 3. Incorporate app-specific guidance if available 182 | 183 | For example, for a "Quote to Sales Order Conversion" workflow: 184 | 185 | - Static hints provide the step-by-step process 186 | - Schema information helps understand the fields to fill 187 | - App introspection might provide company-specific requirements 188 | 189 | ## 7. Future Enhancements 190 | 191 | Potential future enhancements to the system include: 192 | 193 | 1. **Contextual Hints**: Provide different hints based on the user's role or context 194 | 2. **Interactive Tutorials**: Generate step-by-step tutorials based on the combined information 195 | 3. **Usage Analytics**: Track which hints are most useful and refine them over time 196 | 4. **Community-Contributed Hints**: Allow the community to contribute hints for common DocTypes and workflows 197 | 5. **Multilingual Support**: Provide hints in multiple languages 198 | 6. **Versioned Hints**: Maintain different hints for different versions of Frappe/ERPNext 199 | 200 | ## 8. Conclusion 201 | 202 | The Usage Information Enhancement significantly improves the ability of LLMs to understand and work with Frappe-based applications. By combining information from multiple sources, it provides a comprehensive view that includes both technical details and usage guidance. 203 | 204 | This approach is flexible and extensible, allowing for both centralized maintenance of common information (via static hints) and distributed maintenance of app-specific information (via custom app introspection). 205 | -------------------------------------------------------------------------------- /examples/claude-integration.js: -------------------------------------------------------------------------------- 1 | // Example of how to use the Frappe MCP Server with Claude 2 | // This is a pseudocode example to illustrate the integration 3 | 4 | // 1. First, ensure the Frappe MCP Server is configured in your Claude settings 5 | // Add the following to your Claude MCP settings file: 6 | /* 7 | { 8 | "mcpServers": { 9 | "frappe": { 10 | "command": "node", 11 | "args": ["/path/to/frappe-mcp-server/build/index.js"], 12 | "env": { 13 | "FRAPPE_URL": "http://frappe:8000", 14 | "FRAPPE_API_KEY": "ebd0f67ed8867c8", 15 | "FRAPPE_API_SECRET": "712b361980f1eb0" 16 | }, 17 | "disabled": false, 18 | "alwaysAllow": [] 19 | } 20 | } 21 | } 22 | */ 23 | 24 | // 2. Example of using the MCP server with Claude 25 | 26 | // Example 1: Get DocType Schema 27 | /* 28 | You: Can you show me the schema for the Customer DocType in our Frappe system? 29 | 30 | Claude: I'll retrieve the schema for the Customer DocType from your Frappe system. 31 | 32 | 33 | frappe 34 | get_doctype_schema 35 | 36 | { 37 | "doctype": "Customer" 38 | } 39 | 40 | 41 | 42 | You: [Result of the MCP tool call with the Customer schema] 43 | 44 | Claude: Here's the schema for the Customer DocType: 45 | [Claude would then format and explain the schema information] 46 | */ 47 | 48 | // Example 2: Create a new Customer 49 | /* 50 | You: Please create a new customer named "Acme Corporation" in our Frappe system. 51 | 52 | Claude: I'll create a new customer record for "Acme Corporation" in your Frappe system. 53 | 54 | 55 | frappe 56 | create_document 57 | 58 | { 59 | "doctype": "Customer", 60 | "values": { 61 | "customer_name": "Acme Corporation", 62 | "customer_type": "Company", 63 | "customer_group": "Commercial", 64 | "territory": "All Territories" 65 | } 66 | } 67 | 68 | 69 | 70 | You: [Result of the MCP tool call with the created customer information] 71 | 72 | Claude: I've successfully created a new customer record for "Acme Corporation" in your Frappe system. 73 | The customer has been assigned the ID: CUST-00123. 74 | */ 75 | 76 | // Example 3: List Customers with Filters 77 | /* 78 | You: Can you list all customers in the Commercial customer group? 79 | 80 | Claude: I'll retrieve a list of all customers in the Commercial customer group. 81 | 82 | 83 | frappe 84 | list_documents 85 | 86 | { 87 | "doctype": "Customer", 88 | "filters": { 89 | "customer_group": "Commercial" 90 | } 91 | } 92 | 93 | 94 | 95 | You: [Result of the MCP tool call with the list of customers] 96 | 97 | Claude: Here are the customers in the Commercial customer group: 98 | 1. Acme Corporation (CUST-00123) 99 | 2. XYZ Industries (CUST-00089) 100 | 3. ABC Enterprises (CUST-00045) 101 | [Claude would continue listing and might provide additional insights] 102 | */ 103 | 104 | // Example 4: Update a Customer 105 | /* 106 | You: Please update the customer "Acme Corporation" to change their territory to "North America". 107 | 108 | Claude: I'll update the customer record for "Acme Corporation" to change their territory. 109 | 110 | 111 | frappe 112 | update_document 113 | 114 | { 115 | "doctype": "Customer", 116 | "name": "CUST-00123", 117 | "values": { 118 | "territory": "North America" 119 | } 120 | } 121 | 122 | 123 | 124 | You: [Result of the MCP tool call with the updated customer information] 125 | 126 | Claude: I've successfully updated the customer record for "Acme Corporation". 127 | Their territory has been changed to "North America". 128 | */ -------------------------------------------------------------------------------- /frappe-server-write-operations.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * FRAPPE SERVER WRITE OPERATIONS 3 | * 4 | * This file contains all the functions that write to the Frappe server. 5 | * Extracted from frappe-api.ts and document-operations.ts for Frappe support. 6 | */ 7 | 8 | import axios, { AxiosError } from "axios"; 9 | import { FrappeApp } from "frappe-js-sdk"; 10 | 11 | // Error class for Frappe API errors 12 | export class FrappeApiError extends Error { 13 | statusCode?: number; 14 | endpoint?: string; 15 | details?: any; 16 | 17 | constructor(message: string, statusCode?: number, endpoint?: string, details?: any) { 18 | super(message); 19 | this.name = "FrappeApiError"; 20 | this.statusCode = statusCode; 21 | this.endpoint = endpoint; 22 | this.details = details; 23 | } 24 | 25 | static fromAxiosError(error: AxiosError, operation: string): FrappeApiError { 26 | const statusCode = error.response?.status; 27 | const endpoint = error.config?.url || "unknown"; 28 | let message = `Frappe API error during ${operation}: ${error.message}`; 29 | let details: any = null; 30 | 31 | // Extract more detailed error information from Frappe's response 32 | if (error.response?.data) { 33 | const data = error.response.data as any; 34 | if (data.exception) { 35 | message = `Frappe exception during ${operation}: ${data.exception}`; 36 | details = data; 37 | } else if (data._server_messages) { 38 | try { 39 | // Server messages are often JSON strings inside a string 40 | const serverMessages = JSON.parse(data._server_messages); 41 | const parsedMessages = Array.isArray(serverMessages) 42 | ? serverMessages.map((msg: string) => { 43 | try { 44 | return JSON.parse(msg); 45 | } catch { 46 | return msg; 47 | } 48 | }) 49 | : [serverMessages]; 50 | 51 | message = `Frappe server message during ${operation}: ${parsedMessages.map((m: any) => m.message || m).join("; ")}`; 52 | details = { serverMessages: parsedMessages }; 53 | } catch (e) { 54 | message = `Frappe server message during ${operation}: ${data._server_messages}`; 55 | details = { serverMessages: data._server_messages }; 56 | } 57 | } else if (data.message) { 58 | message = `Frappe API error during ${operation}: ${data.message}`; 59 | details = data; 60 | } 61 | } 62 | 63 | return new FrappeApiError(message, statusCode, endpoint, details); 64 | } 65 | } 66 | 67 | // Initialize Frappe JS SDK 68 | const frappe = new FrappeApp(process.env.FRAPPE_URL || "http://localhost:8000", { 69 | useToken: true, 70 | token: () => `${process.env.FRAPPE_API_KEY}:${process.env.FRAPPE_API_SECRET}`, 71 | type: "token", // For API key/secret pairs 72 | }); 73 | 74 | // ==================== DOCUMENT CREATION ==================== 75 | 76 | /** 77 | * Create a document with verification 78 | */ 79 | export async function createDocument( 80 | doctype: string, 81 | values: Record 82 | ): Promise { 83 | try { 84 | if (!doctype) throw new Error("DocType is required"); 85 | if (!values || Object.keys(values).length === 0) { 86 | throw new Error("Document values are required"); 87 | } 88 | 89 | console.error(`Creating document of type ${doctype} with values:`, JSON.stringify(values, null, 2)); 90 | 91 | const response = await frappe.db().createDoc(doctype, values); 92 | 93 | console.error(`Create document response:`, JSON.stringify(response, null, 2)); 94 | 95 | if (!response) { 96 | throw new Error(`Invalid response format for creating ${doctype}`); 97 | } 98 | 99 | // IMPROVED VERIFICATION: Make this a required step, not just a try-catch 100 | const verificationResult = await verifyDocumentCreation(doctype, values, response); 101 | if (!verificationResult.success) { 102 | console.error(`Document creation verification failed: ${verificationResult.message}`); 103 | // Return the response but include verification info 104 | return { ...response, _verification: verificationResult }; 105 | } 106 | 107 | return response; 108 | } catch (error) { 109 | console.error(`Error in createDocument:`, error); 110 | return handleApiError(error, `create_document(${doctype})`); 111 | } 112 | } 113 | 114 | 115 | /** 116 | * Verify that a document was successfully created 117 | */ 118 | async function verifyDocumentCreation( 119 | doctype: string, 120 | values: Record, 121 | creationResponse: any 122 | ): Promise<{ success: boolean; message: string }> { 123 | try { 124 | // First check if we have a name in the response 125 | if (!creationResponse.name) { 126 | return { success: false, message: "Response does not contain a document name" }; 127 | } 128 | 129 | // Try to fetch the document directly by name 130 | try { 131 | const document = await frappe.db().getDoc(doctype, creationResponse.name); 132 | if (document && document.name === creationResponse.name) { 133 | return { success: true, message: "Document verified by direct fetch" }; 134 | } 135 | } catch (error) { 136 | console.error(`Error fetching document by name during verification:`, error); 137 | // Continue with alternative verification methods 138 | } 139 | 140 | // Try to find the document by filtering 141 | const filters: Record = {}; 142 | 143 | // Use the most unique fields for filtering 144 | if (values.name) { 145 | filters['name'] = ['=', values.name]; 146 | } else if (values.title) { 147 | filters['title'] = ['=', values.title]; 148 | } else if (values.description) { 149 | // Use a substring of the description to avoid issues with long text 150 | filters['description'] = ['like', `%${values.description.substring(0, 20)}%`]; 151 | } 152 | 153 | if (Object.keys(filters).length > 0) { 154 | const documents = await frappe.db().getDocList(doctype, { 155 | filters: filters as any[], 156 | limit: 5 157 | }); 158 | 159 | if (documents && documents.length > 0) { 160 | // Check if any of the returned documents match our expected name 161 | const matchingDoc = documents.find(doc => doc.name === creationResponse.name); 162 | if (matchingDoc) { 163 | return { success: true, message: "Document verified by filter search" }; 164 | } 165 | 166 | // If we found documents but none match our expected name, that's suspicious 167 | return { 168 | success: false, 169 | message: `Found ${documents.length} documents matching filters, but none match the expected name ${creationResponse.name}` 170 | }; 171 | } 172 | 173 | return { 174 | success: false, 175 | message: "No documents found matching the creation filters" 176 | }; 177 | } 178 | 179 | // If we couldn't verify with filters, return a warning 180 | return { 181 | success: false, 182 | message: "Could not verify document creation - no suitable filters available" 183 | }; 184 | } catch (verifyError) { 185 | return { 186 | success: false, 187 | message: `Error during verification: ${(verifyError as Error).message}` 188 | }; 189 | } 190 | } 191 | 192 | /** 193 | * Create a document with retry logic 194 | */ 195 | async function createDocumentWithRetry( 196 | doctype: string, 197 | values: Record, 198 | maxRetries = 3 199 | ): Promise { 200 | let lastError; 201 | 202 | for (let attempt = 1; attempt <= maxRetries; attempt++) { 203 | try { 204 | console.error(`Attempt ${attempt} to create document of type ${doctype}`); 205 | 206 | const result = await frappe.db().createDoc(doctype, values); 207 | 208 | // Verify document creation 209 | const verificationResult = await verifyDocumentCreation(doctype, values, result); 210 | if (verificationResult.success) { 211 | console.error(`Document creation verified on attempt ${attempt}`); 212 | return { ...result, _verification: verificationResult }; 213 | } 214 | 215 | // If verification failed, throw an error to trigger retry 216 | lastError = new Error(`Verification failed: ${verificationResult.message}`); 217 | console.error(`Verification failed on attempt ${attempt}: ${verificationResult.message}`); 218 | 219 | // Wait before retrying (exponential backoff) 220 | const delay = Math.pow(2, attempt - 1) * 1000; // 1s, 2s, 4s, etc. 221 | await new Promise(resolve => setTimeout(resolve, delay)); 222 | } catch (error) { 223 | lastError = error; 224 | console.error(`Error on attempt ${attempt}:`, error); 225 | 226 | // Wait before retrying 227 | const delay = Math.pow(2, attempt - 1) * 1000; 228 | await new Promise(resolve => setTimeout(resolve, delay)); 229 | } 230 | } 231 | 232 | // If we've exhausted all retries, throw the last error 233 | throw lastError || new Error(`Failed to create document after ${maxRetries} attempts`); 234 | } 235 | 236 | /** 237 | * Create a document with transaction-like pattern 238 | */ 239 | async function createDocumentTransactional( 240 | doctype: string, 241 | values: Record 242 | ): Promise { 243 | // 1. Create a temporary log entry to track this operation 244 | const operationId = `create_${doctype}_${Date.now()}`; 245 | try { 246 | // Log the operation start 247 | await logOperation(operationId, 'start', { doctype, values }); 248 | 249 | // 2. Attempt to create the document 250 | const result = await createDocumentWithRetry(doctype, values); 251 | 252 | // 3. Verify the document was created 253 | const verificationResult = await verifyDocumentCreation(doctype, values, result); 254 | 255 | // 4. Log the operation result 256 | await logOperation(operationId, verificationResult.success ? 'success' : 'failure', { 257 | result, 258 | verification: verificationResult 259 | }); 260 | 261 | // 5. Return the result with verification info 262 | return { 263 | ...result, 264 | _verification: verificationResult 265 | }; 266 | } catch (error) { 267 | // Log the operation error 268 | await logOperation(operationId, 'error', { error: (error as Error).message }); 269 | throw error; 270 | } 271 | } 272 | 273 | // ==================== DOCUMENT UPDATES ==================== 274 | 275 | /** 276 | * Update an existing document 277 | */ 278 | export async function updateDocument( 279 | doctype: string, 280 | name: string, 281 | values: Record 282 | ): Promise { 283 | try { 284 | if (!doctype) throw new Error("DocType is required"); 285 | if (!name) throw new Error("Document name is required"); 286 | if (!values || Object.keys(values).length === 0) { 287 | throw new Error("Update values are required"); 288 | } 289 | 290 | const response = await frappe.db().updateDoc(doctype, name, values); 291 | 292 | if (!response) { 293 | throw new Error(`Invalid response format for updating ${doctype}/${name}`); 294 | } 295 | 296 | return response; 297 | } catch (error) { 298 | return handleApiError(error, `update_document(${doctype}, ${name})`); 299 | } 300 | } 301 | 302 | 303 | // ==================== DOCUMENT DELETION ==================== 304 | 305 | /** 306 | * Delete a document 307 | */ 308 | export async function deleteDocument( 309 | doctype: string, 310 | name: string 311 | ): Promise { 312 | try { 313 | if (!doctype) throw new Error("DocType is required"); 314 | if (!name) throw new Error("Document name is required"); 315 | 316 | const response = await frappe.db().deleteDoc(doctype, name); 317 | 318 | if (!response) { 319 | return response; 320 | } 321 | return response; 322 | 323 | } catch (error) { 324 | return handleApiError(error, `delete_document(${doctype}, ${name})`); 325 | } 326 | } 327 | 328 | 329 | // ==================== METHOD CALLS ==================== 330 | 331 | /** 332 | * Execute a Frappe method call 333 | * @param method The method name to call 334 | * @param params The parameters to pass to the method 335 | * @returns The method response 336 | */ 337 | export async function callMethod( 338 | method: string, 339 | params?: Record 340 | ): Promise { 341 | try { 342 | if (!method) throw new Error("Method name is required"); 343 | 344 | const response = await frappe.call().post(method, params); 345 | 346 | if (!response) { 347 | throw new Error(`Invalid response format for method ${method}`); 348 | } 349 | 350 | return response; 351 | } catch (error) { 352 | return handleApiError(error, `call_method(${method})`); 353 | } 354 | } 355 | 356 | // ==================== HELPER FUNCTIONS ==================== 357 | 358 | /** 359 | * Log operation for transaction-like pattern 360 | */ 361 | async function logOperation( 362 | operationId: string, 363 | status: 'start' | 'success' | 'failure' | 'error', 364 | data: any 365 | ): Promise { 366 | // This could write to a local log file, a database, or even a separate API 367 | console.error(`[Operation ${operationId}] ${status}:`, JSON.stringify(data, null, 2)); 368 | 369 | // In a production system, you might want to persist this information 370 | // to help with debugging and recovery 371 | } 372 | 373 | /** 374 | * Helper function to handle API errors 375 | */ 376 | function handleApiError(error: any, operation: string): never { 377 | if (axios.isAxiosError(error)) { 378 | throw FrappeApiError.fromAxiosError(error, operation); 379 | } else { 380 | throw new FrappeApiError(`Error during ${operation}: ${(error as Error).message}`); 381 | } 382 | } 383 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "frappe-mcp-server", 3 | "version": "0.2.16", 4 | "description": "Enhanced Model Context Protocol server for Frappe Framework with comprehensive API instructions and helper tools", 5 | "main": "build/index.js", 6 | "type": "module", 7 | "scripts": { 8 | "build": "tsc && node -e \"require('fs').chmodSync('build/index.js', '755')\"", 9 | "start": "node build/index.js", 10 | "dev": "ts-node --esm src/index.ts", 11 | "test": "echo \"Error: no test specified\" && exit 1", 12 | "test-server": "node test-server.js", 13 | "test-tools": "node test-tools.js", 14 | "fixpkg": "npm pkg fix", 15 | "publish": "npm publish --access public" 16 | }, 17 | "bin": { 18 | "frappe-mcp-server": "build/index.js" 19 | }, 20 | "keywords": [ 21 | "frappe", 22 | "mcp", 23 | "ai", 24 | "claude", 25 | "anthropic", 26 | "erp" 27 | ], 28 | "author": "", 29 | "license": "ISC", 30 | "dependencies": { 31 | "@modelcontextprotocol/sdk": "^1.6.1", 32 | "axios": "^1.8.2", 33 | "frappe-js-sdk": "^1.7.0" 34 | }, 35 | "devDependencies": { 36 | "@types/node": "^22.13.10", 37 | "ts-node": "^10.9.2", 38 | "typescript": "^5.8.2" 39 | }, 40 | "engines": { 41 | "node": ">=18.0.0" 42 | }, 43 | "repository": { 44 | "type": "git", 45 | "url": "git+https://github.com/appliedrelevance/frappe_mcp_server.git" 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /src/api-client.ts: -------------------------------------------------------------------------------- 1 | import { FrappeApp } from "frappe-js-sdk"; 2 | 3 | // Get environment variables with standardized access pattern 4 | const FRAPPE_URL = process.env.FRAPPE_URL || "http://localhost:8000"; 5 | const FRAPPE_API_KEY = process.env.FRAPPE_API_KEY; 6 | const FRAPPE_API_SECRET = process.env.FRAPPE_API_SECRET; 7 | const FRAPPE_TEAM_NAME = process.env.FRAPPE_TEAM_NAME || ""; 8 | 9 | // Enhanced logging for debugging 10 | console.error(`[AUTH] Initializing Frappe JS SDK with URL: ${FRAPPE_URL}`); 11 | console.error(`[AUTH] API Key available: ${!!FRAPPE_API_KEY}`); 12 | console.error(`[AUTH] API Secret available: ${!!FRAPPE_API_SECRET}`); 13 | 14 | // Show first few characters of credentials for debugging (never show full credentials) 15 | if (FRAPPE_API_KEY) { 16 | console.error(`[AUTH] API Key prefix: ${FRAPPE_API_KEY.substring(0, 4)}...`); 17 | console.error(`[AUTH] API Key length: ${FRAPPE_API_KEY.length} characters`); 18 | } 19 | if (FRAPPE_API_SECRET) { 20 | console.error(`[AUTH] API Secret length: ${FRAPPE_API_SECRET.length} characters`); 21 | } 22 | 23 | // Log authentication method and status 24 | if (!FRAPPE_API_KEY || !FRAPPE_API_SECRET) { 25 | console.error("[AUTH] WARNING: API key/secret authentication is required. Missing credentials will cause operations to fail."); 26 | console.error("[AUTH] Please set both FRAPPE_API_KEY and FRAPPE_API_SECRET environment variables."); 27 | } else { 28 | console.error("[AUTH] Using API key/secret authentication (token-based)"); 29 | 30 | // Test token formation 31 | const testToken = `${FRAPPE_API_KEY}:${FRAPPE_API_SECRET}`; 32 | console.error(`[AUTH] Test token formed successfully, length: ${testToken.length} characters`); 33 | console.error(`[AUTH] Token format check: ${testToken.includes(':') ? 'Valid (contains colon separator)' : 'Invalid (missing colon separator)'}`); 34 | } 35 | 36 | // Create token function with enhanced error handling 37 | const getToken = () => { 38 | if (!FRAPPE_API_KEY || !FRAPPE_API_SECRET) { 39 | console.error("[AUTH] ERROR: Missing API credentials when attempting to create authentication token"); 40 | throw new Error("Authentication failed: Missing API key or secret. Both are required."); 41 | } 42 | 43 | const token = `${FRAPPE_API_KEY}:${FRAPPE_API_SECRET}`; 44 | 45 | // Validate token format 46 | if (!token.includes(':') || token === ':' || token.startsWith(':') || token.endsWith(':')) { 47 | console.error("[AUTH] ERROR: Malformed authentication token"); 48 | throw new Error("Authentication failed: Malformed token. Check API key and secret format."); 49 | } 50 | 51 | return token; 52 | }; 53 | 54 | // Initialize Frappe JS SDK with enhanced error handling 55 | export const frappe = new FrappeApp(FRAPPE_URL, { 56 | useToken: true, 57 | token: getToken, 58 | type: "token", // For API key/secret pairs 59 | }); 60 | 61 | // Add request interceptor with enhanced authentication debugging 62 | frappe.axios.interceptors.request.use(config => { 63 | config.headers = config.headers || {}; 64 | console.error(`Request method: ${config.method}`); 65 | config.headers['X-Press-Team'] = FRAPPE_TEAM_NAME; 66 | 67 | // Log basic request info 68 | console.error(`[REQUEST] Making request to: ${config.url}`); 69 | console.error(`[REQUEST] Method: ${config.method}`); 70 | 71 | // Enhanced authentication header debugging 72 | const authHeader = config.headers['Authorization'] as string; 73 | 74 | // Detailed auth header analysis 75 | if (!authHeader) { 76 | console.error('[AUTH] ERROR: Authorization header is missing completely'); 77 | } else if (authHeader.includes('undefined')) { 78 | console.error('[AUTH] ERROR: Authorization header contains "undefined"'); 79 | } else if (authHeader.includes('null')) { 80 | console.error('[AUTH] ERROR: Authorization header contains "null"'); 81 | } else if (authHeader === ':') { 82 | console.error('[AUTH] ERROR: Authorization header is just a colon - both API key and secret are empty strings'); 83 | } else if (!authHeader.includes(':')) { 84 | console.error('[AUTH] ERROR: Authorization header is missing the colon separator'); 85 | } else if (authHeader.startsWith(':')) { 86 | console.error('[AUTH] ERROR: Authorization header is missing the API key (starts with colon)'); 87 | } else if (authHeader.endsWith(':')) { 88 | console.error('[AUTH] ERROR: Authorization header is missing the API secret (ends with colon)'); 89 | } else { 90 | // Safe logging of auth header (partial) 91 | const parts = authHeader.split(':'); 92 | console.error(`[AUTH] Authorization header format: ${parts[0].substring(0, 4)}...:${parts[1] ? '***' : 'missing'}`); 93 | console.error(`[AUTH] Authorization header length: ${authHeader.length} characters`); 94 | } 95 | 96 | // Log other headers without the auth header 97 | const headersForLogging = {...config.headers}; 98 | delete headersForLogging['Authorization']; // Remove auth header for safe logging 99 | console.error(`[REQUEST] Headers:`, JSON.stringify(headersForLogging, null, 2)); 100 | 101 | if (config.data) { 102 | console.error(`[REQUEST] Data:`, JSON.stringify(config.data, null, 2)); 103 | } 104 | 105 | return config; 106 | }); 107 | 108 | // Add response interceptor with enhanced error handling 109 | frappe.axios.interceptors.response.use( 110 | response => { 111 | console.error(`[RESPONSE] Status: ${response.status}`); 112 | console.error(`[RESPONSE] Headers:`, JSON.stringify(response.headers, null, 2)); 113 | console.error(`[RESPONSE] Data:`, JSON.stringify(response.data, null, 2)); 114 | return response; 115 | }, 116 | error => { 117 | console.error(`[ERROR] Response error occurred:`, error.message); 118 | 119 | // Enhanced error logging 120 | if (error.response) { 121 | console.error(`[ERROR] Status: ${error.response.status}`); 122 | console.error(`[ERROR] Status text: ${error.response.statusText}`); 123 | console.error(`[ERROR] Data:`, JSON.stringify(error.response.data, null, 2)); 124 | 125 | // Special handling for authentication errors 126 | if (error.response.status === 401 || error.response.status === 403) { 127 | console.error(`[AUTH ERROR] Authentication failed with status ${error.response.status}`); 128 | 129 | // Check for specific Frappe error patterns 130 | const data = error.response.data; 131 | if (data) { 132 | if (data.exc_type) console.error(`[AUTH ERROR] Exception type: ${data.exc_type}`); 133 | if (data.exception) console.error(`[AUTH ERROR] Exception: ${data.exception}`); 134 | if (data._server_messages) console.error(`[AUTH ERROR] Server messages: ${data._server_messages}`); 135 | if (data.message) console.error(`[AUTH ERROR] Message: ${data.message}`); 136 | } 137 | 138 | // Add authentication error info to the error object for better error handling 139 | error.authError = true; 140 | error.authErrorDetails = { 141 | status: error.response.status, 142 | data: error.response.data, 143 | apiKeyAvailable: !!FRAPPE_API_KEY, 144 | apiSecretAvailable: !!FRAPPE_API_SECRET 145 | }; 146 | } 147 | } else if (error.request) { 148 | console.error(`[ERROR] No response received. Request:`, error.request); 149 | } else { 150 | console.error(`[ERROR] Error setting up request:`, error.message); 151 | } 152 | 153 | // Log config information 154 | if (error.config) { 155 | console.error(`[ERROR] Request URL: ${error.config.method?.toUpperCase()} ${error.config.url}`); 156 | console.error(`[ERROR] Base URL: ${error.config.baseURL}`); 157 | 158 | // Log headers without auth header 159 | const headersForLogging = {...error.config.headers}; 160 | delete headersForLogging['Authorization']; // Remove auth header for safe logging 161 | console.error(`[ERROR] Request headers:`, JSON.stringify(headersForLogging, null, 2)); 162 | } 163 | 164 | return Promise.reject(error); 165 | } 166 | ); -------------------------------------------------------------------------------- /src/app-introspection.ts: -------------------------------------------------------------------------------- 1 | import { frappe } from './api-client.js'; 2 | import { handleApiError } from './errors.js'; 3 | import { callMethod } from './document-api.js'; 4 | 5 | /** 6 | * Interface for app usage instructions 7 | */ 8 | export interface AppUsageInstructions { 9 | app_name?: string; 10 | app_description?: string; 11 | main_workflows?: Array<{ 12 | name: string; 13 | description: string; 14 | steps: string[]; 15 | related_doctypes?: string[]; 16 | }>; 17 | key_concepts?: Array<{ 18 | name: string; 19 | description: string; 20 | }>; 21 | } 22 | 23 | /** 24 | * Interface for DocType usage instructions 25 | */ 26 | export interface DocTypeUsageInstructions { 27 | doctype: string; 28 | instructions: { 29 | description: string; 30 | usage_guidance: string; 31 | key_fields?: Array<{ 32 | name: string; 33 | description: string; 34 | }>; 35 | common_workflows?: string[]; 36 | }; 37 | } 38 | 39 | /** 40 | * Cache of DocType to app mappings 41 | */ 42 | const doctypeAppCache = new Map(); 43 | 44 | /** 45 | * Cache of app usage instructions 46 | */ 47 | const appInstructionsCache = new Map(); 48 | 49 | /** 50 | * Cache of DocType usage instructions 51 | */ 52 | const doctypeInstructionsCache = new Map(); 53 | 54 | /** 55 | * Get the app that a DocType belongs to 56 | * @param doctype The DocType name 57 | * @returns The app name, or null if not found 58 | */ 59 | export async function getAppForDocType(doctype: string): Promise { 60 | try { 61 | // Check cache first 62 | if (doctypeAppCache.has(doctype)) { 63 | return doctypeAppCache.get(doctype) || null; 64 | } 65 | 66 | // Query Frappe to get the module for this DocType 67 | const doctypeDoc = await frappe.db().getDoc('DocType', doctype); 68 | if (!doctypeDoc || !doctypeDoc.module) { 69 | return null; 70 | } 71 | 72 | // Query Frappe to get the app for this module 73 | const moduleDoc = await frappe.db().getDoc('Module Def', doctypeDoc.module); 74 | if (!moduleDoc || !moduleDoc.app_name) { 75 | return null; 76 | } 77 | 78 | // Cache the result 79 | const appName = moduleDoc.app_name; 80 | doctypeAppCache.set(doctype, appName); 81 | 82 | return appName; 83 | } catch (error) { 84 | console.error(`Error getting app for DocType ${doctype}:`, error); 85 | return null; 86 | } 87 | } 88 | 89 | /** 90 | * Check if an app has a usage instructions API 91 | * @param appName The app name 92 | * @returns True if the app has a usage instructions API 93 | */ 94 | export async function hasUsageInstructionsAPI(appName: string): Promise { 95 | try { 96 | // Try to call the get_usage_instructions method 97 | // We'll use a dummy call with no parameters to check if the method exists 98 | await callMethod(`${appName}.api_usage.get_usage_instructions`); 99 | return true; 100 | } catch (error) { 101 | // If we get a specific error about the method not existing, return false 102 | // Otherwise, it might be another error (like missing parameters), which means the method exists 103 | const errorMessage = (error as Error).message || ''; 104 | if (errorMessage.includes('not found') || errorMessage.includes('does not exist')) { 105 | return false; 106 | } 107 | 108 | // If it's some other error, assume the method exists but had an issue 109 | return true; 110 | } 111 | } 112 | 113 | /** 114 | * Get usage instructions for an app 115 | * @param appName The app name 116 | * @returns The app usage instructions, or null if not available 117 | */ 118 | export async function getAppUsageInstructions(appName: string): Promise { 119 | try { 120 | // Check cache first 121 | if (appInstructionsCache.has(appName)) { 122 | return appInstructionsCache.get(appName) || null; 123 | } 124 | 125 | // Check if the app has a usage instructions API 126 | const hasAPI = await hasUsageInstructionsAPI(appName); 127 | if (!hasAPI) { 128 | return null; 129 | } 130 | 131 | // Call the app's usage instructions API 132 | const instructions = await callMethod(`${appName}.api_usage.get_usage_instructions`); 133 | 134 | // Cache the result 135 | appInstructionsCache.set(appName, instructions); 136 | 137 | return instructions; 138 | } catch (error) { 139 | console.error(`Error getting usage instructions for app ${appName}:`, error); 140 | return null; 141 | } 142 | } 143 | 144 | /** 145 | * Get usage instructions for a DocType from its app 146 | * @param doctype The DocType name 147 | * @returns The DocType usage instructions, or null if not available 148 | */ 149 | export async function getDocTypeUsageInstructions(doctype: string): Promise { 150 | try { 151 | // Check cache first 152 | if (doctypeInstructionsCache.has(doctype)) { 153 | return doctypeInstructionsCache.get(doctype) || null; 154 | } 155 | 156 | // Get the app for this DocType 157 | const appName = await getAppForDocType(doctype); 158 | if (!appName) { 159 | return null; 160 | } 161 | 162 | // Check if the app has a usage instructions API 163 | const hasAPI = await hasUsageInstructionsAPI(appName); 164 | if (!hasAPI) { 165 | return null; 166 | } 167 | 168 | // Call the app's usage instructions API with the DocType 169 | const instructions = await callMethod(`${appName}.api_usage.get_usage_instructions`, { doctype }); 170 | 171 | // Cache the result 172 | doctypeInstructionsCache.set(doctype, instructions); 173 | 174 | return instructions; 175 | } catch (error) { 176 | console.error(`Error getting usage instructions for DocType ${doctype}:`, error); 177 | return null; 178 | } 179 | } 180 | 181 | /** 182 | * Clear all caches 183 | * This should be called periodically to ensure fresh data 184 | */ 185 | export function clearIntrospectionCaches(): void { 186 | doctypeAppCache.clear(); 187 | appInstructionsCache.clear(); 188 | doctypeInstructionsCache.clear(); 189 | console.error('Cleared app introspection caches'); 190 | } 191 | 192 | /** 193 | * Initialize the app introspection system 194 | * This should be called during server startup 195 | */ 196 | export async function initializeAppIntrospection(): Promise { 197 | console.error('Initializing app introspection...'); 198 | 199 | // Set up a timer to clear caches periodically (every hour) 200 | setInterval(clearIntrospectionCaches, 60 * 60 * 1000); 201 | 202 | console.error('App introspection initialized'); 203 | } -------------------------------------------------------------------------------- /src/auth.ts: -------------------------------------------------------------------------------- 1 | import { frappe } from './api-client.js'; 2 | 3 | // Authentication state tracking 4 | let isAuthenticated = false; 5 | let authenticationInProgress = false; 6 | let lastAuthAttempt = 0; 7 | const AUTH_TIMEOUT = 1000 * 60 * 30; // 30 minutes 8 | 9 | /** 10 | * Validates that the required API credentials are available 11 | * @returns Object indicating if credentials are valid with detailed message 12 | */ 13 | export function validateApiCredentials(): { 14 | valid: boolean; 15 | message: string; 16 | } { 17 | const apiKey = process.env.FRAPPE_API_KEY; 18 | const apiSecret = process.env.FRAPPE_API_SECRET; 19 | 20 | if (!apiKey && !apiSecret) { 21 | return { 22 | valid: false, 23 | message: "Authentication failed: Both API key and API secret are missing. API key/secret is the only supported authentication method." 24 | }; 25 | } 26 | 27 | if (!apiKey) { 28 | return { 29 | valid: false, 30 | message: "Authentication failed: API key is missing. API key/secret is the only supported authentication method." 31 | }; 32 | } 33 | 34 | if (!apiSecret) { 35 | return { 36 | valid: false, 37 | message: "Authentication failed: API secret is missing. API key/secret is the only supported authentication method." 38 | }; 39 | } 40 | 41 | return { 42 | valid: true, 43 | message: "API credentials validation successful." 44 | }; 45 | } 46 | 47 | /** 48 | * Check the health of the Frappe API connection 49 | * @returns Health status information 50 | */ 51 | export async function checkFrappeApiHealth(): Promise<{ 52 | healthy: boolean; 53 | tokenAuth: boolean; 54 | message: string; 55 | }> { 56 | const result = { 57 | healthy: false, 58 | tokenAuth: false, 59 | message: "" 60 | }; 61 | 62 | // First validate credentials 63 | const credentialsCheck = validateApiCredentials(); 64 | if (!credentialsCheck.valid) { 65 | result.message = credentialsCheck.message; 66 | console.error(`API Health Check: ${result.message}`); 67 | return result; 68 | } 69 | 70 | try { 71 | // Try token authentication 72 | try { 73 | console.error("Attempting token authentication health check..."); 74 | const tokenResponse = await frappe.db().getDocList("DocType", { limit: 1 }); 75 | result.tokenAuth = true; 76 | console.error("Token authentication health check successful"); 77 | } catch (tokenError) { 78 | console.error("Token authentication health check failed:", tokenError); 79 | result.tokenAuth = false; 80 | } 81 | 82 | // Set overall health status 83 | result.healthy = result.tokenAuth; 84 | result.message = result.healthy 85 | ? `API connection healthy. Token auth: ${result.tokenAuth}` 86 | : "API connection unhealthy. Token authentication failed. Please ensure your API key and secret are correct."; 87 | 88 | return result; 89 | } catch (error) { 90 | result.message = `Health check failed: ${(error as Error).message}`; 91 | console.error(`API Health Check Error: ${result.message}`); 92 | return result; 93 | } 94 | } -------------------------------------------------------------------------------- /src/document-api.ts: -------------------------------------------------------------------------------- 1 | import { frappe } from './api-client.js'; 2 | import { handleApiError } from './errors.js'; 3 | 4 | /** 5 | * Verify that a document was successfully created 6 | */ 7 | async function verifyDocumentCreation( 8 | doctype: string, 9 | values: Record, 10 | creationResponse: any 11 | ): Promise<{ success: boolean; message: string }> { 12 | try { 13 | // First check if we have a name in the response 14 | if (!creationResponse.name) { 15 | return { success: false, message: "Response does not contain a document name" }; 16 | } 17 | 18 | // Try to fetch the document directly by name 19 | try { 20 | const document = await frappe.db().getDoc(doctype, creationResponse.name); 21 | if (document && document.name === creationResponse.name) { 22 | return { success: true, message: "Document verified by direct fetch" }; 23 | } 24 | } catch (error) { 25 | console.error(`Error fetching document by name during verification:`, error); 26 | // Continue with alternative verification methods 27 | } 28 | 29 | // Try to find the document by filtering 30 | const filters: Record = {}; 31 | 32 | // Use the most unique fields for filtering 33 | if (values.name) { 34 | filters['name'] = ['=', values.name]; 35 | } else if (values.title) { 36 | filters['title'] = ['=', values.title]; 37 | } else if (values.description) { 38 | // Use a substring of the description to avoid issues with long text 39 | filters['description'] = ['like', `%${values.description.substring(0, 20)}%`]; 40 | } 41 | 42 | if (Object.keys(filters).length > 0) { 43 | const documents = await frappe.db().getDocList(doctype, { 44 | filters: filters as any[], 45 | limit: 5 46 | }); 47 | 48 | if (documents && documents.length > 0) { 49 | // Check if any of the returned documents match our expected name 50 | const matchingDoc = documents.find(doc => doc.name === creationResponse.name); 51 | if (matchingDoc) { 52 | return { success: true, message: "Document verified by filter search" }; 53 | } 54 | 55 | // If we found documents but none match our expected name, that's suspicious 56 | return { 57 | success: false, 58 | message: `Found ${documents.length} documents matching filters, but none match the expected name ${creationResponse.name}` 59 | }; 60 | } 61 | 62 | return { 63 | success: false, 64 | message: "No documents found matching the creation filters" 65 | }; 66 | } 67 | 68 | // If we couldn't verify with filters, return a warning 69 | return { 70 | success: false, 71 | message: "Could not verify document creation - no suitable filters available" 72 | }; 73 | } catch (verifyError) { 74 | return { 75 | success: false, 76 | message: `Error during verification: ${(verifyError as Error).message}` 77 | }; 78 | } 79 | } 80 | 81 | /** 82 | * Create a document with retry logic 83 | */ 84 | async function createDocumentWithRetry( 85 | doctype: string, 86 | values: Record, 87 | maxRetries = 3 88 | ): Promise { 89 | let lastError; 90 | 91 | for (let attempt = 1; attempt <= maxRetries; attempt++) { 92 | try { 93 | const result = await frappe.db().createDoc(doctype, values); 94 | 95 | // Verify document creation 96 | const verificationResult = await verifyDocumentCreation(doctype, values, result); 97 | if (verificationResult.success) { 98 | return { ...result, _verification: verificationResult }; 99 | } 100 | 101 | // If verification failed, throw an error to trigger retry 102 | lastError = new Error(`Verification failed: ${verificationResult.message}`); 103 | 104 | // Wait before retrying (exponential backoff) 105 | const delay = Math.pow(2, attempt - 1) * 1000; // 1s, 2s, 4s, etc. 106 | await new Promise(resolve => setTimeout(resolve, delay)); 107 | } catch (error) { 108 | lastError = error; 109 | 110 | // Wait before retrying 111 | const delay = Math.pow(2, attempt - 1) * 1000; 112 | await new Promise(resolve => setTimeout(resolve, delay)); 113 | } 114 | } 115 | 116 | // If we've exhausted all retries, throw the last error 117 | throw lastError || new Error(`Failed to create document after ${maxRetries} attempts`); 118 | } 119 | 120 | /** 121 | * Log operation for transaction-like pattern 122 | */ 123 | async function logOperation( 124 | operationId: string, 125 | status: 'start' | 'success' | 'failure' | 'error', 126 | data: any 127 | ): Promise { 128 | // This could write to a local log file, a database, or even a separate API 129 | console.error(`[Operation ${operationId}] ${status}:`, JSON.stringify(data, null, 2)); 130 | 131 | // In a production system, you might want to persist this information 132 | // to help with debugging and recovery 133 | } 134 | 135 | /** 136 | * Create a document with transaction-like pattern 137 | */ 138 | async function createDocumentTransactional( 139 | doctype: string, 140 | values: Record 141 | ): Promise { 142 | // 1. Create a temporary log entry to track this operation 143 | const operationId = `create_${doctype}_${Date.now()}`; 144 | try { 145 | // Log the operation start 146 | await logOperation(operationId, 'start', { doctype, values }); 147 | 148 | // 2. Attempt to create the document 149 | const result = await createDocumentWithRetry(doctype, values); 150 | 151 | // 3. Verify the document was created 152 | const verificationResult = await verifyDocumentCreation(doctype, values, result); 153 | 154 | // 4. Log the operation result 155 | await logOperation(operationId, verificationResult.success ? 'success' : 'failure', { 156 | result, 157 | verification: verificationResult 158 | }); 159 | 160 | // 5. Return the result with verification info 161 | return { 162 | ...result, 163 | _verification: verificationResult 164 | }; 165 | } catch (error) { 166 | // Log the operation error 167 | await logOperation(operationId, 'error', { error: (error as Error).message }); 168 | throw error; 169 | } 170 | } 171 | 172 | // Document operations 173 | export async function getDocument( 174 | doctype: string, 175 | name: string, 176 | fields?: string[] 177 | ): Promise { 178 | if (!doctype) throw new Error("DocType is required"); 179 | if (!name) throw new Error("Document name is required"); 180 | 181 | const fieldsParam = fields ? `?fields=${JSON.stringify(fields)}` : ""; 182 | try { 183 | const response = await frappe.db().getDoc( 184 | doctype, 185 | name 186 | ); 187 | 188 | if (!response) { 189 | throw new Error(`Invalid response format for document ${doctype}/${name}`); 190 | } 191 | 192 | return response; 193 | } catch (error) { 194 | handleApiError(error, `get_document(${doctype}, ${name})`); 195 | } 196 | } 197 | 198 | export async function createDocument( 199 | doctype: string, 200 | values: Record 201 | ): Promise { 202 | try { 203 | if (!doctype) throw new Error("DocType is required"); 204 | if (!values || Object.keys(values).length === 0) { 205 | throw new Error("Document values are required"); 206 | } 207 | 208 | const response = await frappe.db().createDoc(doctype, values); 209 | 210 | if (!response) { 211 | throw new Error(`Invalid response format for creating ${doctype}`); 212 | } 213 | 214 | return response; 215 | } catch (error) { 216 | handleApiError(error, `create_document(${doctype})`); 217 | } 218 | } 219 | 220 | export async function updateDocument( 221 | doctype: string, 222 | name: string, 223 | values: Record 224 | ): Promise { 225 | try { 226 | if (!doctype) throw new Error("DocType is required"); 227 | if (!name) throw new Error("Document name is required"); 228 | if (!values || Object.keys(values).length === 0) { 229 | throw new Error("Update values are required"); 230 | } 231 | 232 | const response = await frappe.db().updateDoc(doctype, name, values); 233 | 234 | if (!response) { 235 | throw new Error(`Invalid response format for updating ${doctype}/${name}`); 236 | } 237 | 238 | return response; 239 | } catch (error) { 240 | handleApiError(error, `update_document(${doctype}, ${name})`); 241 | } 242 | } 243 | 244 | export async function deleteDocument( 245 | doctype: string, 246 | name: string 247 | ): Promise { 248 | try { 249 | if (!doctype) throw new Error("DocType is required"); 250 | if (!name) throw new Error("Document name is required"); 251 | 252 | const response = await frappe.db().deleteDoc(doctype, name); 253 | 254 | if (!response) { 255 | return response; 256 | } 257 | return response; 258 | 259 | } catch (error) { 260 | handleApiError(error, `delete_document(${doctype}, ${name})`); 261 | } 262 | } 263 | 264 | export async function listDocuments( 265 | doctype: string, 266 | filters?: Record, 267 | fields?: string[], 268 | limit?: number, 269 | order_by?: string, 270 | limit_start?: number 271 | ): Promise { 272 | try { 273 | if (!doctype) throw new Error("DocType is required"); 274 | 275 | const params: Record = {}; 276 | 277 | if (filters) params.filters = JSON.stringify(filters); 278 | if (fields) params.fields = JSON.stringify(fields); 279 | if (limit !== undefined) params.limit = limit.toString(); 280 | if (order_by) params.order_by = order_by; 281 | if (limit_start !== undefined) params.limit_start = limit_start.toString(); 282 | 283 | let orderByOption: { field: string; order?: "asc" | "desc" } | undefined = undefined; 284 | if (order_by) { 285 | const parts = order_by.trim().split(/\s+/); 286 | const field = parts[0]; 287 | const order = parts[1]?.toLowerCase() === "desc" ? "desc" : "asc"; 288 | orderByOption = { field, order }; 289 | } 290 | 291 | const optionsForGetDocList = { 292 | fields: fields, 293 | filters: filters as any[], 294 | orderBy: orderByOption, 295 | limit_start: limit_start, 296 | limit: limit 297 | }; 298 | 299 | try { 300 | const response = await frappe.db().getDocList(doctype, optionsForGetDocList as any); // Cast to any to resolve complex type issue for now, focusing on runtime 301 | 302 | if (!response) { 303 | throw new Error(`Invalid response format for listing ${doctype}`); 304 | } 305 | 306 | return response; 307 | } catch (sdkError) { 308 | // Re-throw the error to be handled by the existing handleApiError 309 | throw sdkError; 310 | } 311 | } catch (error) { 312 | handleApiError(error, `list_documents(${doctype})`); 313 | } 314 | } 315 | 316 | /** 317 | * Execute a Frappe method call 318 | * @param method The method name to call 319 | * @param params The parameters to pass to the method 320 | * @returns The method response 321 | */ 322 | export async function callMethod( 323 | method: string, 324 | params?: Record 325 | ): Promise { 326 | try { 327 | if (!method) throw new Error("Method name is required"); 328 | 329 | const response = await frappe.call().post(method, params); 330 | 331 | if (!response) { 332 | throw new Error(`Invalid response format for method ${method}`); 333 | } 334 | 335 | return response; 336 | } catch (error) { 337 | handleApiError(error, `call_method(${method})`); 338 | } 339 | } -------------------------------------------------------------------------------- /src/document-operations.ts: -------------------------------------------------------------------------------- 1 | import { Server } from "@modelcontextprotocol/sdk/server/index.js"; 2 | import { 3 | CallToolRequestSchema, 4 | ListToolsRequestSchema, 5 | } from "@modelcontextprotocol/sdk/types.js"; 6 | import { callMethod } from "./frappe-api.js"; 7 | import { 8 | createDocument, 9 | getDocument, 10 | updateDocument, 11 | deleteDocument, 12 | listDocuments, 13 | FrappeApiError 14 | } from "./frappe-api.js"; 15 | import { getRequiredFields, formatFilters } from "./frappe-helpers.js"; 16 | import { FRAPPE_INSTRUCTIONS } from "./frappe-instructions.js"; 17 | 18 | /** 19 | * Format error response with detailed information 20 | */ 21 | function formatErrorResponse(error: any, operation: string): any { 22 | // Include all error diagnostics directly in the response 23 | const apiKey = process.env.FRAPPE_API_KEY; 24 | const apiSecret = process.env.FRAPPE_API_SECRET; 25 | 26 | // Build a detailed diagnostic message 27 | let diagnostics = [ 28 | `Error in ${operation}`, 29 | `Error type: ${typeof error}`, 30 | `Constructor: ${error.constructor?.name || 'unknown'}`, 31 | `Is FrappeApiError: ${error instanceof FrappeApiError}`, 32 | `Error properties: ${Object.keys(error).join(', ')}`, 33 | `API Key available: ${!!apiKey}`, 34 | `API Secret available: ${!!apiSecret}` 35 | ].join('\n'); 36 | 37 | let errorMessage = ''; 38 | let errorDetails = null; 39 | 40 | // Check for missing credentials first as this is likely the issue 41 | if (!apiKey || !apiSecret) { 42 | errorMessage = `Authentication failed: ${!apiKey && !apiSecret ? 'Both API key and API secret are missing' : 43 | !apiKey ? 'API key is missing' : 'API secret is missing'}. API key/secret is the only supported authentication method.`; 44 | errorDetails = { 45 | error: "Missing credentials", 46 | apiKeyAvailable: !!apiKey, 47 | apiSecretAvailable: !!apiSecret, 48 | authMethod: "API key/secret (token)", 49 | diagnostics: diagnostics 50 | }; 51 | } 52 | // Then check if it's a FrappeApiError 53 | else if (error instanceof FrappeApiError) { 54 | errorMessage = error.message; 55 | // Include the full error object properties for debugging 56 | errorDetails = { 57 | statusCode: error.statusCode, 58 | endpoint: error.endpoint, 59 | details: error.details, 60 | message: error.message, 61 | name: error.name, 62 | stack: error.stack?.split('\n').slice(0, 3).join('\n'), 63 | diagnostics: diagnostics, 64 | authError: false // Initialize the property 65 | }; 66 | 67 | // If it's an authentication error, provide more specific guidance 68 | if (error.message.includes('Authentication') || 69 | error.message.includes('auth') || 70 | error.statusCode === 401 || 71 | error.statusCode === 403) { 72 | 73 | errorMessage = `Authentication error: ${error.message}. Please check your API key and secret.`; 74 | errorDetails.authError = true; 75 | } 76 | } 77 | // Check for Axios errors 78 | else if (error.isAxiosError) { 79 | errorMessage = `API request error: ${error.message}`; 80 | errorDetails = { 81 | status: error.response?.status, 82 | statusText: error.response?.statusText, 83 | url: error.config?.url, 84 | method: error.config?.method, 85 | diagnostics: diagnostics 86 | }; 87 | } 88 | // Default error handling 89 | else { 90 | errorMessage = `Error in ${operation}: ${error.message || 'Unknown error'}`; 91 | errorDetails = { 92 | diagnostics: diagnostics 93 | }; 94 | } 95 | 96 | return { 97 | content: [ 98 | { 99 | type: "text", 100 | text: errorMessage, 101 | }, 102 | ...(errorDetails ? [ 103 | { 104 | type: "text", 105 | text: `\nDetails: ${JSON.stringify(errorDetails, null, 2)}`, 106 | } 107 | ] : []) 108 | ], 109 | isError: true, 110 | }; 111 | } 112 | 113 | /** 114 | * Validate document values against required fields 115 | */ 116 | async function validateDocumentValues(doctype: string, values: Record): Promise { 117 | try { 118 | const requiredFields = await getRequiredFields(doctype); 119 | const missingFields = requiredFields 120 | .filter(field => !values.hasOwnProperty(field.fieldname)) 121 | .map(field => field.fieldname); 122 | 123 | return missingFields; 124 | } catch (error) { 125 | console.error(`Error validating document values for ${doctype}:`, error); 126 | return []; // Return empty array on error to avoid blocking the operation 127 | } 128 | } 129 | 130 | // Define document tools 131 | export const DOCUMENT_TOOLS = [ 132 | { 133 | name: "create_document", 134 | description: "Create a new document in Frappe", 135 | inputSchema: { 136 | type: "object", 137 | properties: { 138 | doctype: { type: "string", description: "DocType name" }, 139 | values: { 140 | type: "object", 141 | description: "Document field values. Required fields must be included. For Link fields, provide the exact document name. For Table fields, provide an array of row objects.", 142 | additionalProperties: true 143 | }, 144 | }, 145 | required: ["doctype", "values"], 146 | }, 147 | }, 148 | { 149 | name: "get_document", 150 | description: "Retrieve a document from Frappe", 151 | inputSchema: { 152 | type: "object", 153 | properties: { 154 | doctype: { type: "string", description: "DocType name" }, 155 | name: { type: "string", description: "Document name (case-sensitive)" }, 156 | fields: { 157 | type: "array", 158 | items: { type: "string" }, 159 | description: "Fields to retrieve (optional). If not specified, all fields will be returned.", 160 | }, 161 | }, 162 | required: ["doctype", "name"], 163 | }, 164 | }, 165 | { 166 | name: "update_document", 167 | description: "Update an existing document in Frappe", 168 | inputSchema: { 169 | type: "object", 170 | properties: { 171 | doctype: { type: "string", description: "DocType name" }, 172 | name: { type: "string", description: "Document name (case-sensitive)" }, 173 | values: { 174 | type: "object", 175 | description: "Document field values to update. Only include fields that need to be updated. For Table fields, provide the entire table data including row IDs for existing rows.", 176 | additionalProperties: true 177 | }, 178 | }, 179 | required: ["doctype", "name", "values"], 180 | }, 181 | }, 182 | { 183 | name: "delete_document", 184 | description: "Delete a document from Frappe", 185 | inputSchema: { 186 | type: "object", 187 | properties: { 188 | doctype: { type: "string", description: "DocType name" }, 189 | name: { type: "string", description: "Document name (case-sensitive)" }, 190 | }, 191 | required: ["doctype", "name"], 192 | }, 193 | }, 194 | { 195 | name: "list_documents", 196 | description: "List documents from Frappe with filters", 197 | inputSchema: { 198 | type: "object", 199 | properties: { 200 | doctype: { type: "string", description: "DocType name" }, 201 | filters: { 202 | type: "object", 203 | description: "Filters to apply (optional). Simple format: {\"field\": \"value\"} or with operators: {\"field\": [\">\", \"value\"]}. Available operators: =, !=, <, >, <=, >=, like, not like, in, not in, is, is not, between.", 204 | additionalProperties: true 205 | }, 206 | fields: { 207 | type: "array", 208 | items: { type: "string" }, 209 | description: "Fields to retrieve (optional). For better performance, specify only the fields you need.", 210 | }, 211 | limit: { 212 | type: "number", 213 | description: "Maximum number of documents to retrieve (optional). Use with limit_start for pagination.", 214 | }, 215 | limit_start: { 216 | type: "number", 217 | description: "Starting offset for pagination (optional). Use with limit for pagination.", 218 | }, 219 | order_by: { 220 | type: "string", 221 | description: "Field to order by (optional). Format: \"field_name asc\" or \"field_name desc\".", 222 | }, 223 | }, 224 | required: ["doctype"], 225 | }, 226 | }, 227 | { 228 | name: "reconcile_bank_transaction_with_vouchers", 229 | description: "Reconciles a Bank Transaction document with specified vouchers by calling a specific Frappe method.", 230 | inputSchema: { 231 | type: "object", 232 | properties: { 233 | bank_transaction_name: { 234 | type: "string", 235 | description: "The ID (name) of the Bank Transaction document to reconcile.", 236 | }, 237 | vouchers: { 238 | type: "array", 239 | description: "An array of voucher objects to reconcile against the bank transaction.", 240 | items: { 241 | type: "object", 242 | properties: { 243 | payment_doctype: { 244 | type: "string", 245 | description: "The DocType of the payment voucher (e.g., Payment Entry, Journal Entry).", 246 | }, 247 | payment_name: { 248 | type: "string", 249 | description: "The ID (name) of the payment voucher document.", 250 | }, 251 | amount: { 252 | type: "number", 253 | description: "The amount from the voucher to reconcile.", 254 | }, 255 | }, 256 | required: ["payment_doctype", "payment_name", "amount"], 257 | }, 258 | }, 259 | }, 260 | required: ["bank_transaction_name", "vouchers"], 261 | }, 262 | }, 263 | ]; 264 | 265 | // Export a handler function for document tool calls 266 | export async function handleDocumentToolCall(request: any): Promise { 267 | const { name, arguments: args } = request.params; 268 | 269 | if (!args) { 270 | return { 271 | content: [ 272 | { 273 | type: "text", 274 | text: "Missing arguments for tool call", 275 | }, 276 | ], 277 | isError: true, 278 | }; 279 | } 280 | 281 | try { 282 | console.error("Handling document tool:", name, "with args:", args); 283 | 284 | // Handle document operations 285 | if (name === "create_document") { 286 | const doctype = args.doctype as string; 287 | const values = args.values as Record; 288 | 289 | if (!doctype || !values) { 290 | return { 291 | content: [ 292 | { 293 | type: "text", 294 | text: "Missing required parameters: doctype and values", 295 | }, 296 | ], 297 | isError: true, 298 | }; 299 | } 300 | 301 | // Validate required fields 302 | const missingFields = await validateDocumentValues(doctype, values); 303 | if (missingFields.length > 0) { 304 | return { 305 | content: [ 306 | { 307 | type: "text", 308 | text: `Missing required fields: ${missingFields.join(', ')}`, 309 | }, 310 | { 311 | type: "text", 312 | text: "\nTip: Use get_required_fields tool to see all required fields for this DocType.", 313 | }, 314 | ], 315 | isError: true, 316 | }; 317 | } 318 | 319 | try { 320 | console.error(`Calling createDocument for ${doctype} with values:`, JSON.stringify(values, null, 2)); 321 | 322 | let result; 323 | let authMethod = "token"; 324 | let verificationSuccess = false; 325 | let verificationMessage = ""; 326 | 327 | // Use API key/secret authentication 328 | result = await createDocument(doctype, values); 329 | console.error(`Result from createDocument (API key/secret auth):`, JSON.stringify(result, null, 2)); 330 | authMethod = "api_key"; 331 | 332 | // Check for verification result 333 | if (result._verification && result._verification.success === false) { 334 | verificationSuccess = false; 335 | verificationMessage = result._verification.message; 336 | delete result._verification; // Remove internal property before returning to client 337 | } else { 338 | verificationSuccess = true; 339 | } 340 | 341 | // IMPROVED: Return error if verification failed 342 | if (!verificationSuccess) { 343 | return { 344 | content: [ 345 | { 346 | type: "text", 347 | text: `Error: Document creation reported success but verification failed. The document may not have been created.\n\nDetails: ${verificationMessage}`, 348 | }, 349 | ], 350 | isError: true, 351 | }; 352 | } 353 | 354 | return { 355 | content: [ 356 | { 357 | type: "text", 358 | text: `Document created successfully using ${authMethod} authentication:\n\n${JSON.stringify(result, null, 2)}`, 359 | }, 360 | ], 361 | }; 362 | } catch (error) { 363 | console.error(`Error in create_document handler:`, error); 364 | return formatErrorResponse(error, `create_document(${doctype})`); 365 | } 366 | } else if (name === "get_document") { 367 | const doctype = args.doctype as string; 368 | const docName = args.name as string; 369 | const fields = args.fields as string[] | undefined; 370 | 371 | if (!doctype || !docName) { 372 | return { 373 | content: [ 374 | { 375 | type: "text", 376 | text: "Missing required parameters: doctype and name", 377 | }, 378 | ], 379 | isError: true, 380 | }; 381 | } 382 | 383 | try { 384 | let document; 385 | let authMethod = "token"; 386 | 387 | // Use API key/secret authentication 388 | document = await getDocument(doctype, docName, fields); 389 | console.error(`Retrieved document using API key/secret auth:`, JSON.stringify(document, null, 2)); 390 | authMethod = "api_key"; 391 | 392 | return { 393 | content: [ 394 | { 395 | type: "text", 396 | text: `Document retrieved using ${authMethod} authentication:\n\n${JSON.stringify(document, null, 2)}`, 397 | }, 398 | ], 399 | }; 400 | } catch (error) { 401 | return formatErrorResponse(error, `get_document(${doctype}, ${docName})`); 402 | } 403 | } else if (name === "update_document") { 404 | const doctype = args.doctype as string; 405 | const docName = args.name as string; 406 | const values = args.values as Record; 407 | 408 | if (!doctype || !docName || !values) { 409 | return { 410 | content: [ 411 | { 412 | type: "text", 413 | text: "Missing required parameters: doctype, name, and values", 414 | }, 415 | ], 416 | isError: true, 417 | }; 418 | } 419 | 420 | try { 421 | let result; 422 | let authMethod = "token"; 423 | 424 | // Use API key/secret authentication 425 | result = await updateDocument(doctype, docName, values); 426 | console.error(`Result from updateDocument (API key/secret auth):`, JSON.stringify(result, null, 2)); 427 | authMethod = "api_key"; 428 | 429 | return { 430 | content: [ 431 | { 432 | type: "text", 433 | text: `Document updated successfully using ${authMethod} authentication:\n\n${JSON.stringify(result, null, 2)}`, 434 | }, 435 | ], 436 | }; 437 | } catch (error) { 438 | return formatErrorResponse(error, `update_document(${doctype}, ${docName})`); 439 | } 440 | } else if (name === "delete_document") { 441 | const doctype = args.doctype as string; 442 | const docName = args.name as string; 443 | 444 | if (!doctype || !docName) { 445 | return { 446 | content: [ 447 | { 448 | type: "text", 449 | text: "Missing required parameters: doctype and name", 450 | }, 451 | ], 452 | isError: true, 453 | }; 454 | } 455 | 456 | try { 457 | let authMethod = "token"; 458 | 459 | // Use API key/secret authentication 460 | await deleteDocument(doctype, docName); 461 | console.error(`Document deleted using API key/secret auth`); 462 | authMethod = "api_key"; 463 | 464 | return { 465 | content: [ 466 | { 467 | type: "text", 468 | text: JSON.stringify({ 469 | success: true, 470 | message: `Document ${doctype}/${docName} deleted successfully using ${authMethod} authentication` 471 | }, null, 2), 472 | }, 473 | ], 474 | }; 475 | } catch (error) { 476 | return formatErrorResponse(error, `delete_document(${doctype}, ${docName})`); 477 | } 478 | } else if (name === "list_documents") { 479 | const doctype = args.doctype as string; 480 | const filters = args.filters as Record | undefined; 481 | const fields = args.fields as string[] | undefined; 482 | const limit = args.limit as number | undefined; 483 | const order_by = args.order_by as string | undefined; 484 | const limit_start = args.limit_start as number | undefined; 485 | 486 | if (!doctype) { 487 | return { 488 | content: [ 489 | { 490 | type: "text", 491 | text: "Missing required parameter: doctype", 492 | }, 493 | ], 494 | isError: true, 495 | }; 496 | } 497 | 498 | try { 499 | // Format filters if provided 500 | const formattedFilters = filters ? formatFilters(filters) : undefined; 501 | 502 | let documents; 503 | let authMethod = "token"; 504 | 505 | // Use API key/secret authentication 506 | documents = await listDocuments( 507 | doctype, 508 | formattedFilters, 509 | fields, 510 | limit, 511 | order_by, 512 | limit_start 513 | ); 514 | console.error(`Retrieved ${documents.length} documents using API key/secret auth`); 515 | authMethod = "api_key"; 516 | 517 | // Add pagination info if applicable 518 | let paginationInfo = ""; 519 | if (limit) { 520 | const startIndex = limit_start || 0; 521 | const endIndex = startIndex + documents.length; 522 | paginationInfo = `\n\nShowing items ${startIndex + 1}-${endIndex}`; 523 | 524 | if (documents.length === limit) { 525 | paginationInfo += ` (more items may be available, use limit_start=${endIndex} to see next page)`; 526 | } 527 | } 528 | 529 | return { 530 | content: [ 531 | { 532 | type: "text", 533 | text: `Documents retrieved using ${authMethod} authentication:\n\n${JSON.stringify(documents, null, 2)}${paginationInfo}`, 534 | }, 535 | ], 536 | }; 537 | } catch (error) { 538 | return formatErrorResponse(error, `list_documents(${doctype})`); 539 | } 540 | } else if (name === "reconcile_bank_transaction_with_vouchers") { 541 | const bankTransactionName = args.bank_transaction_name as string; 542 | const vouchers = args.vouchers as Array<{ payment_doctype: string; payment_name: string; amount: number }>; 543 | 544 | if (!bankTransactionName || !vouchers) { 545 | return { 546 | content: [ 547 | { 548 | type: "text", 549 | text: "Missing required parameters: bank_transaction_name and vouchers", 550 | }, 551 | ], 552 | isError: true, 553 | }; 554 | } 555 | if (!Array.isArray(vouchers) || vouchers.some(v => !v.payment_doctype || !v.payment_name || typeof v.amount !== 'number')) { 556 | return { 557 | content: [ 558 | { 559 | type: "text", 560 | text: "Invalid format for 'vouchers' parameter. It must be an array of objects, each with 'payment_doctype' (string), 'payment_name' (string), and 'amount' (number).", 561 | }, 562 | ], 563 | isError: true, 564 | }; 565 | } 566 | 567 | try { 568 | const frappeMethod = "erpnext.accounts.doctype.bank_reconciliation_tool.bank_reconciliation_tool.reconcile_vouchers"; 569 | const params = { 570 | bank_transaction_name: bankTransactionName, 571 | vouchers: JSON.stringify(vouchers), // Frappe method expects vouchers as a JSON string 572 | }; 573 | 574 | console.error(`Calling Frappe method '${frappeMethod}' with params:`, JSON.stringify(params, null, 2)); 575 | const result = await callMethod(frappeMethod, params); 576 | console.error(`Result from '${frappeMethod}':`, JSON.stringify(result, null, 2)); 577 | 578 | return { 579 | content: [ 580 | { 581 | type: "text", 582 | text: `Bank transaction '${bankTransactionName}' reconciled successfully with vouchers:\n\n${JSON.stringify(result, null, 2)}`, 583 | }, 584 | ], 585 | }; 586 | } catch (error) { 587 | console.error(`Error in reconcile_bank_transaction_with_vouchers handler:`, error); 588 | return formatErrorResponse(error, `reconcile_bank_transaction_with_vouchers(${bankTransactionName})`); 589 | } 590 | } 591 | 592 | 593 | return { 594 | content: [ 595 | { 596 | type: "text", 597 | text: `Document operations module doesn't handle tool: ${name}`, 598 | }, 599 | ], 600 | isError: true, 601 | }; 602 | } catch (error) { 603 | return formatErrorResponse(error, `document_operations.${name}`); 604 | } 605 | } 606 | 607 | export function setupDocumentTools(server: Server): void { 608 | // We no longer register tools here 609 | // Tools are now registered in the central handler in index.ts 610 | 611 | // This function is kept as a no-op to prevent import errors 612 | console.error("Document tools are now registered in the central handler in index.ts"); 613 | } 614 | 615 | /** 616 | * Handle call_method tool call 617 | */ 618 | export async function handleCallMethodToolCall(request: any): Promise { 619 | const { name, arguments: args } = request.params; 620 | 621 | if (!args) { 622 | return { 623 | content: [ 624 | { 625 | type: "text", 626 | text: "Missing arguments for tool call", 627 | }, 628 | ], 629 | isError: true, 630 | }; 631 | } 632 | 633 | try { 634 | console.error(`Handling call_method tool with args:`, args); 635 | const method = args.method as string; 636 | const params = args.params as Record | undefined; 637 | 638 | if (!method) { 639 | return { 640 | content: [ 641 | { 642 | type: "text", 643 | text: "Missing required parameter: method", 644 | }, 645 | ], 646 | isError: true, 647 | }; 648 | } 649 | 650 | const result = await callMethod(method, params); 651 | return { 652 | content: [ 653 | { 654 | type: "text", 655 | text: `Method ${method} called successfully:\n\n${JSON.stringify(result, null, 2)}`, 656 | }, 657 | ], 658 | }; 659 | } catch (error) { 660 | return formatErrorResponse(error, `call_method(${name})`); 661 | } 662 | } -------------------------------------------------------------------------------- /src/errors.ts: -------------------------------------------------------------------------------- 1 | import { AxiosError } from "axios"; 2 | 3 | /** 4 | * Error class for Frappe API errors 5 | */ 6 | export class FrappeApiError extends Error { 7 | statusCode?: number; 8 | endpoint?: string; 9 | details?: any; 10 | 11 | constructor(message: string, statusCode?: number, endpoint?: string, details?: any) { 12 | super(message); 13 | this.name = "FrappeApiError"; 14 | this.statusCode = statusCode; 15 | this.endpoint = endpoint; 16 | this.details = details; 17 | } 18 | 19 | static fromAxiosError(error: AxiosError, operation: string): FrappeApiError { 20 | const statusCode = error.response?.status; 21 | const endpoint = error.config?.url || "unknown"; 22 | let message = `Frappe API error during ${operation}: ${error.message}`; 23 | let details = null; 24 | 25 | // Check for connection errors first (no response) 26 | if (!error.response) { 27 | message = `Network error during ${operation}: ${error.message}`; 28 | details = { error: "Network error", code: error.code }; 29 | } 30 | // Extract more detailed error information from Frappe's response 31 | else if (error.response) { 32 | const data = error.response.data as any; 33 | 34 | if (error.response.status === 401 || error.response.status === 403) { 35 | message = `Authentication failed during ${operation}. Check API key/secret.`; 36 | details = { 37 | error: "Authentication Error", 38 | status: error.response.status, 39 | statusText: error.response.statusText, 40 | responseData: data 41 | }; 42 | } else if (data.exception) { 43 | message = `Frappe exception during ${operation}: ${data.exception}`; 44 | details = data; 45 | } else if (data._server_messages) { 46 | try { 47 | // Server messages are often JSON strings inside a string 48 | const serverMessages = JSON.parse(data._server_messages); 49 | const parsedMessages = Array.isArray(serverMessages) 50 | ? serverMessages.map((msg: string) => { 51 | try { 52 | return JSON.parse(msg); 53 | } catch { 54 | return msg; 55 | } 56 | }) 57 | : [serverMessages]; 58 | 59 | message = `Frappe server message during ${operation}: ${parsedMessages.map((m: any) => m.message || m).join("; ")}`; 60 | details = { serverMessages: parsedMessages }; 61 | } catch (e) { 62 | message = `Frappe server message during ${operation}: ${data._server_messages}`; 63 | details = { serverMessages: data._server_messages }; 64 | } 65 | } else if (data.message) { 66 | message = `Frappe API error during ${operation}: ${data.message}`; 67 | details = data; 68 | } 69 | } 70 | 71 | return new FrappeApiError(message, statusCode, endpoint, details); 72 | } 73 | } 74 | 75 | /** 76 | * Helper function to handle API errors 77 | */ 78 | export function handleApiError(error: any, operation: string): never { 79 | if (error.isAxiosError) { 80 | throw FrappeApiError.fromAxiosError(error, operation); 81 | } else { 82 | throw new FrappeApiError( 83 | `Error during ${operation}: ${(error as Error).message || 'Unknown error'}`, 84 | undefined, // statusCode 85 | undefined, // endpoint 86 | error // Pass original error as details 87 | ); 88 | } 89 | } -------------------------------------------------------------------------------- /src/frappe-api.ts: -------------------------------------------------------------------------------- 1 | // Re-export everything from the new modular files 2 | export { FrappeApiError, handleApiError } from './errors.js'; 3 | export { frappe } from './api-client.js'; 4 | export { checkFrappeApiHealth } from './auth.js'; 5 | export { 6 | getDocument, 7 | createDocument, 8 | updateDocument, 9 | deleteDocument, 10 | listDocuments, 11 | callMethod 12 | } from './document-api.js'; 13 | export { 14 | getDocTypeSchema, 15 | getFieldOptions, 16 | getAllDocTypes, 17 | getAllModules 18 | } from './schema-api.js'; -------------------------------------------------------------------------------- /src/frappe-helpers.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Helper functions for interacting with the Frappe API 3 | * These functions provide additional functionality and better error handling 4 | */ 5 | 6 | import axios, { AxiosError } from "axios"; 7 | import { getDocument, listDocuments, getDocTypeSchema } from "./frappe-api.js"; 8 | 9 | /** 10 | * Error class for Frappe API errors with improved details 11 | */ 12 | export class FrappeApiError extends Error { 13 | statusCode?: number; 14 | endpoint?: string; 15 | details?: any; 16 | 17 | constructor(message: string, statusCode?: number, endpoint?: string, details?: any) { 18 | super(message); 19 | this.name = "FrappeApiError"; 20 | this.statusCode = statusCode; 21 | this.endpoint = endpoint; 22 | this.details = details; 23 | } 24 | 25 | static fromAxiosError(error: AxiosError, operation: string): FrappeApiError { 26 | const statusCode = error.response?.status; 27 | const endpoint = error.config?.url || "unknown"; 28 | let message = `Frappe API error during ${operation}: ${error.message}`; 29 | let details = null; 30 | 31 | // Extract more detailed error information from Frappe's response 32 | if (error.response?.data) { 33 | const data = error.response.data as any; 34 | if (data.exception) { 35 | message = `Frappe exception during ${operation}: ${data.exception}`; 36 | details = data; 37 | } else if (data._server_messages) { 38 | try { 39 | // Server messages are often JSON strings inside a string 40 | const serverMessages = JSON.parse(data._server_messages); 41 | const parsedMessages = Array.isArray(serverMessages) 42 | ? serverMessages.map(msg => { 43 | try { 44 | return JSON.parse(msg); 45 | } catch { 46 | return msg; 47 | } 48 | }) 49 | : [serverMessages]; 50 | 51 | message = `Frappe server message during ${operation}: ${parsedMessages.map(m => m.message || m).join("; ")}`; 52 | details = { serverMessages: parsedMessages }; 53 | } catch (e) { 54 | message = `Frappe server message during ${operation}: ${data._server_messages}`; 55 | details = { serverMessages: data._server_messages }; 56 | } 57 | } else if (data.message) { 58 | message = `Frappe API error during ${operation}: ${data.message}`; 59 | details = data; 60 | } 61 | } 62 | 63 | return new FrappeApiError(message, statusCode, endpoint, details); 64 | } 65 | } 66 | 67 | /** 68 | * Check if a DocType exists 69 | * @param doctype The DocType name to check 70 | * @returns True if the DocType exists, false otherwise 71 | */ 72 | export async function doesDocTypeExist(doctype: string): Promise { 73 | try { 74 | await getDocTypeSchema(doctype); 75 | return true; 76 | } catch (error) { 77 | if (error instanceof Error && 78 | (error.message.includes("not found") || 79 | error.message.includes("does not exist"))) { 80 | return false; 81 | } 82 | throw error; // Re-throw other errors 83 | } 84 | } 85 | 86 | /** 87 | * Check if a document exists 88 | * @param doctype The DocType name 89 | * @param name The document name 90 | * @returns True if the document exists, false otherwise 91 | */ 92 | export async function doesDocumentExist(doctype: string, name: string): Promise { 93 | try { 94 | await getDocument(doctype, name, ["name"]); 95 | return true; 96 | } catch (error) { 97 | if (error instanceof Error && 98 | (error.message.includes("not found") || 99 | error.message.includes("does not exist"))) { 100 | return false; 101 | } 102 | throw error; // Re-throw other errors 103 | } 104 | } 105 | 106 | /** 107 | * Find DocTypes matching a search term 108 | * @param searchTerm The search term to look for in DocType names 109 | * @param options Additional options for the search 110 | * @returns Array of matching DocTypes with their details 111 | */ 112 | export async function findDocTypes( 113 | searchTerm: string, 114 | options: { 115 | module?: string; 116 | isTable?: boolean; 117 | isSingle?: boolean; 118 | isCustom?: boolean; 119 | limit?: number; 120 | } = {} 121 | ): Promise { 122 | const filters: Record = {}; 123 | 124 | // Add name search filter 125 | if (searchTerm) { 126 | filters.name = ["like", `%${searchTerm}%`]; 127 | } 128 | 129 | // Add optional filters 130 | if (options.module !== undefined) { 131 | filters.module = options.module; 132 | } 133 | 134 | if (options.isTable !== undefined) { 135 | filters.istable = options.isTable ? 1 : 0; 136 | } 137 | 138 | if (options.isSingle !== undefined) { 139 | filters.issingle = options.isSingle ? 1 : 0; 140 | } 141 | 142 | if (options.isCustom !== undefined) { 143 | filters.custom = options.isCustom ? 1 : 0; 144 | } 145 | 146 | return await listDocuments( 147 | "DocType", 148 | filters, 149 | ["name", "module", "description", "istable", "issingle", "custom"], 150 | options.limit || 20 151 | ); 152 | } 153 | 154 | /** 155 | * Get a list of all modules in the system 156 | * @returns Array of module names 157 | */ 158 | export async function getModuleList(): Promise { 159 | try { 160 | const modules = await listDocuments( 161 | "Module Def", 162 | {}, 163 | ["name", "module_name"], 164 | 100 165 | ); 166 | 167 | return modules.map(m => m.name || m.module_name); 168 | } catch (error) { 169 | console.error("Error fetching module list:", error); 170 | throw new FrappeApiError( 171 | `Failed to fetch module list: ${(error as Error).message}` 172 | ); 173 | } 174 | } 175 | 176 | /** 177 | * Get a list of DocTypes in a specific module 178 | * @param module The module name 179 | * @returns Array of DocTypes in the module 180 | */ 181 | export async function getDocTypesInModule(module: string): Promise { 182 | return await listDocuments( 183 | "DocType", 184 | { module: module }, 185 | ["name", "description", "istable", "issingle", "custom"], 186 | 100 187 | ); 188 | } 189 | 190 | /** 191 | * Get a count of documents matching filters 192 | * @param doctype The DocType name 193 | * @param filters Filters to apply 194 | * @returns The count of matching documents 195 | */ 196 | export async function getDocumentCount( 197 | doctype: string, 198 | filters: Record = {} 199 | ): Promise { 200 | try { 201 | // Use count(*) to get the total count of documents 202 | const result = await listDocuments( 203 | doctype, 204 | filters, 205 | ["count(name) as total_count"], 206 | 1 207 | ); 208 | 209 | // Extract the count from the result 210 | if (result && result.length > 0 && result[0].total_count !== undefined) { 211 | return result[0].total_count; 212 | } 213 | 214 | // Fallback: make another request to get all IDs and count them 215 | // This is less efficient but should work if the count query fails 216 | console.error(`Count query didn't return expected result, using fallback method`); 217 | const allIds = await listDocuments(doctype, filters, ["name"]); 218 | return allIds.length; 219 | } catch (error) { 220 | console.error(`Error getting document count for ${doctype}:`, error); 221 | throw new FrappeApiError( 222 | `Failed to get document count for ${doctype}: ${(error as Error).message}` 223 | ); 224 | } 225 | } 226 | 227 | /** 228 | * Get the naming series for a DocType 229 | * @param doctype The DocType name 230 | * @returns The naming series information or null if not applicable 231 | */ 232 | export async function getNamingSeriesInfo(doctype: string): Promise { 233 | try { 234 | const schema = await getDocTypeSchema(doctype); 235 | 236 | // Return naming information from the schema 237 | return { 238 | autoname: schema.autoname, 239 | namingSeriesField: schema.fields.find((f: any) => f.fieldname === "naming_series"), 240 | isAutoNamed: !!schema.autoname && schema.autoname !== "prompt", 241 | isPromptNamed: schema.autoname === "prompt", 242 | hasNamingSeries: schema.fields.some((f: any) => f.fieldname === "naming_series") 243 | }; 244 | } catch (error) { 245 | console.error(`Error getting naming series for ${doctype}:`, error); 246 | throw new FrappeApiError( 247 | `Failed to get naming series for ${doctype}: ${(error as Error).message}` 248 | ); 249 | } 250 | } 251 | 252 | /** 253 | * Format filters for Frappe API 254 | * This helper converts various filter formats to the format expected by the API 255 | * @param filters The filters in various formats 256 | * @returns Properly formatted filters for the API 257 | */ 258 | export function formatFilters(filters: any): any { 259 | if (!filters) return {}; 260 | 261 | // If already in the correct format, return as is 262 | if (Array.isArray(filters) && filters.every(f => Array.isArray(f))) { 263 | return filters; 264 | } 265 | 266 | // If it's an object, convert to the array format 267 | if (typeof filters === 'object' && !Array.isArray(filters)) { 268 | const formattedFilters = []; 269 | 270 | for (const [field, value] of Object.entries(filters)) { 271 | if (Array.isArray(value) && value.length === 2 && 272 | typeof value[0] === 'string' && ['=', '!=', '<', '>', '<=', '>=', 'like', 'not like', 'in', 'not in', 'is', 'is not', 'between'].includes(value[0])) { 273 | // It's already in [operator, value] format 274 | formattedFilters.push([field, value[0], value[1]]); 275 | } else { 276 | // It's a simple equality filter 277 | formattedFilters.push([field, '=', value]); 278 | } 279 | } 280 | 281 | return formattedFilters; 282 | } 283 | 284 | // Return as is for other cases 285 | return filters; 286 | } 287 | 288 | /** 289 | * Get field metadata for a specific field in a DocType 290 | * @param doctype The DocType name 291 | * @param fieldname The field name 292 | * @returns The field metadata or null if not found 293 | */ 294 | export async function getFieldMetadata(doctype: string, fieldname: string): Promise { 295 | try { 296 | const schema = await getDocTypeSchema(doctype); 297 | 298 | if (!schema || !schema.fields) { 299 | throw new Error(`Could not get schema for DocType ${doctype}`); 300 | } 301 | 302 | const field = schema.fields.find((f: any) => f.fieldname === fieldname); 303 | return field || null; 304 | } catch (error) { 305 | console.error(`Error getting field metadata for ${doctype}.${fieldname}:`, error); 306 | throw new FrappeApiError( 307 | `Failed to get field metadata for ${doctype}.${fieldname}: ${(error as Error).message}` 308 | ); 309 | } 310 | } 311 | 312 | /** 313 | * Get required fields for a DocType 314 | * @param doctype The DocType name 315 | * @returns Array of required field names and their metadata 316 | */ 317 | export async function getRequiredFields(doctype: string): Promise { 318 | try { 319 | const schema = await getDocTypeSchema(doctype); 320 | 321 | if (!schema || !schema.fields) { 322 | throw new Error(`Could not get schema for DocType ${doctype}`); 323 | } 324 | 325 | return schema.fields.filter((f: any) => f.reqd); 326 | } catch (error) { 327 | console.error(`Error getting required fields for ${doctype}:`, error); 328 | throw new FrappeApiError( 329 | `Failed to get required fields for ${doctype}: ${(error as Error).message}` 330 | ); 331 | } 332 | } -------------------------------------------------------------------------------- /src/frappe-instructions.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * This file contains detailed instructions and examples for using the Frappe API 3 | * through the MCP server. It provides guidance on common operations and best practices. 4 | */ 5 | 6 | /** 7 | * Common Frappe DocTypes 8 | * 9 | * These are some of the standard DocTypes in Frappe that you might want to interact with: 10 | * 11 | * - User: User accounts in the system 12 | * - Role: User roles for permission management 13 | * - DocType: Metadata about document types 14 | * - DocField: Field definitions for DocTypes 15 | * - DocPerm: Permission rules for DocTypes 16 | * - Custom Field: Custom fields added to DocTypes 17 | * - Custom Script: Client-side scripts for DocTypes 18 | * - Server Script: Server-side scripts for automation 19 | * - Workflow: Workflow definitions 20 | * - Workflow State: States in a workflow 21 | * - Workflow Action: Actions that transition between workflow states 22 | */ 23 | 24 | export const COMMON_DOCTYPES = { 25 | SYSTEM: [ 26 | 'User', 'Role', 'DocType', 'DocField', 'DocPerm', 'Module Def', 27 | 'Custom Field', 'Custom Script', 'Server Script', 'Client Script', 28 | 'Property Setter', 'Print Format', 'Report', 'Page', 'Workflow', 29 | 'Workflow State', 'Workflow Action' 30 | ], 31 | CORE: [ 32 | 'File', 'Note', 'ToDo', 'Tag', 'Email Queue', 'Email Template', 33 | 'Notification', 'Event', 'Comment', 'Activity Log' 34 | ] 35 | }; 36 | 37 | /** 38 | * Frappe API Usage Instructions 39 | * 40 | * This object contains detailed instructions for common Frappe operations. 41 | * Each instruction includes a description, example usage, and tips. 42 | */ 43 | export const FRAPPE_INSTRUCTIONS = { 44 | // Document Operations 45 | DOCUMENT_OPERATIONS: { 46 | CREATE: { 47 | description: "Create a new document in Frappe", 48 | usage: ` 49 | To create a new document, use the create_document tool with the DocType name and field values: 50 | 51 | Example: 52 | { 53 | "doctype": "ToDo", 54 | "values": { 55 | "description": "Complete the project", 56 | "priority": "Medium", 57 | "status": "Open" 58 | } 59 | } 60 | 61 | Tips: 62 | - Required fields must be included in the values 63 | - For Link fields, provide the exact document name as the value 64 | - For Table fields, provide an array of row objects. **Do not create child documents separately before adding them to the parent document's table field.** 65 | - Child table rows should include all required fields. 66 | - The system will automatically set owner, creation, and modified fields. 67 | - For documents with a naming series (autoname is "naming_series"), do not include the "name" field in the values. The system will generate the name automatically. 68 | `, 69 | }, 70 | GET: { 71 | description: "Retrieve a document from Frappe", 72 | usage: ` 73 | To get a document, use the get_document tool with the DocType name and document name: 74 | 75 | Example: 76 | { 77 | "doctype": "ToDo", 78 | "name": "TODO-0001", 79 | "fields": ["description", "status", "priority"] // Optional: specific fields to retrieve 80 | } 81 | 82 | Tips: 83 | - If fields are not specified, all fields will be returned 84 | - The document name is case-sensitive 85 | - For standard naming, use the format [DocType]-[Number] (e.g., TODO-0001) 86 | - For documents with custom naming, use the exact document name 87 | `, 88 | }, 89 | UPDATE: { 90 | description: "Update an existing document in Frappe", 91 | usage: ` 92 | To update a document, use the update_document tool with the DocType name, document name, and values to update: 93 | 94 | Example: 95 | { 96 | "doctype": "ToDo", 97 | "name": "TODO-0001", 98 | "values": { 99 | "status": "Completed", 100 | "priority": "High" 101 | } 102 | } 103 | 104 | Tips: 105 | - Only include fields that need to be updated. 106 | - For Table fields, you need to provide the entire table data, not just the changed rows. **When updating child documents, include the 'name' field for existing rows. Do not attempt to update child documents by creating them separately.** 107 | - The system will automatically update the modified and modified_by fields. 108 | - Some fields may be read-only and cannot be updated. 109 | `, 110 | }, 111 | DELETE: { 112 | description: "Delete a document from Frappe", 113 | usage: ` 114 | To delete a document, use the delete_document tool with the DocType name and document name: 115 | 116 | Example: 117 | { 118 | "doctype": "ToDo", 119 | "name": "TODO-0001" 120 | } 121 | 122 | Tips: 123 | - Deletion may fail if there are dependent documents 124 | - Some documents may be set as not deletable in their DocType configuration 125 | - Deletion permissions are controlled by DocPerm records 126 | `, 127 | }, 128 | LIST: { 129 | description: "List documents from Frappe with filters", 130 | usage: ` 131 | To list documents, use the list_documents tool with the DocType name and optional filters: 132 | 133 | Example: 134 | { 135 | "doctype": "ToDo", 136 | "filters": { 137 | "status": "Open", 138 | "priority": "High" 139 | }, 140 | "fields": ["name", "description", "status"], 141 | "limit": 10, 142 | "limit_start": 0, 143 | "order_by": "creation desc" 144 | } 145 | 146 | Filter Formats: 147 | 1. Simple equality: { "status": "Open" } 148 | 2. Operators: { "creation": [">=", "2023-01-01"] } 149 | 3. Multiple conditions: { "status": "Open", "priority": "High" } 150 | 4. OR conditions: [ ["status", "=", "Open"], ["status", "=", "In Progress"] ] 151 | 152 | Available operators: 153 | - "=", "!=", "<", ">", "<=", ">=", "like", "not like" 154 | - "in", "not in" (for arrays) 155 | - "is", "is not" (for null values) 156 | - "between" (for date ranges) 157 | 158 | Tips: 159 | - Before using the \`list_documents\` tool with filters, the schema for the target doctype **must** be retrieved (e.g., using a \`get_doctype_schema\` tool or equivalent functionality). This step is crucial to identify the correct field names for the filter conditions, preventing errors due to invalid field references. 160 | - Use limit and limit_start for pagination 161 | - order_by accepts field name with optional "asc" or "desc" direction 162 | - If fields are not specified, standard fields will be returned 163 | - Complex filters can be created using arrays for OR conditions 164 | `, 165 | }, 166 | }, 167 | 168 | // Schema Operations 169 | SCHEMA_OPERATIONS: { 170 | GET_DOCTYPE_SCHEMA: { 171 | description: "Get the complete schema for a DocType", 172 | usage: ` 173 | To get a DocType schema, use the get_doctype_schema tool with the DocType name: 174 | 175 | Example: 176 | { 177 | "doctype": "ToDo" 178 | } 179 | 180 | The response includes: 181 | - Field definitions with types, labels, and validation rules 182 | - Permissions information 183 | - Naming configuration 184 | - Workflow information (if applicable) 185 | 186 | Tips: 187 | - Use this to understand the structure of a DocType before creating or updating documents 188 | - Check required fields, field types, and validation rules 189 | - Examine linked DocTypes for reference fields 190 | - Review permissions to ensure operations will succeed 191 | `, 192 | }, 193 | GET_FIELD_OPTIONS: { 194 | description: "Get available options for a Link or Select field", 195 | usage: ` 196 | To get field options, use the get_field_options tool with the DocType name and field name: 197 | 198 | Example: 199 | { 200 | "doctype": "ToDo", 201 | "fieldname": "priority", 202 | "filters": { 203 | "enabled": 1 204 | } 205 | } 206 | 207 | Tips: 208 | - For Link fields, this returns documents from the linked DocType 209 | - For Select fields, this returns the predefined options 210 | - Use filters to narrow down the options for Link fields 211 | - The response includes both value and label for each option 212 | `, 213 | }, 214 | FIND_DOCTYPE: { 215 | description: "Find DocTypes in the system", 216 | usage: ` 217 | To find DocTypes, use the list_documents tool with DocType as the doctype: 218 | 219 | Example: 220 | { 221 | "doctype": "DocType", 222 | "filters": { 223 | "istable": 0, 224 | "issingle": 0 225 | }, 226 | "fields": ["name", "module", "description"], 227 | "limit": 20 228 | } 229 | 230 | Common filters for DocTypes: 231 | - istable: 0/1 (whether it's a child table) 232 | - issingle: 0/1 (whether it's a single document) 233 | - module: "Core" (filter by module) 234 | - custom: 0/1 (whether it's a custom DocType) 235 | - name: ["like", "%User%"] (search by name) 236 | 237 | Tips: 238 | - Use this to discover available DocTypes in the system 239 | - Filter by module to find related DocTypes 240 | - Check istable=0 and issingle=0 for regular DocTypes 241 | - Check custom=1 for custom DocTypes 242 | `, 243 | }, 244 | }, 245 | 246 | // Advanced Operations 247 | ADVANCED_OPERATIONS: { 248 | WORKING_WITH_CHILD_TABLES: { 249 | description: "Working with child tables (Table fields)", 250 | usage: ` 251 | Child tables are handled as arrays of row objects when creating or updating documents: 252 | 253 | Example (Creating a document with child table): 254 | { 255 | "doctype": "Sales Order", 256 | "values": { 257 | "customer": "Customer Name", 258 | "delivery_date": "2023-12-31", 259 | "items": [ 260 | { 261 | "item_code": "ITEM-001", 262 | "qty": 5, 263 | "rate": 100 264 | }, 265 | { 266 | "item_code": "ITEM-002", 267 | "qty": 2, 268 | "rate": 200 269 | } 270 | ] 271 | } 272 | } 273 | 274 | Example (Updating a child table): 275 | { 276 | "doctype": "Sales Order", 277 | "name": "SO-0001", 278 | "values": { 279 | "items": [ 280 | { 281 | "name": "existing-row-id-1", // Include row ID for existing rows 282 | "qty": 10 // Updated quantity 283 | }, 284 | { 285 | "item_code": "ITEM-003", // New row without name field 286 | "qty": 3, 287 | "rate": 150 288 | } 289 | ] 290 | } 291 | } 292 | 293 | Tips: 294 | - When updating, include the row "name" for existing rows 295 | - Rows without a "name" field will be added as new rows 296 | - Rows in the database but not in the update will be deleted 297 | - Always include all required fields for new rows 298 | `, 299 | }, 300 | HANDLING_FILE_ATTACHMENTS: { 301 | description: "Handling file attachments", 302 | usage: ` 303 | Files in Frappe are stored as File documents. To attach a file to a document: 304 | 305 | 1. First, create a File document: 306 | { 307 | "doctype": "File", 308 | "values": { 309 | "file_name": "document.pdf", 310 | "is_private": 1, 311 | "content": "[base64 encoded content]", 312 | "attached_to_doctype": "ToDo", 313 | "attached_to_name": "TODO-0001" 314 | } 315 | } 316 | 317 | 2. The file will automatically be attached to the specified document 318 | 319 | Tips: 320 | - Use base64 encoding for the file content 321 | - Set is_private to 1 for private files, 0 for public files 322 | - The attached_to_doctype and attached_to_name fields link the file to a document 323 | - To get attached files, list File documents with filters for attached_to_doctype and attached_to_name 324 | `, 325 | }, 326 | WORKING_WITH_WORKFLOWS: { 327 | description: "Working with workflows", 328 | usage: ` 329 | Documents with workflows have additional fields for tracking the workflow state: 330 | 331 | 1. Check if a DocType has a workflow: 332 | { 333 | "doctype": "DocType", 334 | "name": "ToDo", 335 | "fields": ["name", "workflow_state"] 336 | } 337 | 338 | 2. Get available workflow states: 339 | { 340 | "doctype": "Workflow", 341 | "filters": { 342 | "document_type": "ToDo" 343 | } 344 | } 345 | 346 | 3. Update a document's workflow state: 347 | { 348 | "doctype": "ToDo", 349 | "name": "TODO-0001", 350 | "values": { 351 | "workflow_state": "Approved" 352 | } 353 | } 354 | 355 | Tips: 356 | - Workflow transitions may have permission requirements 357 | - Some states may require additional fields to be filled 358 | - Workflow actions may trigger notifications or other automations 359 | - Check the Workflow DocType for the complete workflow definition 360 | `, 361 | }, 362 | }, 363 | 364 | // Common Patterns and Best Practices 365 | BEST_PRACTICES: { 366 | HANDLING_ERRORS: { 367 | description: "Handling common errors", 368 | usage: ` 369 | Common Frappe API errors and how to handle them: 370 | 371 | 1. Document not found: 372 | - Check if the document exists 373 | - Verify the document name is correct (case-sensitive) 374 | - Ensure you have permission to access the document 375 | 376 | 2. Permission denied: 377 | - Check if you have the required permissions 378 | - Verify the API key has sufficient privileges 379 | - Check if the document is restricted by user permissions 380 | 381 | 3. Validation errors: 382 | - Required fields are missing 383 | - Field value doesn't match validation rules 384 | - Linked document doesn't exist 385 | - Unique constraint violation 386 | 387 | 4. Workflow errors: 388 | - Invalid workflow state transition 389 | - Missing workflow transition permission 390 | 391 | Tips: 392 | - Always check the error message for specific details 393 | - Use get_doctype_schema to understand field requirements 394 | - Test operations with minimal data first 395 | - Handle errors gracefully in your application 396 | `, 397 | }, 398 | EFFICIENT_QUERYING: { 399 | description: "Efficient querying patterns", 400 | usage: ` 401 | Tips for efficient querying in Frappe: 402 | 403 | 1. Always specify only the fields you need: 404 | { 405 | "doctype": "ToDo", 406 | "fields": ["name", "description", "status"], 407 | "limit": 10 408 | } 409 | 410 | 2. Use appropriate filters to reduce result set: 411 | { 412 | "doctype": "ToDo", 413 | "filters": { 414 | "status": "Open", 415 | "owner": "current_user" 416 | } 417 | } 418 | 419 | 3. Use pagination for large result sets: 420 | { 421 | "doctype": "ToDo", 422 | "limit": 20, 423 | "limit_start": 0, 424 | "order_by": "modified desc" 425 | } 426 | Then increment limit_start by limit for each page. 427 | 428 | 4. Use indexed fields in filters when possible: 429 | - name 430 | - modified 431 | - creation 432 | - owner 433 | - docstatus 434 | - Fields marked as "in_standard_filter" or "in_list_view" 435 | 436 | 5. Avoid complex OR conditions when possible 437 | 438 | 6. For reporting, consider using Frappe Reports instead of raw queries 439 | `, 440 | }, 441 | NAMING_CONVENTIONS: { 442 | description: "Understanding Frappe naming conventions", 443 | usage: ` 444 | Frappe uses several naming conventions for documents: 445 | 446 | 1. Standard naming: [DocType]-[Number] (e.g., TODO-0001) 447 | - Automatically generated when autoname is "naming_series" 448 | 449 | 2. Field-based naming: Uses a field value as the name 450 | - When autoname is "field:[fieldname]" 451 | 452 | 3. Format-based naming: Uses a pattern with fields 453 | - When autoname is like "HR-EMP-.YYYY.-.#####" 454 | - Supports date formatting (YYYY, MM, DD) and sequences (#) 455 | 456 | 4. Prompt naming: User provides the name 457 | - When autoname is "prompt" 458 | 459 | 5. Custom naming: Programmatically generated 460 | - When autoname is "custom" 461 | 462 | Tips: 463 | - Check the DocType's autoname field to understand its naming convention 464 | - Names are case-sensitive and must be unique within a DocType 465 | - When creating documents, you can often omit the name for auto-named DocTypes 466 | - For manually named DocTypes, always provide a unique name 467 | `, 468 | }, 469 | USER_FRIENDLY_DISPLAY_NAMES: { 470 | description: "Using user-friendly names vs. internal IDs", 471 | usage: ` 472 | When presenting information to the user, generating summaries, or updating descriptive documentation (such as markdown files): 473 | * Prioritize using user-friendly display names for Frappe documents (e.g., the \`account_name\` for an Account, \`item_name\` for an Item, \`full_name\` for a User) instead of their internal \`name\` (ID). 474 | * If you only have the document's internal \`name\` (ID), and need its display name for descriptive purposes, attempt to fetch the document to retrieve the appropriate display field. 475 | * **Important Distinction:** For all direct API interactions with Frappe (e.g., creating documents, updating documents, setting link fields, applying filters via \`list_documents\`), you **must** continue to use the internal Frappe \`name\` (ID) of the document in the relevant data fields to ensure correct system operation. 476 | 477 | Example: 478 | If you have an Account with \`name\` = "001-ACC" and \`account_name\` = "Sales Revenue Account". 479 | - When showing this account in a report: Display "Sales Revenue Account". 480 | - When setting this account in a Journal Entry's \`account\` field (a Link field): Use "001-ACC". 481 | `, 482 | }, 483 | } 484 | }; 485 | 486 | /** 487 | * Helper function to get instructions for a specific operation 488 | */ 489 | export function getInstructions(category: string, operation: string): string { 490 | const categoryData = (FRAPPE_INSTRUCTIONS as any)[category]; 491 | if (!categoryData) { 492 | return `Category '${category}' not found in instructions.`; 493 | } 494 | 495 | const operationData = categoryData[operation]; 496 | if (!operationData) { 497 | return `Operation '${operation}' not found in category '${category}'.`; 498 | } 499 | 500 | return `${operationData.description}\n\n${operationData.usage}`; 501 | } 502 | 503 | /** 504 | * Helper function to get a list of common DocTypes 505 | */ 506 | export function getCommonDocTypes(category: string): string[] { 507 | return COMMON_DOCTYPES[category as keyof typeof COMMON_DOCTYPES] || []; 508 | } 509 | 510 | // Define helper tools 511 | export const HELPER_TOOLS = [ 512 | { 513 | name: "find_doctypes", 514 | description: "Find DocTypes in the system matching a search term", 515 | inputSchema: { 516 | type: "object", 517 | properties: { 518 | search_term: { type: "string", description: "Search term to look for in DocType names" }, 519 | module: { type: "string", description: "Filter by module name (optional)" }, 520 | is_table: { type: "boolean", description: "Filter by table DocTypes (optional)" }, 521 | is_single: { type: "boolean", description: "Filter by single DocTypes (optional)" }, 522 | is_custom: { type: "boolean", description: "Filter by custom DocTypes (optional)" }, 523 | limit: { type: "number", description: "Maximum number of results (optional, default 20)" } 524 | }, 525 | required: [] 526 | } 527 | }, 528 | { 529 | name: "get_module_list", 530 | description: "Get a list of all modules in the system", 531 | inputSchema: { 532 | type: "object", 533 | properties: {}, 534 | required: [] 535 | } 536 | }, 537 | { 538 | name: "get_doctypes_in_module", 539 | description: "Get a list of DocTypes in a specific module", 540 | inputSchema: { 541 | type: "object", 542 | properties: { 543 | module: { type: "string", description: "Module name" } 544 | }, 545 | required: ["module"] 546 | } 547 | }, 548 | { 549 | name: "check_doctype_exists", 550 | description: "Check if a DocType exists in the system", 551 | inputSchema: { 552 | type: "object", 553 | properties: { 554 | doctype: { type: "string", description: "DocType name to check" } 555 | }, 556 | required: ["doctype"] 557 | } 558 | }, 559 | { 560 | name: "check_document_exists", 561 | description: "Check if a document exists", 562 | inputSchema: { 563 | type: "object", 564 | properties: { 565 | doctype: { type: "string", description: "DocType name" }, 566 | name: { type: "string", description: "Document name to check" } 567 | }, 568 | required: ["doctype", "name"] 569 | } 570 | }, 571 | { 572 | name: "get_document_count", 573 | description: "Get a count of documents matching filters", 574 | inputSchema: { 575 | type: "object", 576 | properties: { 577 | doctype: { type: "string", description: "DocType name" }, 578 | filters: { 579 | type: "object", 580 | description: "Filters to apply (optional)", 581 | additionalProperties: true 582 | } 583 | }, 584 | required: ["doctype"] 585 | } 586 | }, 587 | { 588 | name: "get_naming_info", 589 | description: "Get the naming series information for a DocType", 590 | inputSchema: { 591 | type: "object", 592 | properties: { 593 | doctype: { type: "string", description: "DocType name" } 594 | }, 595 | required: ["doctype"] 596 | } 597 | }, 598 | { 599 | name: "get_required_fields", 600 | description: "Get a list of required fields for a DocType", 601 | inputSchema: { 602 | type: "object", 603 | properties: { 604 | doctype: { type: "string", description: "DocType name" } 605 | }, 606 | required: ["doctype"] 607 | } 608 | }, 609 | { 610 | name: "get_api_instructions", 611 | description: "Get detailed instructions for using the Frappe API", 612 | inputSchema: { 613 | type: "object", 614 | properties: { 615 | category: { 616 | type: "string", 617 | description: "Instruction category (DOCUMENT_OPERATIONS, SCHEMA_OPERATIONS, ADVANCED_OPERATIONS, BEST_PRACTICES)" 618 | }, 619 | operation: { 620 | type: "string", 621 | description: "Operation name (e.g., CREATE, GET, UPDATE, DELETE, LIST, GET_DOCTYPE_SCHEMA, etc.)" 622 | } 623 | }, 624 | required: ["category", "operation"] 625 | } 626 | } 627 | ]; -------------------------------------------------------------------------------- /src/index-helpers.ts: -------------------------------------------------------------------------------- 1 | import { 2 | findDocTypes, 3 | getModuleList, 4 | getDocTypesInModule, 5 | doesDocTypeExist, 6 | doesDocumentExist, 7 | getDocumentCount, 8 | getNamingSeriesInfo, 9 | getRequiredFields 10 | } from "./frappe-helpers.js"; 11 | import { getInstructions } from "./frappe-instructions.js"; 12 | 13 | // Define new tool handlers 14 | export async function handleHelperToolCall(request: any): Promise { 15 | const { name, arguments: args } = request.params; 16 | 17 | if (!args) { 18 | return { 19 | content: [{ type: "text", text: "Missing arguments for tool call" }], 20 | isError: true, 21 | }; 22 | } 23 | 24 | try { 25 | console.error("Handling helper tool: " + name + " with args:", args); 26 | 27 | switch (name) { 28 | case "find_doctypes": 29 | const searchTerm = args.search_term || ""; 30 | const options = { 31 | module: args.module, 32 | isTable: args.is_table, 33 | isSingle: args.is_single, 34 | isCustom: args.is_custom, 35 | limit: args.limit || 20 36 | }; 37 | const doctypes = await findDocTypes(searchTerm, options); 38 | return { 39 | content: [{ type: "text", text: JSON.stringify(doctypes, null, 2) }], 40 | }; 41 | 42 | case "get_module_list": 43 | const modules = await getModuleList(); 44 | return { 45 | content: [{ type: "text", text: JSON.stringify(modules, null, 2) }], 46 | }; 47 | 48 | case "get_doctypes_in_module": 49 | if (!args.module) { 50 | return { 51 | content: [{ type: "text", text: "Missing required parameter: module" }], 52 | isError: true, 53 | }; 54 | } 55 | const moduleDocTypes = await getDocTypesInModule(args.module); 56 | return { 57 | content: [{ type: "text", text: JSON.stringify(moduleDocTypes, null, 2) }], 58 | }; 59 | 60 | case "check_doctype_exists": 61 | if (!args.doctype) { 62 | return { 63 | content: [{ type: "text", text: "Missing required parameter: doctype" }], 64 | isError: true, 65 | }; 66 | } 67 | const doctypeExists = await doesDocTypeExist(args.doctype); 68 | return { 69 | content: [{ type: "text", text: JSON.stringify({ exists: doctypeExists }, null, 2) }], 70 | }; 71 | 72 | case "check_document_exists": 73 | if (!args.doctype || !args.name) { 74 | return { 75 | content: [{ type: "text", text: "Missing required parameters: doctype and name" }], 76 | isError: true, 77 | }; 78 | } 79 | const documentExists = await doesDocumentExist(args.doctype, args.name); 80 | return { 81 | content: [{ type: "text", text: JSON.stringify({ exists: documentExists }, null, 2) }], 82 | }; 83 | 84 | case "get_document_count": 85 | if (!args.doctype) { 86 | return { 87 | content: [{ type: "text", text: "Missing required parameter: doctype" }], 88 | isError: true, 89 | }; 90 | } 91 | const count = await getDocumentCount(args.doctype, args.filters || {}); 92 | return { 93 | content: [{ type: "text", text: JSON.stringify({ count }, null, 2) }], 94 | }; 95 | 96 | case "get_naming_info": 97 | if (!args.doctype) { 98 | return { 99 | content: [{ type: "text", text: "Missing required parameter: doctype" }], 100 | isError: true, 101 | }; 102 | } 103 | const namingInfo = await getNamingSeriesInfo(args.doctype); 104 | return { 105 | content: [{ type: "text", text: JSON.stringify(namingInfo, null, 2) }], 106 | }; 107 | 108 | case "get_required_fields": 109 | if (!args.doctype) { 110 | return { 111 | content: [{ type: "text", text: "Missing required parameter: doctype" }], 112 | isError: true, 113 | }; 114 | } 115 | const requiredFields = await getRequiredFields(args.doctype); 116 | return { 117 | content: [{ type: "text", text: JSON.stringify(requiredFields, null, 2) }], 118 | }; 119 | 120 | case "get_api_instructions": 121 | if (!args.category || !args.operation) { 122 | return { 123 | content: [{ type: "text", text: "Missing required parameters: category and operation" }], 124 | isError: true, 125 | }; 126 | } 127 | const instructions = getInstructions(args.category, args.operation); 128 | return { 129 | content: [{ type: "text", text: instructions }], 130 | }; 131 | 132 | default: 133 | return { 134 | content: [{ type: "text", text: "Helper module doesn't handle tool: " + name }], 135 | isError: true, 136 | }; 137 | } 138 | } catch (error) { 139 | console.error("Error in helper tool " + name + ":", error); 140 | return { 141 | content: [{ type: "text", text: "Error in helper tool " + name + ": " + (error as Error).message }], 142 | isError: true, 143 | }; 144 | } 145 | } -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | import { Server } from "@modelcontextprotocol/sdk/server/index.js"; 3 | import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"; 4 | import { 5 | CallToolRequestSchema, 6 | ListToolsRequestSchema, 7 | } from "@modelcontextprotocol/sdk/types.js"; 8 | import { handleCallMethodToolCall } from "./document-operations.js"; 9 | import { setupDocumentTools, handleDocumentToolCall, DOCUMENT_TOOLS } from "./document-operations.js"; 10 | import { setupSchemaTools, handleSchemaToolCall, SCHEMA_TOOLS } from "./schema-operations.js"; 11 | import { getInstructions, HELPER_TOOLS } from "./frappe-instructions.js"; 12 | import { handleHelperToolCall } from "./index-helpers.js"; // Moved helper tool handlers to a separate file 13 | 14 | import { validateApiCredentials } from './auth.js'; 15 | 16 | async function main() { 17 | console.error("Starting Frappe MCP server..."); 18 | console.error("Current working directory:", process.cwd()); 19 | 20 | // Validate API credentials at startup 21 | const credentialsCheck = validateApiCredentials(); 22 | if (!credentialsCheck.valid) { 23 | console.error(`ERROR: ${credentialsCheck.message}`); 24 | console.error("The server will start, but most operations will fail without valid API credentials."); 25 | console.error("Please set FRAPPE_API_KEY and FRAPPE_API_SECRET environment variables."); 26 | } else { 27 | console.error("API credentials validation successful."); 28 | } 29 | 30 | const server = new Server( 31 | { 32 | name: "frappe-mcp-server", 33 | version: "0.2.13", 34 | }, 35 | { 36 | capabilities: { 37 | resources: {}, 38 | tools: {}, 39 | }, 40 | } 41 | ); 42 | 43 | setupSchemaTools(server); 44 | setupDocumentTools(server); 45 | 46 | 47 | // Centralized tool registration 48 | server.setRequestHandler(ListToolsRequestSchema, async () => { 49 | const tools = [ 50 | { 51 | name: "call_method", 52 | description: "Execute a whitelisted Frappe method", 53 | inputSchema: { 54 | type: "object", 55 | properties: { 56 | method: { type: "string", description: "Method name to call (whitelisted)" }, 57 | params: { 58 | type: "object", 59 | description: "Parameters to pass to the method (optional)", 60 | additionalProperties: true 61 | }, 62 | }, 63 | required: ["method"], 64 | }, 65 | }, 66 | ...DOCUMENT_TOOLS, 67 | ...SCHEMA_TOOLS, 68 | ...HELPER_TOOLS, 69 | { 70 | name: "ping", 71 | description: "A simple tool to check if the server is responding.", 72 | inputSchema: { type: "object", properties: {} }, // No input needed 73 | }, 74 | ]; 75 | return { tools }; 76 | }); 77 | 78 | // Centralized tool call handling 79 | server.setRequestHandler(CallToolRequestSchema, async (request) => { 80 | const { name } = request.params; 81 | 82 | try { 83 | console.error(`Received tool call for: ${name}`); 84 | 85 | if (name === "call_method") { 86 | console.error(`Routing to call_method handler: ${name}`); 87 | return await handleCallMethodToolCall(request); 88 | } 89 | 90 | if (DOCUMENT_TOOLS.find(tool => tool.name === name)) { 91 | console.error(`Routing to document handler: ${name}`); 92 | return await handleDocumentToolCall(request); 93 | } 94 | 95 | if (SCHEMA_TOOLS.find((tool: { name: string }) => tool.name === name)) { 96 | console.error(`Routing to schema handler: ${name}`); 97 | return await handleSchemaToolCall(request); 98 | } 99 | 100 | if (HELPER_TOOLS.find((tool: { name: string }) => tool.name === name)) { 101 | console.error(`Routing to helper handler: ${name}`); 102 | return await handleHelperToolCall(request); 103 | } 104 | 105 | 106 | if (name === "ping") { 107 | console.error(`Routing to ping handler: ${name}`); 108 | return { 109 | content: [{ type: "text", text: "pong" }], 110 | isError: false, 111 | }; 112 | } 113 | 114 | console.error(`No handler found for tool: ${name}`); 115 | return { 116 | content: [ 117 | { 118 | type: "text", 119 | text: `Unknown tool: ${name}`, 120 | }, 121 | ], 122 | isError: true, 123 | }; 124 | } catch (error) { 125 | console.error(`Error handling tool ${name}:`, error); 126 | return { 127 | content: [ 128 | { 129 | type: "text", 130 | text: `Error handling tool ${name}: ${(error as Error).message}`, 131 | }, 132 | ], 133 | isError: true, 134 | }; 135 | } 136 | }); 137 | 138 | server.onerror = (error) => console.error("[MCP Error]", error); 139 | 140 | process.on("SIGINT", async () => { 141 | console.error("Shutting down Frappe MCP server..."); 142 | await server.close(); 143 | process.exit(0); 144 | }); 145 | 146 | const transport = new StdioServerTransport(); 147 | await server.connect(transport); 148 | console.error("Frappe MCP server running on stdio"); 149 | } 150 | 151 | main().catch((error) => { 152 | console.error("Fatal error:", error); 153 | process.exit(1); 154 | }); -------------------------------------------------------------------------------- /src/schema-api.ts: -------------------------------------------------------------------------------- 1 | import { frappe } from './api-client.js'; 2 | import { handleApiError } from './errors.js'; 3 | import { getDocument } from './document-api.js'; 4 | 5 | // Schema operations 6 | /** 7 | * Get the schema for a DocType 8 | * @param doctype The DocType name 9 | * @returns The DocType schema 10 | */ 11 | export async function getDocTypeSchema(doctype: string): Promise { 12 | try { 13 | if (!doctype) throw new Error("DocType name is required"); 14 | 15 | // Primary approach: Use the standard API endpoint 16 | console.error(`Using standard API endpoint for ${doctype}`); 17 | let response; 18 | try { 19 | response = await frappe.call().get('frappe.get_meta', { doctype: doctype }); 20 | console.error(`Got response from standard API endpoint for ${doctype}`); 21 | console.error(`Raw response data:`, JSON.stringify(response?.data, null, 2)); 22 | } catch (error) { 23 | console.error(`Error using standard API endpoint for ${doctype}:`, error); 24 | // Fallback to document API 25 | } 26 | 27 | // Directly use response data from standard API endpoint 28 | const docTypeData = response; 29 | console.error(`Using /api/v2/doctype/{doctype}/meta format`); 30 | 31 | if (docTypeData) { 32 | // If we got schema data from standard API, process and return it 33 | const doctypeInfo = docTypeData.doctype || {}; 34 | return { 35 | name: doctype, 36 | label: doctypeInfo.name || doctype, 37 | description: doctypeInfo.description, 38 | module: doctypeInfo.module, 39 | issingle: doctypeInfo.issingle === 1, 40 | istable: doctypeInfo.istable === 1, 41 | custom: doctypeInfo.custom === 1, 42 | fields: (docTypeData.fields || []).map((field: any) => ({ 43 | fieldname: field.fieldname, 44 | label: field.label, 45 | fieldtype: field.fieldtype, 46 | required: field.reqd === 1, 47 | description: field.description, 48 | default: field.default, 49 | options: field.options, 50 | // Include validation information 51 | min_length: field.min_length, 52 | max_length: field.max_length, 53 | min_value: field.min_value, 54 | max_value: field.max_value, 55 | // Include linked DocType information if applicable 56 | linked_doctype: field.fieldtype === "Link" ? field.options : null, 57 | // Include child table information if applicable 58 | child_doctype: field.fieldtype === "Table" ? field.options : null, 59 | // Include additional field metadata 60 | in_list_view: field.in_list_view === 1, 61 | in_standard_filter: field.in_standard_filter === 1, 62 | in_global_search: field.in_global_search === 1, 63 | bold: field.bold === 1, 64 | hidden: field.hidden === 1, 65 | read_only: field.read_only === 1, 66 | allow_on_submit: field.allow_on_submit === 1, 67 | set_only_once: field.set_only_once === 1, 68 | allow_bulk_edit: field.allow_bulk_edit === 1, 69 | translatable: field.translatable === 1, 70 | })), 71 | // Include permissions information 72 | permissions: docTypeData.permissions || [], 73 | // Include naming information 74 | autoname: doctypeInfo.autoname, 75 | name_case: doctypeInfo.name_case, 76 | // Include workflow information if available 77 | workflow: docTypeData.workflow || null, 78 | // Include additional metadata 79 | is_submittable: doctypeInfo.is_submittable === 1, 80 | quick_entry: doctypeInfo.quick_entry === 1, 81 | track_changes: doctypeInfo.track_changes === 1, 82 | track_views: doctypeInfo.track_views === 1, 83 | has_web_view: doctypeInfo.has_web_view === 1, 84 | allow_rename: doctypeInfo.allow_rename === 1, 85 | allow_copy: doctypeInfo.allow_copy === 1, 86 | allow_import: doctypeInfo.allow_import === 1, 87 | allow_events_in_timeline: doctypeInfo.allow_events_in_timeline === 1, 88 | allow_auto_repeat: doctypeInfo.allow_auto_repeat === 1, 89 | document_type: doctypeInfo.document_type, 90 | icon: doctypeInfo.icon, 91 | max_attachments: doctypeInfo.max_attachments, 92 | }; 93 | } 94 | 95 | // Fallback to Document API if standard API failed or didn't return schema data 96 | console.error(`Falling back to document API for ${doctype}`); 97 | try { 98 | console.error(`Using document API to get schema for ${doctype}`); 99 | 100 | // 1. Get the DocType document 101 | console.error(`Fetching DocType document for ${doctype}`); 102 | const doctypeDoc = await getDocument("DocType", doctype); 103 | console.error(`DocType document response:`, JSON.stringify(doctypeDoc).substring(0, 200) + "..."); 104 | console.error(`Full DocType document response:`, doctypeDoc); 105 | 106 | if (!doctypeDoc) { 107 | throw new Error(`DocType ${doctype} not found`); 108 | } 109 | 110 | console.error(`DocTypeDoc.fields before schema construction:`, doctypeDoc.fields); 111 | console.error(`DocTypeDoc.permissions before schema construction:`, doctypeDoc.permissions); 112 | 113 | return { 114 | name: doctype, 115 | label: doctypeDoc.name || doctype, 116 | description: doctypeDoc.description, 117 | module: doctypeDoc.module, 118 | issingle: doctypeDoc.issingle === 1, 119 | istable: doctypeDoc.istable === 1, 120 | custom: doctypeDoc.custom === 1, 121 | fields: doctypeDoc.fields || [], // Use fields from doctypeDoc if available, otherwise default to empty array 122 | permissions: doctypeDoc.permissions || [], // Use permissions from doctypeDoc if available, otherwise default to empty array 123 | autoname: doctypeDoc.autoname, 124 | name_case: doctypeDoc.name_case, 125 | workflow: null, 126 | is_submittable: doctypeDoc.is_submittable === 1, 127 | quick_entry: doctypeDoc.quick_entry === 1, 128 | track_changes: doctypeDoc.track_changes === 1, 129 | track_views: doctypeDoc.track_views === 1, 130 | has_web_view: doctypeDoc.has_web_view === 1, 131 | allow_rename: doctypeDoc.allow_rename === 1, 132 | allow_copy: doctypeDoc.allow_copy === 1, 133 | allow_import: doctypeDoc.allow_import === 1, 134 | allow_events_in_timeline: doctypeDoc.allow_events_in_timeline === 1, 135 | allow_auto_repeat: doctypeDoc.allow_auto_repeat === 1, 136 | document_type: doctypeDoc.document_type, 137 | icon: doctypeDoc.icon, 138 | max_attachments: doctypeDoc.max_attachments, 139 | }; 140 | } catch (error) { 141 | console.error(`Error using document API for ${doctype}:`, error); 142 | // If document API also fails, then we cannot retrieve the schema 143 | } 144 | 145 | throw new Error(`Could not retrieve schema for DocType ${doctype} using any available method`); 146 | } catch (error) { 147 | return handleApiError(error, `get_doctype_schema(${doctype})`); 148 | } 149 | } 150 | 151 | export async function getFieldOptions( 152 | doctype: string, 153 | fieldname: string, 154 | filters?: Record 155 | ): Promise> { 156 | try { 157 | if (!doctype) throw new Error("DocType name is required"); 158 | if (!fieldname) throw new Error("Field name is required"); 159 | 160 | // First get the field metadata to determine the type and linked DocType 161 | const schema = await getDocTypeSchema(doctype); 162 | 163 | if (!schema || !schema.fields || !Array.isArray(schema.fields)) { 164 | throw new Error(`Invalid schema returned for DocType ${doctype}`); 165 | } 166 | 167 | const field = schema.fields.find((f: any) => f.fieldname === fieldname); 168 | 169 | if (!field) { 170 | throw new Error(`Field ${fieldname} not found in DocType ${doctype}`); 171 | } 172 | 173 | if (field.fieldtype === "Link") { 174 | // For Link fields, get the list of documents from the linked DocType 175 | const linkedDocType = field.options; 176 | if (!linkedDocType) { 177 | throw new Error(`Link field ${fieldname} has no options (linked DocType) specified`); 178 | } 179 | 180 | console.error(`Getting options for Link field ${fieldname} from DocType ${linkedDocType}`); 181 | 182 | try { 183 | // Try to get the title field for the linked DocType 184 | const linkedSchema = await getDocTypeSchema(linkedDocType); 185 | const titleField = linkedSchema.fields.find((f: any) => f.fieldname === "title" || f.bold === 1); 186 | const displayFields = titleField ? ["name", titleField.fieldname] : ["name"]; 187 | 188 | const response = await frappe.db().getDocList(linkedDocType, { limit: 50, fields: displayFields, filters: filters as any }); 189 | 190 | if (!response) { 191 | throw new Error(`Invalid response for DocType ${linkedDocType}`); 192 | } 193 | 194 | return response.map((item: any) => { 195 | const label = titleField && item[titleField.fieldname] 196 | ? `${item.name} - ${item[titleField.fieldname]}` 197 | : item.name; 198 | 199 | return { 200 | value: item.name, 201 | label: label, 202 | }; 203 | }); 204 | } catch (error) { 205 | console.error(`Error fetching options for Link field ${fieldname}:`, error); 206 | // Try a simpler approach as fallback 207 | const response = await frappe.db().getDocList(linkedDocType, { limit: 50, fields: ["name"], filters: filters as any }); 208 | 209 | if (!response) { 210 | throw new Error(`Invalid response for DocType ${linkedDocType}`); 211 | } 212 | 213 | return response.map((item: any) => ({ 214 | value: item.name, 215 | label: item.name, 216 | })); 217 | } 218 | } else if (field.fieldtype === "Select") { 219 | // For Select fields, parse the options string 220 | console.error(`Getting options for Select field ${fieldname}: ${field.options}`); 221 | 222 | if (!field.options) { 223 | return []; 224 | } 225 | 226 | return field.options.split("\n") 227 | .filter((option: string) => option.trim() !== '') 228 | .map((option: string) => ({ 229 | value: option.trim(), 230 | label: option.trim(), 231 | })); 232 | } else if (field.fieldtype === "Table") { 233 | // For Table fields, return an empty array with a message 234 | console.error(`Field ${fieldname} is a Table field, no options available`); 235 | return []; 236 | } else { 237 | console.error(`Field ${fieldname} is type ${field.fieldtype}, not Link or Select`); 238 | return []; 239 | } 240 | } catch (error) { 241 | console.error(`Error in getFieldOptions for ${doctype}.${fieldname}:`, error); 242 | return handleApiError(error, `get_field_options(${doctype}, ${fieldname})`); 243 | } 244 | } 245 | 246 | /** 247 | * Get a list of all DocTypes in the system 248 | * @returns Array of DocType names 249 | */ 250 | export async function getAllDocTypes(): Promise { 251 | try { 252 | const response = await frappe.db().getDocList('DocType', { limit: 1000, fields: ["name"] }); 253 | 254 | if (!response) { 255 | throw new Error('Invalid response format for DocType list'); 256 | } 257 | 258 | return response.map((item: any) => item.name); 259 | } catch (error) { 260 | return handleApiError(error, 'get_all_doctypes'); 261 | } 262 | } 263 | 264 | /** 265 | * Get a list of all modules in the system 266 | * @returns Array of module names 267 | */ 268 | export async function getAllModules(): Promise { 269 | try { 270 | const response = await frappe.db().getDocList('Module Def', { limit: 100, fields: ["name", "module_name"] }); 271 | 272 | if (!response) { 273 | throw new Error('Invalid response format for Module list'); 274 | } 275 | 276 | return response.map((item: any) => item.name || item.module_name); 277 | } catch (error) { 278 | return handleApiError(error, 'get_all_modules'); 279 | } 280 | } -------------------------------------------------------------------------------- /src/schema-operations.ts: -------------------------------------------------------------------------------- 1 | import { Server } from "@modelcontextprotocol/sdk/server/index.js"; 2 | import { 3 | ListResourceTemplatesRequestSchema, 4 | ReadResourceRequestSchema, 5 | ErrorCode, 6 | McpError, 7 | CallToolRequestSchema, 8 | ListToolsRequestSchema, 9 | } from "@modelcontextprotocol/sdk/types.js"; 10 | import { 11 | getDocTypeSchema, 12 | getFieldOptions, 13 | FrappeApiError, 14 | getAllDocTypes, 15 | getAllModules 16 | } from "./frappe-api.js"; 17 | import { formatFilters } from "./frappe-helpers.js"; 18 | import { 19 | getDocTypeHints, 20 | getWorkflowHints, 21 | findWorkflowsForDocType, 22 | initializeStaticHints 23 | } from "./static-hints.js"; 24 | import { 25 | getDocTypeUsageInstructions, 26 | getAppForDocType, 27 | getAppUsageInstructions, 28 | initializeAppIntrospection 29 | } from "./app-introspection.js"; 30 | 31 | // Define schema tools 32 | export const SCHEMA_TOOLS = [ 33 | { 34 | name: "get_doctype_schema", 35 | description: "Get the complete schema for a DocType including field definitions, validations, and linked DocTypes. Use this to understand the structure of a DocType before creating or updating documents.", 36 | inputSchema: { 37 | type: "object", 38 | properties: { 39 | doctype: { type: "string", description: "DocType name" } 40 | }, 41 | required: ["doctype"] 42 | } 43 | }, 44 | { 45 | name: "get_field_options", 46 | description: "Get available options for a Link or Select field. For Link fields, returns documents from the linked DocType. For Select fields, returns the predefined options.", 47 | inputSchema: { 48 | type: "object", 49 | properties: { 50 | doctype: { type: "string", description: "DocType name" }, 51 | fieldname: { type: "string", description: "Field name" }, 52 | filters: { 53 | type: "object", 54 | description: "Filters to apply to the linked DocType (optional, for Link fields only)", 55 | additionalProperties: true 56 | } 57 | }, 58 | required: ["doctype", "fieldname"] 59 | } 60 | }, 61 | { 62 | name: "get_frappe_usage_info", 63 | description: "Get combined information about a DocType or workflow, including schema metadata and usage guidance from static hints.", 64 | inputSchema: { 65 | type: "object", 66 | properties: { 67 | doctype: { type: "string", description: "DocType name (optional if workflow is provided)" }, 68 | workflow: { type: "string", description: "Workflow name (optional if doctype is provided)" } 69 | }, 70 | required: [] 71 | } 72 | } 73 | ]; 74 | 75 | /** 76 | * Format error response with detailed information 77 | */ 78 | function formatErrorResponse(error: any, operation: string): any { 79 | console.error(`Error in ${operation}:`, error); 80 | 81 | let errorMessage = `Error in ${operation}: ${error.message || 'Unknown error'}`; 82 | let errorDetails = null; 83 | 84 | if (error instanceof FrappeApiError) { 85 | errorMessage = error.message; 86 | errorDetails = { 87 | statusCode: error.statusCode, 88 | endpoint: error.endpoint, 89 | details: error.details 90 | }; 91 | } 92 | 93 | return { 94 | content: [ 95 | { 96 | type: "text", 97 | text: errorMessage, 98 | }, 99 | ...(errorDetails ? [ 100 | { 101 | type: "text", 102 | text: `\nDetails: ${JSON.stringify(errorDetails, null, 2)}`, 103 | } 104 | ] : []) 105 | ], 106 | isError: true, 107 | }; 108 | } 109 | 110 | // Export a handler function for schema tool calls 111 | export async function handleSchemaToolCall(request: any): Promise { 112 | const { name, arguments: args } = request.params; 113 | 114 | if (!args) { 115 | return { 116 | content: [ 117 | { 118 | type: "text", 119 | text: "Missing arguments for tool call", 120 | }, 121 | ], 122 | isError: true, 123 | }; 124 | } 125 | 126 | try { 127 | console.error(`Handling schema tool: ${name} with args:`, args); 128 | 129 | if (name === "get_doctype_schema") { 130 | const doctype = args.doctype as string; 131 | if (!doctype) { 132 | return { 133 | content: [ 134 | { 135 | type: "text", 136 | text: "Missing required parameter: doctype", 137 | }, 138 | ], 139 | isError: true, 140 | }; 141 | } 142 | 143 | try { 144 | let schema; 145 | let authMethod = "token"; 146 | 147 | // Get schema using API key/secret authentication 148 | schema = await getDocTypeSchema(doctype); 149 | console.error(`Retrieved schema for ${doctype} using API key/secret auth`); 150 | authMethod = "api_key"; 151 | 152 | // Add a summary of the schema for easier understanding 153 | const fieldTypes = schema.fields.reduce((acc: Record, field: any) => { 154 | acc[field.fieldtype] = (acc[field.fieldtype] || 0) + 1; 155 | return acc; 156 | }, {}); 157 | 158 | const requiredFields = schema.fields 159 | .filter((field: any) => field.required) 160 | .map((field: any) => field.fieldname); 161 | 162 | const summary = { 163 | name: schema.name, 164 | module: schema.module, 165 | isSingle: schema.issingle, 166 | isTable: schema.istable, 167 | isCustom: schema.custom, 168 | autoname: schema.autoname, 169 | fieldCount: schema.fields.length, 170 | fieldTypes: fieldTypes, 171 | requiredFields: requiredFields, 172 | permissions: schema.permissions.length, 173 | authMethod: authMethod 174 | }; 175 | 176 | return { 177 | content: [ 178 | { 179 | type: "text", 180 | text: `Schema Summary (retrieved using ${authMethod} authentication):\n${JSON.stringify(summary, null, 2)}\n\nFull Schema:\n${JSON.stringify(schema, null, 2)}`, 181 | }, 182 | ], 183 | }; 184 | } catch (error) { 185 | return formatErrorResponse(error, `get_doctype_schema(${doctype})`); 186 | } 187 | } else if (name === "get_field_options") { 188 | const doctype = args.doctype as string; 189 | const fieldname = args.fieldname as string; 190 | 191 | if (!doctype || !fieldname) { 192 | return { 193 | content: [ 194 | { 195 | type: "text", 196 | text: "Missing required parameters: doctype and fieldname are required", 197 | }, 198 | ], 199 | isError: true, 200 | }; 201 | } 202 | 203 | const filters = args.filters as Record | undefined; 204 | const formattedFilters = filters ? formatFilters(filters) : undefined; 205 | 206 | try { 207 | // First get the field metadata to understand what we're dealing with 208 | const schema = await getDocTypeSchema(doctype); 209 | const field = schema.fields.find((f: any) => f.fieldname === fieldname); 210 | 211 | if (!field) { 212 | return { 213 | content: [ 214 | { 215 | type: "text", 216 | text: `Field ${fieldname} not found in DocType ${doctype}`, 217 | }, 218 | ], 219 | isError: true, 220 | }; 221 | } 222 | 223 | // Get the options 224 | const options = await getFieldOptions(doctype, fieldname, formattedFilters); 225 | 226 | // Add field metadata to the response 227 | const fieldInfo = { 228 | fieldname: field.fieldname, 229 | label: field.label, 230 | fieldtype: field.fieldtype, 231 | required: field.required, 232 | description: field.description, 233 | options: field.options, 234 | }; 235 | 236 | return { 237 | content: [ 238 | { 239 | type: "text", 240 | text: `Field Information:\n${JSON.stringify(fieldInfo, null, 2)}\n\nAvailable Options (${options.length}):\n${JSON.stringify(options, null, 2)}`, 241 | }, 242 | ], 243 | }; 244 | } catch (error) { 245 | return formatErrorResponse(error, `get_field_options(${doctype}, ${fieldname})`); 246 | } 247 | } else if (name === "get_frappe_usage_info") { 248 | const doctype = args.doctype as string; 249 | const workflow = args.workflow as string; 250 | 251 | if (!doctype && !workflow) { 252 | return { 253 | content: [ 254 | { 255 | type: "text", 256 | text: "Missing required parameters: either doctype or workflow must be provided", 257 | }, 258 | ], 259 | isError: true, 260 | }; 261 | } 262 | 263 | try { 264 | // Initialize result object 265 | const result: any = { 266 | type: doctype ? "doctype" : "workflow", 267 | name: doctype || workflow, 268 | schema: null, 269 | hints: [], 270 | related_workflows: [], 271 | app_instructions: null 272 | }; 273 | 274 | // If doctype is provided, get the schema, doctype hints, and app instructions 275 | if (doctype) { 276 | try { 277 | // Get schema 278 | result.schema = await getDocTypeSchema(doctype); 279 | 280 | // Get static hints 281 | result.hints = getDocTypeHints(doctype); 282 | result.related_workflows = findWorkflowsForDocType(doctype); 283 | 284 | // Get app-provided instructions 285 | result.app_instructions = await getDocTypeUsageInstructions(doctype); 286 | 287 | // If no app instructions but we have the app name, try to get app-level instructions 288 | if (!result.app_instructions) { 289 | const appName = await getAppForDocType(doctype); 290 | if (appName) { 291 | result.app_name = appName; 292 | result.app_level_instructions = await getAppUsageInstructions(appName); 293 | } 294 | } 295 | } catch (error) { 296 | console.error(`Error getting schema for DocType ${doctype}:`, error); 297 | // Continue even if schema retrieval fails, we can still provide hints 298 | result.schema_error = `Error retrieving schema: ${(error as Error).message}`; 299 | } 300 | } else if (workflow) { 301 | // If workflow is provided, get the workflow hints 302 | result.hints = getWorkflowHints(workflow); 303 | } 304 | 305 | // Format the response 306 | let responseText = ""; 307 | 308 | if (doctype) { 309 | responseText += `# DocType: ${doctype}\n\n`; 310 | 311 | // Add app-provided instructions if available 312 | if (result.app_instructions) { 313 | const instructions = result.app_instructions.instructions; 314 | 315 | responseText += "## App-Provided Usage Information\n\n"; 316 | 317 | if (instructions.description) { 318 | responseText += `### Description\n\n${instructions.description}\n\n`; 319 | } 320 | 321 | if (instructions.usage_guidance) { 322 | responseText += `### Usage Guidance\n\n${instructions.usage_guidance}\n\n`; 323 | } 324 | 325 | if (instructions.key_fields && instructions.key_fields.length > 0) { 326 | responseText += "### Key Fields\n\n"; 327 | for (const field of instructions.key_fields) { 328 | responseText += `- **${field.name}**: ${field.description}\n`; 329 | } 330 | responseText += "\n"; 331 | } 332 | 333 | if (instructions.common_workflows && instructions.common_workflows.length > 0) { 334 | responseText += "### Common Workflows\n\n"; 335 | instructions.common_workflows.forEach((workflow: string, index: number) => { 336 | responseText += `${index + 1}. ${workflow}\n`; 337 | }); 338 | responseText += "\n"; 339 | } 340 | } 341 | 342 | // Add static hints if available 343 | if (result.hints && result.hints.length > 0) { 344 | responseText += "## Static Hints\n\n"; 345 | for (const hint of result.hints) { 346 | responseText += `${hint.hint}\n\n`; 347 | } 348 | } 349 | 350 | // If no specific instructions were found, but we have app-level instructions 351 | if (!result.app_instructions && result.app_level_instructions) { 352 | responseText += `## About ${result.app_name}\n\n`; 353 | 354 | const appInstructions = result.app_level_instructions; 355 | 356 | if (appInstructions.app_description) { 357 | responseText += `${appInstructions.app_description}\n\n`; 358 | } 359 | 360 | // Add a note that this DocType is part of this app 361 | responseText += `The DocType "${doctype}" is part of the ${result.app_name} app.\n\n`; 362 | } 363 | 364 | // Add schema summary if available 365 | if (result.schema) { 366 | const fieldTypes = result.schema.fields.reduce((acc: Record, field: any) => { 367 | acc[field.fieldtype] = (acc[field.fieldtype] || 0) + 1; 368 | return acc; 369 | }, {}); 370 | 371 | const requiredFields = result.schema.fields 372 | .filter((field: any) => field.required) 373 | .map((field: any) => field.fieldname); 374 | 375 | responseText += "## Schema Summary\n\n"; 376 | responseText += `- **Module**: ${result.schema.module}\n`; 377 | responseText += `- **Is Single**: ${result.schema.issingle ? "Yes" : "No"}\n`; 378 | responseText += `- **Is Table**: ${result.schema.istable ? "Yes" : "No"}\n`; 379 | responseText += `- **Is Custom**: ${result.schema.custom ? "Yes" : "No"}\n`; 380 | responseText += `- **Field Count**: ${result.schema.fields.length}\n`; 381 | responseText += `- **Field Types**: ${JSON.stringify(fieldTypes)}\n`; 382 | responseText += `- **Required Fields**: ${requiredFields.join(", ")}\n\n`; 383 | } else if (result.schema_error) { 384 | responseText += `## Schema Error\n\n${result.schema_error}\n\n`; 385 | } 386 | 387 | // Add related workflows if available 388 | if (result.related_workflows && result.related_workflows.length > 0) { 389 | responseText += "## Related Workflows\n\n"; 390 | for (const workflow of result.related_workflows) { 391 | responseText += `### ${workflow.target}\n\n`; 392 | if (workflow.description) { 393 | responseText += `${workflow.description}\n\n`; 394 | } 395 | if (workflow.steps && workflow.steps.length > 0) { 396 | responseText += "Steps:\n"; 397 | workflow.steps.forEach((step: string, index: number) => { 398 | responseText += `${index + 1}. ${step}\n`; 399 | }); 400 | responseText += "\n"; 401 | } 402 | } 403 | } 404 | } else if (workflow) { 405 | responseText += `# Workflow: ${workflow}\n\n`; 406 | 407 | // Add workflow hints if available 408 | if (result.hints && result.hints.length > 0) { 409 | for (const hint of result.hints) { 410 | if (hint.description) { 411 | responseText += `## Description\n\n${hint.description}\n\n`; 412 | } 413 | 414 | if (hint.steps && hint.steps.length > 0) { 415 | responseText += "## Steps\n\n"; 416 | hint.steps.forEach((step: string, index: number) => { 417 | responseText += `${index + 1}. ${step}\n`; 418 | }); 419 | responseText += "\n"; 420 | } 421 | 422 | if (hint.related_doctypes && hint.related_doctypes.length > 0) { 423 | responseText += "## Related DocTypes\n\n"; 424 | responseText += hint.related_doctypes.join(", ") + "\n\n"; 425 | } 426 | } 427 | } else { 428 | responseText += "No workflow information available.\n"; 429 | } 430 | } 431 | 432 | return { 433 | content: [ 434 | { 435 | type: "text", 436 | text: responseText, 437 | }, 438 | ], 439 | }; 440 | } catch (error) { 441 | return formatErrorResponse(error, `get_frappe_usage_info(${doctype || workflow})`); 442 | } 443 | } 444 | 445 | return { 446 | content: [ 447 | { 448 | type: "text", 449 | text: `Schema operations module doesn't handle tool: ${name}`, 450 | }, 451 | ], 452 | isError: true, 453 | }; 454 | } catch (error) { 455 | return formatErrorResponse(error, `schema_operations.${name}`); 456 | } 457 | } 458 | 459 | export function setupSchemaTools(server: Server): void { 460 | // Initialize static hints 461 | console.error("Initializing static hints..."); 462 | initializeStaticHints().then(() => { 463 | console.error("Static hints initialized successfully"); 464 | }).catch(error => { 465 | console.error("Error initializing static hints:", error); 466 | }); 467 | 468 | // Initialize app introspection 469 | console.error("Initializing app introspection..."); 470 | initializeAppIntrospection().then(() => { 471 | console.error("App introspection initialized successfully"); 472 | }).catch(error => { 473 | console.error("Error initializing app introspection:", error); 474 | }); 475 | 476 | // We no longer register tools here, only resources 477 | // Tools are now registered in the central handler in index.ts 478 | 479 | // Register schema resources 480 | server.setRequestHandler(ListResourceTemplatesRequestSchema, async () => ({ 481 | resourceTemplates: [ 482 | { 483 | uriTemplate: "schema://{doctype}", 484 | name: "DocType Schema", 485 | mimeType: "application/json", 486 | description: 487 | "Schema information for a DocType including field definitions and validations", 488 | }, 489 | { 490 | uriTemplate: "schema://{doctype}/{fieldname}/options", 491 | name: "Field Options", 492 | mimeType: "application/json", 493 | description: "Available options for a Link or Select field", 494 | }, 495 | { 496 | uriTemplate: "schema://modules", 497 | name: "Module List", 498 | mimeType: "application/json", 499 | description: "List of all modules in the system", 500 | }, 501 | { 502 | uriTemplate: "schema://doctypes", 503 | name: "DocType List", 504 | mimeType: "application/json", 505 | description: "List of all DocTypes in the system", 506 | }, 507 | ], 508 | })); 509 | 510 | // Handle schema resource requests 511 | server.setRequestHandler(ReadResourceRequestSchema, async (request) => { 512 | const { uri } = request.params; 513 | 514 | try { 515 | // Handle DocType schema resource 516 | const schemaMatch = uri.match(/^schema:\/\/([^\/]+)$/); 517 | if (schemaMatch) { 518 | const doctype = decodeURIComponent(schemaMatch[1]); 519 | 520 | // Special case for modules list 521 | if (doctype === "modules") { 522 | const modules = await getAllModules(); 523 | return { 524 | contents: [ 525 | { 526 | uri, 527 | mimeType: "application/json", 528 | text: JSON.stringify(modules, null, 2), 529 | }, 530 | ], 531 | }; 532 | } 533 | 534 | // Special case for doctypes list 535 | if (doctype === "doctypes") { 536 | const doctypes = await getAllDocTypes(); 537 | return { 538 | contents: [ 539 | { 540 | uri, 541 | mimeType: "application/json", 542 | text: JSON.stringify(doctypes, null, 2), 543 | }, 544 | ], 545 | }; 546 | } 547 | 548 | // Regular DocType schema 549 | const schema = await getDocTypeSchema(doctype); 550 | return { 551 | contents: [ 552 | { 553 | uri, 554 | mimeType: "application/json", 555 | text: JSON.stringify(schema, null, 2), 556 | }, 557 | ], 558 | }; 559 | } 560 | 561 | // Handle field options resource 562 | const optionsMatch = uri.match(/^schema:\/\/([^\/]+)\/([^\/]+)\/options$/); 563 | if (optionsMatch) { 564 | const doctype = decodeURIComponent(optionsMatch[1]); 565 | const fieldname = decodeURIComponent(optionsMatch[2]); 566 | const options = await getFieldOptions(doctype, fieldname); 567 | 568 | return { 569 | contents: [ 570 | { 571 | uri, 572 | mimeType: "application/json", 573 | text: JSON.stringify(options, null, 2), 574 | }, 575 | ], 576 | }; 577 | } 578 | 579 | throw new McpError( 580 | ErrorCode.InvalidRequest, 581 | `Unknown resource URI: ${uri}` 582 | ); 583 | } catch (error) { 584 | console.error(`Error handling resource request for ${uri}:`, error); 585 | 586 | if (error instanceof McpError) { 587 | throw error; 588 | } 589 | 590 | if (error instanceof FrappeApiError) { 591 | throw new McpError( 592 | ErrorCode.InternalError, 593 | error.message 594 | ); 595 | } 596 | 597 | throw new McpError( 598 | ErrorCode.InternalError, 599 | `Error processing resource request: ${(error as Error).message}` 600 | ); 601 | } 602 | }); 603 | } -------------------------------------------------------------------------------- /src/server_hints/customer_hints.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "type": "doctype", 4 | "target": "Customer", 5 | "hint": "When searching for an existing Customer before creating a new one, be aware that names might have variations (e.g., 'Oproma' vs 'Oproma Inc.'). Consider using wildcard searches (e.g., 'Oproma%') or searching for common abbreviations or suffixes to find near matches before assuming the customer does not exist. Use the 'list_documents' tool with filters and the 'like' operator for wildcard searches." 6 | } 7 | ] -------------------------------------------------------------------------------- /src/server_hints/sales_hints.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "type": "doctype", 4 | "target": "Sales Order", 5 | "hint": "A Sales Order confirms a sale to a customer. It typically follows a Quotation and precedes a Delivery Note and Sales Invoice. Key fields include 'customer', 'transaction_date', 'delivery_date', and the 'items' table detailing products/services, quantities, and rates. Use this document to lock in the terms of a sale before fulfillment. When creating a Sales Order, ensure the customer details, item specifications, pricing, and delivery dates are accurate. The 'Grand Total' is calculated automatically based on item rates, quantities, and any applicable taxes or discounts." 6 | }, 7 | { 8 | "type": "workflow", 9 | "target": "Create Invoice from Sales Order", 10 | "id": "WF-SAL-001", 11 | "description": "Process for creating a Sales Invoice directly from an existing Sales Order to bill the customer.", 12 | "steps": [ 13 | "Open the submitted Sales Order that needs to be invoiced.", 14 | "Click on the 'Create' button in the top right corner of the Sales Order form.", 15 | "Select 'Sales Invoice' from the dropdown menu.", 16 | "In the new Sales Invoice form, verify that all details have been correctly copied from the Sales Order.", 17 | "Adjust quantities if you're creating a partial invoice.", 18 | "Add any additional charges or adjustments if needed.", 19 | "Save and submit the Sales Invoice to finalize the billing.", 20 | "The Sales Order's billing status will automatically update to reflect the invoiced amount." 21 | ], 22 | "related_doctypes": ["Sales Order", "Sales Invoice"] 23 | } 24 | ] -------------------------------------------------------------------------------- /src/static-hints.ts: -------------------------------------------------------------------------------- 1 | import fs from 'fs'; 2 | import path from 'path'; 3 | 4 | /** 5 | * Interface for a static hint 6 | */ 7 | export interface Hint { 8 | type: "doctype" | "workflow"; 9 | target: string; 10 | hint?: string; 11 | id?: string; 12 | description?: string; 13 | steps?: string[]; 14 | related_doctypes?: string[]; 15 | } 16 | 17 | /** 18 | * Indexed structure for static hints 19 | */ 20 | export interface StaticHints { 21 | doctype: Map; 22 | workflow: Map; 23 | } 24 | 25 | // Global variable to store indexed hints 26 | let staticHints: StaticHints = { 27 | doctype: new Map(), 28 | workflow: new Map(), 29 | }; 30 | 31 | /** 32 | * Load all hint files from the static_hints directory 33 | * @returns The loaded and indexed hints 34 | */ 35 | export async function loadStaticHints(): Promise { 36 | console.error('Loading static hints...'); 37 | 38 | const hintsDir = path.join(process.cwd(), 'src', 'server_hints'); // Changed path 39 | 40 | // Directory creation logic removed 41 | 42 | // Reset the hints 43 | staticHints = { 44 | doctype: new Map(), 45 | workflow: new Map(), 46 | }; 47 | 48 | try { 49 | // Read all JSON files in the directory 50 | const files = fs.readdirSync(hintsDir).filter(file => file.endsWith('.json')); 51 | console.error(`Found ${files.length} hint files`); 52 | 53 | for (const file of files) { 54 | try { 55 | const filePath = path.join(hintsDir, file); 56 | const content = fs.readFileSync(filePath, 'utf8'); 57 | const hints = JSON.parse(content) as Hint[]; 58 | 59 | if (!Array.isArray(hints)) { 60 | console.error(`Invalid hint file format in ${file}: expected an array of hints`); 61 | continue; 62 | } 63 | 64 | // Index the hints 65 | for (const hint of hints) { 66 | if (!hint.type || !hint.target) { 67 | console.error(`Invalid hint in ${file}: missing type or target`); 68 | continue; 69 | } 70 | 71 | // Validate hint structure based on type 72 | if (hint.type === 'doctype' && !hint.hint) { 73 | console.error(`Invalid doctype hint in ${file}: missing hint text`); 74 | continue; 75 | } 76 | 77 | if (hint.type === 'workflow' && (!hint.steps || !Array.isArray(hint.steps))) { 78 | console.error(`Invalid workflow hint in ${file}: missing or invalid steps`); 79 | continue; 80 | } 81 | 82 | // Add to the appropriate map 83 | const map = staticHints[hint.type]; 84 | const existing = map.get(hint.target) || []; 85 | existing.push(hint); 86 | map.set(hint.target, existing); 87 | } 88 | 89 | console.error(`Indexed hints from ${file}`); 90 | } catch (error) { 91 | console.error(`Error processing hint file ${file}:`, error); 92 | } 93 | } 94 | 95 | // Log summary 96 | console.error(`Loaded ${staticHints.doctype.size} DocType hints and ${staticHints.workflow.size} workflow hints`); 97 | 98 | return staticHints; 99 | } catch (error) { 100 | console.error('Error loading static hints:', error); 101 | return staticHints; 102 | } 103 | } 104 | 105 | /** 106 | * Get hints for a specific DocType 107 | * @param doctype The DocType name 108 | * @returns Array of hints for the DocType, or empty array if none found 109 | */ 110 | export function getDocTypeHints(doctype: string): Hint[] { 111 | return staticHints.doctype.get(doctype) || []; 112 | } 113 | 114 | /** 115 | * Get hints for a specific workflow 116 | * @param workflow The workflow name 117 | * @returns Array of hints for the workflow, or empty array if none found 118 | */ 119 | export function getWorkflowHints(workflow: string): Hint[] { 120 | return staticHints.workflow.get(workflow) || []; 121 | } 122 | 123 | /** 124 | * Find workflow hints that involve a specific DocType 125 | * @param doctype The DocType name 126 | * @returns Array of workflow hints that involve the DocType 127 | */ 128 | export function findWorkflowsForDocType(doctype: string): Hint[] { 129 | const results: Hint[] = []; 130 | 131 | for (const [_, hints] of staticHints.workflow.entries()) { 132 | for (const hint of hints) { 133 | if (hint.related_doctypes && hint.related_doctypes.includes(doctype)) { 134 | results.push(hint); 135 | } 136 | } 137 | } 138 | 139 | return results; 140 | } 141 | 142 | /** 143 | * Initialize the static hints system 144 | * This should be called during server startup 145 | */ 146 | export async function initializeStaticHints(): Promise { 147 | await loadStaticHints(); 148 | } -------------------------------------------------------------------------------- /test-usage-info.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Test Script for Usage Information Enhancement 3 | * 4 | * This script tests the combined usage information functionality that integrates: 5 | * 1. Frappe metadata (schema) 6 | * 2. Static hints 7 | * 3. Custom app introspection 8 | * 9 | * Run with: node test-usage-info.js 10 | */ 11 | 12 | import axios from 'axios'; 13 | 14 | // Configuration 15 | const SERVER_URL = 'http://localhost:3000'; // Adjust if your server runs on a different port 16 | const API_ENDPOINT = `${SERVER_URL}/api/v1/tools/call`; 17 | 18 | // Helper function to call the MCP server tools 19 | async function callTool(toolName, args) { 20 | try { 21 | console.log(`\n=== Calling ${toolName} ===`); 22 | console.log('Arguments:', JSON.stringify(args, null, 2)); 23 | 24 | const response = await axios.post(API_ENDPOINT, { 25 | name: toolName, 26 | arguments: args 27 | }); 28 | 29 | // Extract the text content from the response 30 | const textContent = response.data.content 31 | .filter(item => item.type === 'text') 32 | .map(item => item.text) 33 | .join('\n'); 34 | 35 | console.log('\nResponse:'); 36 | console.log('-------------------------------------------'); 37 | console.log(textContent); 38 | console.log('-------------------------------------------'); 39 | 40 | return textContent; 41 | } catch (error) { 42 | console.error('Error calling tool:', error.response?.data || error.message); 43 | return null; 44 | } 45 | } 46 | 47 | // Main test function 48 | async function runTests() { 49 | console.log('Starting Usage Information Enhancement Tests'); 50 | 51 | // Test 1: Core Frappe DocType (User) 52 | // This should primarily return schema information 53 | await callTool('get_frappe_usage_info', { 54 | doctype: 'User' 55 | }); 56 | 57 | // Test 2: DocType with static hints (Sales Order) 58 | // This should return both schema and static hints 59 | await callTool('get_frappe_usage_info', { 60 | doctype: 'Sales Order' 61 | }); 62 | 63 | // Test 3: Workflow defined in static hints 64 | // This should return workflow information from static hints 65 | await callTool('get_frappe_usage_info', { 66 | workflow: 'Quote to Sales Order Conversion' 67 | }); 68 | 69 | // Test 4: EpiStart DocType (using custom app introspection) 70 | // This should return app-provided instructions 71 | await callTool('get_frappe_usage_info', { 72 | doctype: 'Venture' 73 | }); 74 | 75 | // Test 5: App-level instructions for EpiStart 76 | // This should return app-level instructions 77 | await callTool('get_frappe_usage_info', { 78 | doctype: 'EpiStart' 79 | }); 80 | 81 | console.log('\nAll tests completed!'); 82 | } 83 | 84 | // Run the tests 85 | runTests().catch(error => { 86 | console.error('Test failed:', error); 87 | }); -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ES2020", 4 | "module": "NodeNext", 5 | "moduleResolution": "NodeNext", 6 | "esModuleInterop": true, 7 | "strict": true, 8 | "outDir": "build", 9 | "declaration": true, 10 | "sourceMap": true, 11 | "resolveJsonModule": true, 12 | "skipLibCheck": true, 13 | "forceConsistentCasingInFileNames": true 14 | }, 15 | "include": ["src/**/*"], 16 | "exclude": ["node_modules", "build"] 17 | } --------------------------------------------------------------------------------