# Directory Structure ``` ├── .gitignore ├── docs │ ├── analysis.md │ ├── code-restructuring.md │ ├── error-handling-and-logging.md │ ├── feature-improvements.md │ ├── implementation-roadmap.md │ ├── README.md │ ├── security-and-authentication.md │ └── testing-strategy.md ├── LICENSE ├── package-lock.json ├── package.json ├── README.md ├── src │ └── index.ts └── tsconfig.json ``` # Files -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- ``` 1 | node_modules/ 2 | build/ 3 | *.log 4 | .env* ``` -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- ```markdown 1 | # ERPNext MCP Server 2 | 3 | A Model Context Protocol server for ERPNext integration 4 | 5 | This is a TypeScript-based MCP server that provides integration with ERPNext/Frappe API. It enables AI assistants to interact with ERPNext data and functionality through the Model Context Protocol. 6 | 7 | ## Features 8 | 9 | ### Resources 10 | - Access ERPNext documents via `erpnext://{doctype}/{name}` URIs 11 | - JSON format for structured data access 12 | 13 | ### Tools 14 | - `authenticate_erpnext` - Authenticate with ERPNext using username and password 15 | - `get_documents` - Get a list of documents for a specific doctype 16 | - `create_document` - Create a new document in ERPNext 17 | - `update_document` - Update an existing document in ERPNext 18 | - `run_report` - Run an ERPNext report 19 | - `get_doctype_fields` - Get fields list for a specific DocType 20 | - `get_doctypes` - Get a list of all available DocTypes 21 | 22 | ## Configuration 23 | 24 | The server requires the following environment variables: 25 | - `ERPNEXT_URL` - The base URL of your ERPNext instance 26 | - `ERPNEXT_API_KEY` (optional) - API key for authentication 27 | - `ERPNEXT_API_SECRET` (optional) - API secret for authentication 28 | 29 | ## Development 30 | 31 | Install dependencies: 32 | ```bash 33 | npm install 34 | ``` 35 | 36 | Build the server: 37 | ```bash 38 | npm run build 39 | ``` 40 | 41 | For development with auto-rebuild: 42 | ```bash 43 | npm run watch 44 | ``` 45 | 46 | ## Installation 47 | 48 | To use with Claude Desktop, add the server config: 49 | 50 | On MacOS: `~/Library/Application Support/Claude/claude_desktop_config.json` 51 | On Windows: `%APPDATA%/Claude/claude_desktop_config.json` 52 | 53 | ```json 54 | { 55 | "mcpServers": { 56 | "erpnext": { 57 | "command": "node", 58 | "args": ["/path/to/erpnext-server/build/index.js"], 59 | "env": { 60 | "ERPNEXT_URL": "http://your-erpnext-instance.com", 61 | "ERPNEXT_API_KEY": "your-api-key", 62 | "ERPNEXT_API_SECRET": "your-api-secret" 63 | } 64 | } 65 | } 66 | } 67 | ``` 68 | 69 | To use with Claude in VSCode, add the server config to: 70 | 71 | On MacOS: `~/Library/Application Support/Code/User/globalStorage/saoudrizwan.claude-dev/settings/cline_mcp_settings.json` 72 | On Windows: `%APPDATA%/Code/User/globalStorage/saoudrizwan.claude-dev/settings/cline_mcp_settings.json` 73 | 74 | ### Debugging 75 | 76 | Since MCP servers communicate over stdio, debugging can be challenging. We recommend using the [MCP Inspector](https://github.com/modelcontextprotocol/inspector), which is available as a package script: 77 | 78 | ```bash 79 | npm run inspector 80 | ``` 81 | 82 | The Inspector will provide a URL to access debugging tools in your browser. 83 | 84 | ## Usage Examples 85 | 86 | ### Authentication 87 | ``` 88 | <use_mcp_tool> 89 | <server_name>erpnext</server_name> 90 | <tool_name>authenticate_erpnext</tool_name> 91 | <arguments> 92 | { 93 | "username": "your-username", 94 | "password": "your-password" 95 | } 96 | </arguments> 97 | </use_mcp_tool> 98 | ``` 99 | 100 | ### Get Customer List 101 | ``` 102 | <use_mcp_tool> 103 | <server_name>erpnext</server_name> 104 | <tool_name>get_documents</tool_name> 105 | <arguments> 106 | { 107 | "doctype": "Customer" 108 | } 109 | </arguments> 110 | </use_mcp_tool> 111 | ``` 112 | 113 | ### Get Customer Details 114 | ``` 115 | <access_mcp_resource> 116 | <server_name>erpnext</server_name> 117 | <uri>erpnext://Customer/CUSTOMER001</uri> 118 | </access_mcp_resource> 119 | ``` 120 | 121 | ### Create New Item 122 | ``` 123 | <use_mcp_tool> 124 | <server_name>erpnext</server_name> 125 | <tool_name>create_document</tool_name> 126 | <arguments> 127 | { 128 | "doctype": "Item", 129 | "data": { 130 | "item_code": "ITEM001", 131 | "item_name": "Test Item", 132 | "item_group": "Products", 133 | "stock_uom": "Nos" 134 | } 135 | } 136 | </arguments> 137 | </use_mcp_tool> 138 | ``` 139 | 140 | ### Get Item Fields 141 | ``` 142 | <use_mcp_tool> 143 | <server_name>erpnext</server_name> 144 | <tool_name>get_doctype_fields</tool_name> 145 | <arguments> 146 | { 147 | "doctype": "Item" 148 | } 149 | </arguments> 150 | </use_mcp_tool> 151 | ``` -------------------------------------------------------------------------------- /docs/README.md: -------------------------------------------------------------------------------- ```markdown 1 | # ERPNext MCP Server Improvement Documentation 2 | 3 | This directory contains comprehensive documentation for improving the ERPNext MCP server implementation. These documents analyze the current state of the server and provide detailed recommendations for enhancing its maintainability, reliability, security, and functionality. 4 | 5 | ## Document Overview 6 | 7 | ### [Analysis](./analysis.md) 8 | A comprehensive analysis of the current ERPNext MCP server implementation, identifying key areas for improvement including code organization, error handling, authentication, caching, testing, logging, documentation, configuration, rate limiting, tool implementation, security, resource management, and dependency management. 9 | 10 | ### [Code Restructuring](./code-restructuring.md) 11 | Detailed plan for restructuring the codebase to improve maintainability and testability, including a proposed directory structure, key component implementation examples, and step-by-step implementation plan. 12 | 13 | ### [Testing Strategy](./testing-strategy.md) 14 | Comprehensive testing approach covering unit tests, integration tests, and end-to-end tests, with code examples, mock implementations, and configuration details for setting up a robust testing framework. 15 | 16 | ### [Error Handling and Logging](./error-handling-and-logging.md) 17 | Strategy for implementing structured error handling and logging, including error categorization, correlation IDs, contextual logging, and integration with the MCP server. 18 | 19 | ### [Security and Authentication](./security-and-authentication.md) 20 | Detailed recommendations for enhancing security and authentication, covering input validation, secure configuration management, rate limiting, security middleware, and best practices for credential handling. 21 | 22 | ### [Implementation Roadmap](./implementation-roadmap.md) 23 | Phased approach for implementing all recommended improvements, with timeline estimates, dependencies, task prioritization, and risk management considerations. 24 | 25 | ### [Feature Improvements](./feature-improvements.md) 26 | Detailed recommendations for enhancing the server's functionality with new features like advanced querying, bulk operations, webhooks, document workflows, file operations, and more. 27 | 28 | ## Getting Started 29 | 30 | If you're new to this documentation, we recommend starting with the [Analysis](./analysis.md) document to understand the current state and identified areas for improvement. Then, review the [Implementation Roadmap](./implementation-roadmap.md) for a structured approach to implementing the recommended changes. 31 | 32 | ## Key Improvement Areas 33 | 34 | 1. **Code Organization**: Transition from a monolithic design to a modular architecture 35 | 2. **Error Handling**: Implement structured error handling with proper categorization 36 | 3. **Testing**: Add comprehensive test coverage with unit, integration, and E2E tests 37 | 4. **Logging**: Enhance logging with structured formats and contextual information 38 | 5. **Security**: Improve authentication, add input validation, and implement rate limiting 39 | 6. **Performance**: Add caching and optimize API interactions 40 | 7. **Maintainability**: Improve documentation and implement best practices 41 | 42 | ## Implementation Guide 43 | 44 | For implementing these improvements, follow the phases outlined in the [Implementation Roadmap](./implementation-roadmap.md). This allows for incremental enhancement while maintaining a functioning system throughout the process. 45 | 46 | ## Additional Resources 47 | 48 | - [Model Context Protocol Documentation](https://github.com/modelcontextprotocol/mcp) 49 | - [ERPNext API Reference](https://frappeframework.com/docs/user/en/api) 50 | - [Frappe Framework Documentation](https://frappeframework.com/docs) 51 | ``` -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- ```json 1 | { 2 | "compilerOptions": { 3 | "target": "ES2022", 4 | "module": "Node16", 5 | "moduleResolution": "Node16", 6 | "outDir": "./build", 7 | "rootDir": "./src", 8 | "strict": true, 9 | "esModuleInterop": true, 10 | "skipLibCheck": true, 11 | "forceConsistentCasingInFileNames": true 12 | }, 13 | "include": ["src/**/*"], 14 | "exclude": ["node_modules"] 15 | } 16 | ``` -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- ```json 1 | { 2 | "name": "erpnext-server", 3 | "version": "0.1.0", 4 | "description": "A Model Context Protocol server", 5 | "private": true, 6 | "type": "module", 7 | "bin": { 8 | "erpnext-server": "./build/index.js" 9 | }, 10 | "files": [ 11 | "build" 12 | ], 13 | "scripts": { 14 | "build": "tsc && node -e \"require('fs').chmodSync('build/index.js', '755')\"", 15 | "prepare": "npm run build", 16 | "watch": "tsc --watch", 17 | "inspector": "npx @modelcontextprotocol/inspector build/index.js" 18 | }, 19 | "dependencies": { 20 | "@modelcontextprotocol/sdk": "0.6.0", 21 | "axios": "^1.8.4" 22 | }, 23 | "devDependencies": { 24 | "@types/node": "^20.11.24", 25 | "typescript": "^5.3.3" 26 | } 27 | } 28 | ``` -------------------------------------------------------------------------------- /docs/analysis.md: -------------------------------------------------------------------------------- ```markdown 1 | # ERPNext MCP Server Analysis 2 | 3 | This document provides an analysis of the current ERPNext MCP server implementation and suggests improvements for better reliability, maintainability, and extensibility. 4 | 5 | ## Current Implementation Overview 6 | 7 | The ERPNext MCP server currently provides integration with the ERPNext/Frappe API through: 8 | 9 | - **Resource access**: Documents can be accessed via `erpnext://{doctype}/{name}` URIs 10 | - **Tools**: Several tools for authentication, document manipulation, and report running 11 | - **Single-file implementation**: All logic is contained in `src/index.ts` 12 | 13 | ## Areas for Improvement 14 | 15 | ### 1. Code Organization 16 | 17 | **Issues:** 18 | - The entire server implementation is in a single file, making it difficult to maintain as it grows 19 | - No separation of concerns between API client, MCP server, and request handlers 20 | - Mixed business logic and protocol handling 21 | 22 | **Recommendations:** 23 | - Restructure the codebase into multiple modules: 24 | ``` 25 | src/ 26 | ├── client/ 27 | │ └── erpnext-client.ts # ERPNext API client logic 28 | ├── handlers/ 29 | │ ├── resource-handlers.ts # Resource request handlers 30 | │ └── tool-handlers.ts # Tool request handlers 31 | ├── models/ 32 | │ └── types.ts # TypeScript interfaces and types 33 | ├── utils/ 34 | │ ├── error-handler.ts # Error handling utilities 35 | │ ├── logger.ts # Logging utilities 36 | │ └── config.ts # Configuration management 37 | └── index.ts # Server bootstrap 38 | ``` 39 | 40 | ### 2. Error Handling 41 | 42 | **Issues:** 43 | - Basic error handling with limited categorization 44 | - Inconsistent error response formats 45 | - No handling of network-specific errors vs. business logic errors 46 | 47 | **Recommendations:** 48 | - Create a dedicated error handling module 49 | - Map ERPNext API errors to appropriate MCP error codes 50 | - Implement consistent error logging with appropriate detail levels 51 | - Add correlation IDs to track errors across requests 52 | 53 | ### 3. Authentication 54 | 55 | **Issues:** 56 | - Only username/password authentication supported 57 | - No token refresh mechanism 58 | - API key/secret handling is basic 59 | 60 | **Recommendations:** 61 | - Support modern OAuth-based authentication 62 | - Implement token refresh strategy 63 | - Add secure credential storage options 64 | - Consider implementing session management 65 | 66 | ### 4. Caching 67 | 68 | **Issues:** 69 | - `doctypeCache` is defined but never used 70 | - No caching strategy for frequently accessed data 71 | - Each request makes a fresh API call 72 | 73 | **Recommendations:** 74 | - Implement in-memory cache for DocType metadata 75 | - Add time-based cache expiration 76 | - Consider using a dedicated cache library for more complex scenarios 77 | - Add cache invalidation when documents are updated 78 | 79 | ### 5. Testing 80 | 81 | **Issues:** 82 | - No test suite 83 | - No mocking of external dependencies 84 | 85 | **Recommendations:** 86 | - Add unit tests for core functionality 87 | - Implement integration tests for ERPNext client 88 | - Create mock ERPNext API for testing 89 | - Set up CI/CD pipeline with test automation 90 | 91 | ### 6. Logging 92 | 93 | **Issues:** 94 | - Basic console logging 95 | - No structured log format 96 | - No log levels for different environments 97 | 98 | **Recommendations:** 99 | - Implement structured logging 100 | - Add configurable log levels 101 | - Include request/response details for debugging 102 | - Consider using a dedicated logging library 103 | 104 | ### 7. Documentation 105 | 106 | **Issues:** 107 | - Basic README with limited examples 108 | - No JSDoc comments for most functions 109 | - No detailed API documentation 110 | 111 | **Recommendations:** 112 | - Add comprehensive JSDoc comments 113 | - Create detailed API reference 114 | - Add code examples for common scenarios 115 | - Document error codes and their meanings 116 | - Consider generating API docs with TypeDoc 117 | 118 | ### 8. Configuration 119 | 120 | **Issues:** 121 | - Environment variables handled in an ad-hoc way 122 | - No configuration validation 123 | - Limited configuration options 124 | 125 | **Recommendations:** 126 | - Create a dedicated configuration module 127 | - Add validation for required configuration 128 | - Support multiple configuration sources (env vars, config files) 129 | - Add configuration schema validation 130 | 131 | ### 9. Rate Limiting 132 | 133 | **Issues:** 134 | - No protection against excessive API calls 135 | - Potential for unintended DoS on ERPNext instance 136 | 137 | **Recommendations:** 138 | - Implement rate limiting for API calls 139 | - Add configurable limits per operation 140 | - Provide feedback on rate limit status 141 | 142 | ### 10. Tool Implementation 143 | 144 | **Issues:** 145 | - `get_doctype_fields` has a hacky implementation that loads a sample document 146 | - Limited error handling in tool implementations 147 | - No pagination support for large result sets 148 | 149 | **Recommendations:** 150 | - Use proper metadata APIs to get DocType fields 151 | - Implement pagination for list operations 152 | - Add better validation of tool inputs 153 | - Consider adding additional useful tools (e.g., bulk operations) 154 | 155 | ### 11. Security 156 | 157 | **Issues:** 158 | - Passwords are passed directly with no handling recommendations 159 | - No security headers or CORS configuration 160 | - No input sanitization 161 | 162 | **Recommendations:** 163 | - Add input validation and sanitization 164 | - Improve credential handling and provide secure usage guidelines 165 | - Document security best practices 166 | 167 | ### 12. Resource Management 168 | 169 | **Issues:** 170 | - Limited resource implementations compared to tools 171 | - No dynamic resource discovery 172 | 173 | **Recommendations:** 174 | - Expand resource capabilities 175 | - Implement more resource templates 176 | - Add resource discovery mechanism 177 | 178 | ### 13. Dependency Management 179 | 180 | **Issues:** 181 | - No dependency injection for better testability 182 | - Direct dependency on axios without abstraction 183 | 184 | **Recommendations:** 185 | - Implement dependency injection pattern 186 | - Create HTTP client abstraction to allow switching libraries 187 | - Centralize external dependency management 188 | 189 | ## Conclusion 190 | 191 | The ERPNext MCP server provides a solid foundation for integration with ERPNext but could benefit from several improvements to make it more maintainable, robust, and feature-rich. The recommendations provided above would help transform it into a production-ready solution that's easier to maintain and extend. 192 | ``` -------------------------------------------------------------------------------- /docs/implementation-roadmap.md: -------------------------------------------------------------------------------- ```markdown 1 | # Implementation Roadmap 2 | 3 | This document outlines a comprehensive roadmap for implementing the improvements suggested in the analysis of the ERPNext MCP server. 4 | 5 | ## Overview 6 | 7 | The ERPNext MCP server provides integration with ERPNext through the Model Context Protocol, but several improvements can enhance its maintainability, reliability, and security. This roadmap prioritizes these improvements into phases for systematic implementation. 8 | 9 | ## Phase 1: Code Restructuring and Basic Improvements 10 | 11 | **Objective**: Establish a solid foundation by restructuring the codebase into a more maintainable architecture. 12 | 13 | **Duration**: 2-3 weeks 14 | 15 | ### Tasks 16 | 17 | 1. **Project Structure Reorganization** 18 | - Create directory structure as outlined in [`code-restructuring.md`](./code-restructuring.md) 19 | - Split the monolithic `src/index.ts` into modular components 20 | 21 | 2. **Core Module Extraction** 22 | - Extract ERPNext client into `src/client/erpnext-client.ts` 23 | - Extract resource handlers into `src/handlers/resource-handlers.ts` 24 | - Extract tool handlers into `src/handlers/tool-handlers.ts` 25 | 26 | 3. **Configuration Management** 27 | - Create dedicated configuration module in `src/utils/config.ts` 28 | - Implement validation for required configuration 29 | - Add support for multiple configuration sources 30 | 31 | 4. **Basic Logging** 32 | - Implement structured logging in `src/utils/logger.ts` 33 | - Add different log levels for development and production 34 | - Add contextual information to logs 35 | 36 | ### Deliverables 37 | - Modular codebase with clear separation of concerns 38 | - Improved configuration management 39 | - Basic structured logging 40 | 41 | ## Phase 2: Enhanced Error Handling and Testing 42 | 43 | **Objective**: Improve reliability through better error handling and establish a testing framework. 44 | 45 | **Duration**: 3-4 weeks 46 | 47 | ### Tasks 48 | 49 | 1. **Error Handling** 50 | - Implement error categorization as outlined in [`error-handling-and-logging.md`](./error-handling-and-logging.md) 51 | - Create error mapping between ERPNext and MCP error codes 52 | - Add correlation IDs for tracking errors across the system 53 | 54 | 2. **Testing Framework Setup** 55 | - Set up Jest for testing 56 | - Create test directory structure 57 | - Add test utilities and mocks 58 | 59 | 3. **Unit Tests** 60 | - Write unit tests for core modules (client, utils) 61 | - Achieve at least 70% code coverage 62 | 63 | 4. **Integration Tests** 64 | - Add integration tests for API interactions 65 | - Create mock ERPNext API for testing 66 | 67 | ### Deliverables 68 | - Robust error handling system 69 | - Test suite with good coverage 70 | - Mock ERPNext API for testing 71 | 72 | ## Phase 3: Security and Authentication Enhancements 73 | 74 | **Objective**: Strengthen security and improve authentication mechanisms. 75 | 76 | **Duration**: 2-3 weeks 77 | 78 | ### Tasks 79 | 80 | 1. **Input Validation** 81 | - Create validation module as outlined in [`security-and-authentication.md`](./security-and-authentication.md) 82 | - Implement schema validation for all inputs 83 | - Add input sanitization to prevent injection attacks 84 | 85 | 2. **Authentication Improvements** 86 | - Implement authentication manager 87 | - Add token management 88 | - Implement secure credential handling 89 | 90 | 3. **Rate Limiting** 91 | - Add rate limiting middleware 92 | - Implement different limits for different operations 93 | - Add protection against brute force attacks 94 | 95 | 4. **Security Headers** 96 | - Add security headers to responses 97 | - Implement secure defaults 98 | 99 | ### Deliverables 100 | - Secure input validation 101 | - Enhanced authentication 102 | - Protection against common attacks 103 | 104 | ## Phase 4: Advanced Features and Performance 105 | 106 | **Objective**: Add advanced features and optimize performance. 107 | 108 | **Duration**: 4-6 weeks 109 | 110 | ### Tasks 111 | 112 | 1. **Caching Implementation** 113 | - Implement in-memory cache 114 | - Add cache invalidation logic 115 | - Optimize frequently accessed resources 116 | 117 | 2. **Documentation** 118 | - Add comprehensive JSDoc comments 119 | - Generate API documentation 120 | - Update README with examples 121 | 122 | 3. **Pagination Support** 123 | - Add pagination for list operations 124 | - Implement cursor-based pagination for large result sets 125 | 126 | 4. **Resource Expansion** 127 | - Enhance resource capabilities 128 | - Add more resource templates 129 | - Improve discovery mechanism 130 | 131 | 5. **Performance Optimization** 132 | - Implement request batching 133 | - Optimize network requests 134 | - Add performance metrics 135 | 136 | 6. **Feature Enhancements** (as outlined in [`feature-improvements.md`](./feature-improvements.md)) 137 | - Implement enhanced DocType discovery and metadata 138 | - Add advanced querying capabilities 139 | - Develop bulk operations support 140 | - Create file operations functionality 141 | - Add webhook and event support 142 | - Implement document workflows 143 | - Develop data synchronization utilities 144 | 145 | ### Deliverables 146 | - Caching system for improved performance 147 | - Comprehensive documentation 148 | - Enhanced resource capabilities 149 | - Performance metrics and optimizations 150 | - New functional capabilities as detailed in feature improvements 151 | 152 | ## Phase 5: CI/CD and DevOps 153 | 154 | **Objective**: Set up continuous integration and deployment pipeline. 155 | 156 | **Duration**: 1-2 weeks 157 | 158 | ### Tasks 159 | 160 | 1. **CI Setup** 161 | - Configure GitHub Actions or similar CI platform 162 | - Automate testing 163 | - Implement code quality checks 164 | 165 | 2. **Automated Builds** 166 | - Set up automated builds 167 | - Create release versioning 168 | - Generate build artifacts 169 | 170 | 3. **Deployment Automation** 171 | - Create deployment scripts 172 | - Add environment configuration templates 173 | - Document deployment process 174 | 175 | ### Deliverables 176 | - Automated CI/CD pipeline 177 | - Code quality checks 178 | - Streamlined release process 179 | 180 | ## Timeline Overview 181 | 182 | ``` 183 | Week 1-3: Phase 1 - Code Restructuring 184 | Week 4-7: Phase 2 - Error Handling and Testing 185 | Week 8-10: Phase 3 - Security Enhancements 186 | Week 11-14: Phase 4 - Advanced Features 187 | Week 15-16: Phase 5 - CI/CD and DevOps 188 | ``` 189 | 190 | ## Implementation Priorities 191 | 192 | When implementing these improvements, follow these priorities: 193 | 194 | 1. **Critical**: Code restructuring, basic error handling 195 | 2. **High**: Testing setup, security improvements 196 | 3. **Medium**: Caching, pagination, documentation 197 | 4. **Low**: Advanced features, CI/CD 198 | 199 | ## Dependencies and Requirements 200 | 201 | For implementing these improvements, ensure: 202 | 203 | 1. Access to ERPNext instance for testing 204 | 2. Node.js development environment 205 | 3. Knowledge of TypeScript and MCP SDK 206 | 4. Understanding of ERPNext API 207 | 208 | ## Risk Management 209 | 210 | Some potential risks to consider: 211 | 212 | 1. **API Changes**: ERPNext may update its API, requiring adjustments 213 | 2. **Backward Compatibility**: Ensure changes don't break existing clients 214 | 3. **Performance Impact**: Monitor performance impacts of changes 215 | 216 | ## Conclusion 217 | 218 | Following this roadmap will transform the ERPNext MCP server into a robust, maintainable, and secure system. The phased approach allows for incremental improvements while maintaining a functioning system throughout the process. 219 | ``` -------------------------------------------------------------------------------- /docs/code-restructuring.md: -------------------------------------------------------------------------------- ```markdown 1 | # Code Restructuring Plan 2 | 3 | This document outlines a plan for restructuring the ERPNext MCP server codebase to improve maintainability, testability, and extensibility. 4 | 5 | ## Current Structure 6 | 7 | Currently, the entire implementation is in a single file (`src/index.ts`), which contains: 8 | 9 | - ERPNext API client 10 | - MCP server setup 11 | - Resource handlers 12 | - Tool handlers 13 | - Error handling 14 | - Authentication logic 15 | 16 | This monolithic approach makes the code difficult to maintain, test, and extend. 17 | 18 | ## Proposed Structure 19 | 20 | ### Directory Structure 21 | 22 | ``` 23 | src/ 24 | ├── client/ 25 | │ └── erpnext-client.ts # ERPNext API client 26 | ├── handlers/ 27 | │ ├── resource-handlers.ts # Resource request handlers 28 | │ └── tool-handlers.ts # Tool request handlers 29 | ├── models/ 30 | │ ├── doctype.ts # DocType interfaces 31 | │ └── errors.ts # Error types 32 | ├── utils/ 33 | │ ├── cache.ts # Caching utilities 34 | │ ├── config.ts # Configuration management 35 | │ ├── error-handler.ts # Error handling utilities 36 | │ └── logger.ts # Logging utilities 37 | ├── constants/ 38 | │ └── error-codes.ts # Error code definitions 39 | ├── middleware/ 40 | │ ├── auth.ts # Authentication middleware 41 | │ └── rate-limiter.ts # Rate limiting middleware 42 | └── index.ts # Server bootstrap 43 | ``` 44 | 45 | ### Key Components 46 | 47 | #### 1. ERPNext Client (`src/client/erpnext-client.ts`) 48 | 49 | Extract the ERPNext API client into a dedicated module: 50 | 51 | ```typescript 52 | // src/client/erpnext-client.ts 53 | import axios, { AxiosInstance } from "axios"; 54 | import { Logger } from "../utils/logger"; 55 | import { Config } from "../utils/config"; 56 | import { ERPNextError } from "../models/errors"; 57 | 58 | export class ERPNextClient { 59 | private baseUrl: string; 60 | private axiosInstance: AxiosInstance; 61 | private authenticated: boolean = false; 62 | private logger: Logger; 63 | 64 | constructor(config: Config, logger: Logger) { 65 | this.logger = logger; 66 | this.baseUrl = config.getERPNextUrl(); 67 | 68 | // Initialize axios instance 69 | this.axiosInstance = axios.create({ 70 | baseURL: this.baseUrl, 71 | withCredentials: true, 72 | headers: { 73 | 'Content-Type': 'application/json', 74 | 'Accept': 'application/json' 75 | } 76 | }); 77 | 78 | // Configure authentication if credentials provided 79 | const apiKey = config.getERPNextApiKey(); 80 | const apiSecret = config.getERPNextApiSecret(); 81 | 82 | if (apiKey && apiSecret) { 83 | this.axiosInstance.defaults.headers.common['Authorization'] = 84 | `token ${apiKey}:${apiSecret}`; 85 | this.authenticated = true; 86 | this.logger.info("Initialized with API key authentication"); 87 | } 88 | } 89 | 90 | // Methods for API operations... 91 | } 92 | ``` 93 | 94 | #### 2. Resource Handlers (`src/handlers/resource-handlers.ts`) 95 | 96 | Move the resource-related request handlers to a dedicated module: 97 | 98 | ```typescript 99 | // src/handlers/resource-handlers.ts 100 | import { Server } from "@modelcontextprotocol/sdk/server/index.js"; 101 | import { 102 | ListResourcesRequestSchema, 103 | ListResourceTemplatesRequestSchema, 104 | ReadResourceRequestSchema, 105 | McpError, 106 | ErrorCode 107 | } from "@modelcontextprotocol/sdk/types.js"; 108 | import { ERPNextClient } from "../client/erpnext-client"; 109 | import { Logger } from "../utils/logger"; 110 | import { Cache } from "../utils/cache"; 111 | 112 | export function registerResourceHandlers( 113 | server: Server, 114 | erpnext: ERPNextClient, 115 | cache: Cache, 116 | logger: Logger 117 | ) { 118 | // Handler for listing resources 119 | server.setRequestHandler(ListResourcesRequestSchema, async () => { 120 | logger.debug("Handling ListResourcesRequest"); 121 | 122 | const resources = [ 123 | { 124 | uri: "erpnext://DocTypes", 125 | name: "All DocTypes", 126 | mimeType: "application/json", 127 | description: "List of all available DocTypes in the ERPNext instance" 128 | } 129 | ]; 130 | 131 | return { resources }; 132 | }); 133 | 134 | // Other resource handlers... 135 | } 136 | ``` 137 | 138 | #### 3. Tool Handlers (`src/handlers/tool-handlers.ts`) 139 | 140 | Similarly, move the tool-related request handlers: 141 | 142 | ```typescript 143 | // src/handlers/tool-handlers.ts 144 | import { Server } from "@modelcontextprotocol/sdk/server/index.js"; 145 | import { 146 | CallToolRequestSchema, 147 | ListToolsRequestSchema, 148 | McpError, 149 | ErrorCode 150 | } from "@modelcontextprotocol/sdk/types.js"; 151 | import { ERPNextClient } from "../client/erpnext-client"; 152 | import { Logger } from "../utils/logger"; 153 | import { handleErrors } from "../utils/error-handler"; 154 | 155 | export function registerToolHandlers( 156 | server: Server, 157 | erpnext: ERPNextClient, 158 | logger: Logger 159 | ) { 160 | // Handler for listing tools 161 | server.setRequestHandler(ListToolsRequestSchema, async () => { 162 | logger.debug("Handling ListToolsRequest"); 163 | 164 | return { 165 | tools: [ 166 | { 167 | name: "get_doctypes", 168 | description: "Get a list of all available DocTypes", 169 | inputSchema: { 170 | type: "object", 171 | properties: {} 172 | } 173 | }, 174 | // Other tools... 175 | ] 176 | }; 177 | }); 178 | 179 | // Handler for tool calls with proper error handling 180 | server.setRequestHandler(CallToolRequestSchema, async (request) => { 181 | logger.debug(`Handling CallToolRequest: ${request.params.name}`); 182 | 183 | try { 184 | switch (request.params.name) { 185 | case "authenticate_erpnext": 186 | return await handleAuthenticateErpnext(request, erpnext, logger); 187 | // Other tool handlers... 188 | default: 189 | throw new McpError( 190 | ErrorCode.MethodNotFound, 191 | `Unknown tool: ${request.params.name}` 192 | ); 193 | } 194 | } catch (error) { 195 | return handleErrors(error, logger); 196 | } 197 | }); 198 | } 199 | 200 | // Individual tool handler functions 201 | async function handleAuthenticateErpnext(request, erpnext, logger) { 202 | // Implementation... 203 | } 204 | ``` 205 | 206 | #### 4. Cache Utility (`src/utils/cache.ts`) 207 | 208 | Implement the previously defined but unused cache: 209 | 210 | ```typescript 211 | // src/utils/cache.ts 212 | export class Cache { 213 | private cache: Map<string, CacheEntry>; 214 | private defaultTTLMs: number; 215 | 216 | constructor(defaultTTLMs: number = 5 * 60 * 1000) { // 5 minutes default 217 | this.cache = new Map(); 218 | this.defaultTTLMs = defaultTTLMs; 219 | } 220 | 221 | set(key: string, value: any, ttlMs?: number): void { 222 | const expiryTime = Date.now() + (ttlMs || this.defaultTTLMs); 223 | this.cache.set(key, { value, expiryTime }); 224 | } 225 | 226 | get<T>(key: string): T | undefined { 227 | const entry = this.cache.get(key); 228 | 229 | if (!entry) { 230 | return undefined; 231 | } 232 | 233 | if (Date.now() > entry.expiryTime) { 234 | this.cache.delete(key); 235 | return undefined; 236 | } 237 | 238 | return entry.value as T; 239 | } 240 | 241 | invalidate(keyPrefix: string): void { 242 | for (const key of this.cache.keys()) { 243 | if (key.startsWith(keyPrefix)) { 244 | this.cache.delete(key); 245 | } 246 | } 247 | } 248 | } 249 | 250 | interface CacheEntry { 251 | value: any; 252 | expiryTime: number; 253 | } 254 | ``` 255 | 256 | #### 5. Configuration Module (`src/utils/config.ts`) 257 | 258 | Create a dedicated configuration module: 259 | 260 | ```typescript 261 | // src/utils/config.ts 262 | export class Config { 263 | private erpnextUrl: string; 264 | private apiKey?: string; 265 | private apiSecret?: string; 266 | 267 | constructor() { 268 | this.erpnextUrl = this.getRequiredEnv("ERPNEXT_URL"); 269 | // Remove trailing slash if present 270 | this.erpnextUrl = this.erpnextUrl.replace(/\/$/, ''); 271 | 272 | this.apiKey = process.env.ERPNEXT_API_KEY; 273 | this.apiSecret = process.env.ERPNEXT_API_SECRET; 274 | 275 | this.validate(); 276 | } 277 | 278 | private getRequiredEnv(name: string): string { 279 | const value = process.env[name]; 280 | if (!value) { 281 | throw new Error(`${name} environment variable is required`); 282 | } 283 | return value; 284 | } 285 | 286 | private validate() { 287 | if (!this.erpnextUrl.startsWith("http")) { 288 | throw new Error("ERPNEXT_URL must include protocol (http:// or https://)"); 289 | } 290 | 291 | // If one of API key/secret is provided, both must be provided 292 | if ((this.apiKey && !this.apiSecret) || (!this.apiKey && this.apiSecret)) { 293 | throw new Error("Both ERPNEXT_API_KEY and ERPNEXT_API_SECRET must be provided if using API key authentication"); 294 | } 295 | } 296 | 297 | getERPNextUrl(): string { 298 | return this.erpnextUrl; 299 | } 300 | 301 | getERPNextApiKey(): string | undefined { 302 | return this.apiKey; 303 | } 304 | 305 | getERPNextApiSecret(): string | undefined { 306 | return this.apiSecret; 307 | } 308 | } 309 | ``` 310 | 311 | #### 6. Logger Module (`src/utils/logger.ts`) 312 | 313 | Implement a proper logging utility: 314 | 315 | ```typescript 316 | // src/utils/logger.ts 317 | export enum LogLevel { 318 | ERROR = 0, 319 | WARN = 1, 320 | INFO = 2, 321 | DEBUG = 3, 322 | } 323 | 324 | export class Logger { 325 | private level: LogLevel; 326 | 327 | constructor(level: LogLevel = LogLevel.INFO) { 328 | this.level = level; 329 | } 330 | 331 | setLevel(level: LogLevel) { 332 | this.level = level; 333 | } 334 | 335 | error(message: string, ...meta: any[]) { 336 | if (this.level >= LogLevel.ERROR) { 337 | console.error(`[ERROR] ${message}`, ...meta); 338 | } 339 | } 340 | 341 | warn(message: string, ...meta: any[]) { 342 | if (this.level >= LogLevel.WARN) { 343 | console.warn(`[WARN] ${message}`, ...meta); 344 | } 345 | } 346 | 347 | info(message: string, ...meta: any[]) { 348 | if (this.level >= LogLevel.INFO) { 349 | console.info(`[INFO] ${message}`, ...meta); 350 | } 351 | } 352 | 353 | debug(message: string, ...meta: any[]) { 354 | if (this.level >= LogLevel.DEBUG) { 355 | console.debug(`[DEBUG] ${message}`, ...meta); 356 | } 357 | } 358 | } 359 | ``` 360 | 361 | #### 7. Main File (`src/index.ts`) 362 | 363 | The main file becomes much simpler: 364 | 365 | ```typescript 366 | #!/usr/bin/env node 367 | 368 | import { Server } from "@modelcontextprotocol/sdk/server/index.js"; 369 | import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"; 370 | import { Config } from "./utils/config"; 371 | import { Logger, LogLevel } from "./utils/logger"; 372 | import { Cache } from "./utils/cache"; 373 | import { ERPNextClient } from "./client/erpnext-client"; 374 | import { registerResourceHandlers } from "./handlers/resource-handlers"; 375 | import { registerToolHandlers } from "./handlers/tool-handlers"; 376 | 377 | async function main() { 378 | // Initialize components 379 | const logger = new Logger( 380 | process.env.DEBUG ? LogLevel.DEBUG : LogLevel.INFO 381 | ); 382 | 383 | try { 384 | logger.info("Initializing ERPNext MCP server"); 385 | 386 | const config = new Config(); 387 | const cache = new Cache(); 388 | const erpnext = new ERPNextClient(config, logger); 389 | 390 | // Create server 391 | const server = new Server( 392 | { 393 | name: "erpnext-server", 394 | version: "0.1.0" 395 | }, 396 | { 397 | capabilities: { 398 | resources: {}, 399 | tools: {} 400 | } 401 | } 402 | ); 403 | 404 | // Register handlers 405 | registerResourceHandlers(server, erpnext, cache, logger); 406 | registerToolHandlers(server, erpnext, logger); 407 | 408 | // Setup error handling 409 | server.onerror = (error) => { 410 | logger.error("Server error:", error); 411 | }; 412 | 413 | // Start server 414 | const transport = new StdioServerTransport(); 415 | await server.connect(transport); 416 | logger.info('ERPNext MCP server running on stdio'); 417 | 418 | // Handle graceful shutdown 419 | process.on('SIGINT', async () => { 420 | logger.info("Shutting down..."); 421 | await server.close(); 422 | process.exit(0); 423 | }); 424 | } catch (error) { 425 | logger.error("Failed to start server:", error); 426 | process.exit(1); 427 | } 428 | } 429 | 430 | main(); 431 | ``` 432 | 433 | ## Implementation Plan 434 | 435 | 1. Create the directory structure 436 | 2. Move the ERPNext client to its own module 437 | 3. Create utility modules (config, logger, cache) 438 | 4. Split out the resource and tool handlers 439 | 5. Update the main file to use the new modules 440 | 6. Add tests for each module 441 | 7. Update documentation 442 | 443 | This restructuring will make the code more maintainable, easier to test, and facilitate future enhancements. 444 | ``` -------------------------------------------------------------------------------- /docs/feature-improvements.md: -------------------------------------------------------------------------------- ```markdown 1 | # Feature Improvements 2 | 3 | This document outlines potential feature improvements for the ERPNext MCP server to enhance its functionality, usability, and integration capabilities with ERPNext. 4 | 5 | ## Current Feature Set 6 | 7 | Currently, the ERPNext MCP server provides these core features: 8 | 9 | 1. **Authentication** with ERPNext using username/password or API keys 10 | 2. **Document Operations** (get, list, create, update) 11 | 3. **Report Running** 12 | 4. **DocType Information** (listing DocTypes and fields) 13 | 5. **Basic Resource Access** via URIs 14 | 15 | ## Proposed Feature Improvements 16 | 17 | ### 1. Enhanced DocType Discovery and Metadata 18 | 19 | **Current Limitation**: Basic DocType listing without metadata or relationships. 20 | 21 | **Proposed Improvements**: 22 | - Add metadata about each DocType (description, icon, module) 23 | - Include field type information and validations 24 | - Show relationships between DocTypes (links, child tables) 25 | - Provide DocType-specific operations and permissions 26 | 27 | **Implementation**: 28 | ```typescript 29 | // src/handlers/tool-handlers.ts 30 | // New tool: get_doctype_metadata 31 | 32 | export async function handleGetDoctypeMetadata(request, erpnext, logger) { 33 | const doctype = request.params.arguments?.doctype; 34 | 35 | if (!doctype) { 36 | throw new McpError(ErrorCode.InvalidParams, "DocType is required"); 37 | } 38 | 39 | try { 40 | // Get DocType metadata including fields with types 41 | const metadata = await erpnext.getDocTypeMetadata(doctype); 42 | 43 | // Get relationship information 44 | const relationships = await erpnext.getDocTypeRelationships(doctype); 45 | 46 | return { 47 | content: [{ 48 | type: "text", 49 | text: JSON.stringify({ 50 | doctype, 51 | metadata, 52 | relationships, 53 | operations: getAvailableOperations(doctype) 54 | }, null, 2) 55 | }] 56 | }; 57 | } catch (error) { 58 | return handleToolError(error, logger); 59 | } 60 | } 61 | ``` 62 | 63 | ### 2. Bulk Operations 64 | 65 | **Current Limitation**: Operations are limited to single documents. 66 | 67 | **Proposed Improvements**: 68 | - Add bulk document creation 69 | - Implement batch updates 70 | - Support bulk data import/export 71 | - Add atomic transaction support 72 | 73 | **Implementation**: 74 | ```typescript 75 | // New tool: bulk_create_documents 76 | 77 | export async function handleBulkCreateDocuments(request, erpnext, logger) { 78 | const doctype = request.params.arguments?.doctype; 79 | const documents = request.params.arguments?.documents; 80 | 81 | if (!doctype || !Array.isArray(documents)) { 82 | throw new McpError(ErrorCode.InvalidParams, "DocType and array of documents are required"); 83 | } 84 | 85 | try { 86 | // Create documents in a transaction if possible 87 | const results = await erpnext.bulkCreateDocuments(doctype, documents); 88 | 89 | return { 90 | content: [{ 91 | type: "text", 92 | text: JSON.stringify({ 93 | message: `Created ${results.length} ${doctype} documents`, 94 | results 95 | }, null, 2) 96 | }] 97 | }; 98 | } catch (error) { 99 | return handleToolError(error, logger); 100 | } 101 | } 102 | ``` 103 | 104 | ### 3. Advanced Querying 105 | 106 | **Current Limitation**: Basic filtering without complex queries. 107 | 108 | **Proposed Improvements**: 109 | - Support complex query conditions 110 | - Add sorting and grouping options 111 | - Implement flexible field selection 112 | - Add query templates for common operations 113 | 114 | **Implementation**: 115 | ```typescript 116 | // New tool: advanced_query 117 | 118 | export async function handleAdvancedQuery(request, erpnext, logger) { 119 | const doctype = request.params.arguments?.doctype; 120 | const query = request.params.arguments?.query || {}; 121 | 122 | if (!doctype) { 123 | throw new McpError(ErrorCode.InvalidParams, "DocType is required"); 124 | } 125 | 126 | try { 127 | // Transform query to ERPNext format 128 | const erpnextQuery = transformQuery(query); 129 | 130 | // Execute query 131 | const results = await erpnext.advancedQuery(doctype, erpnextQuery); 132 | 133 | return { 134 | content: [{ 135 | type: "text", 136 | text: JSON.stringify(results, null, 2) 137 | }] 138 | }; 139 | } catch (error) { 140 | return handleToolError(error, logger); 141 | } 142 | } 143 | 144 | function transformQuery(query) { 145 | // Transform from MCP query format to ERPNext query format 146 | const result = { 147 | filters: query.filters || {}, 148 | fields: query.fields || ["*"], 149 | limit: query.limit, 150 | offset: query.offset, 151 | order_by: query.orderBy, 152 | group_by: query.groupBy 153 | }; 154 | 155 | // Handle complex conditions 156 | if (query.conditions) { 157 | result.filters = buildComplexFilters(query.conditions); 158 | } 159 | 160 | return result; 161 | } 162 | ``` 163 | 164 | ### 4. Webhooks and Events 165 | 166 | **Current Limitation**: No support for event-driven operations. 167 | 168 | **Proposed Improvements**: 169 | - Add webhook registration for ERPNext events 170 | - Implement event listeners 171 | - Support callback URLs for async operations 172 | - Create notification tools 173 | 174 | **Implementation**: 175 | ```typescript 176 | // New tool: register_webhook 177 | 178 | export async function handleRegisterWebhook(request, erpnext, logger) { 179 | const doctype = request.params.arguments?.doctype; 180 | const event = request.params.arguments?.event; 181 | const callbackUrl = request.params.arguments?.callbackUrl; 182 | 183 | if (!doctype || !event || !callbackUrl) { 184 | throw new McpError(ErrorCode.InvalidParams, "DocType, event, and callbackUrl are required"); 185 | } 186 | 187 | try { 188 | // Register webhook with ERPNext 189 | const webhook = await erpnext.registerWebhook(doctype, event, callbackUrl); 190 | 191 | return { 192 | content: [{ 193 | type: "text", 194 | text: JSON.stringify({ 195 | message: `Webhook registered for ${doctype} ${event} events`, 196 | webhook 197 | }, null, 2) 198 | }] 199 | }; 200 | } catch (error) { 201 | return handleToolError(error, logger); 202 | } 203 | } 204 | ``` 205 | 206 | ### 5. Document Workflows 207 | 208 | **Current Limitation**: No workflow or process automation. 209 | 210 | **Proposed Improvements**: 211 | - Add workflow status tracking 212 | - Implement state transitions 213 | - Support approval processes 214 | - Create multi-step operations 215 | 216 | **Implementation**: 217 | ```typescript 218 | // New tool: execute_workflow_action 219 | 220 | export async function handleExecuteWorkflowAction(request, erpnext, logger) { 221 | const doctype = request.params.arguments?.doctype; 222 | const name = request.params.arguments?.name; 223 | const action = request.params.arguments?.action; 224 | const comments = request.params.arguments?.comments; 225 | 226 | if (!doctype || !name || !action) { 227 | throw new McpError(ErrorCode.InvalidParams, "DocType, document name, and action are required"); 228 | } 229 | 230 | try { 231 | // Execute workflow action 232 | const result = await erpnext.executeWorkflowAction(doctype, name, action, comments); 233 | 234 | return { 235 | content: [{ 236 | type: "text", 237 | text: JSON.stringify({ 238 | message: `Executed ${action} on ${doctype} ${name}`, 239 | result 240 | }, null, 2) 241 | }] 242 | }; 243 | } catch (error) { 244 | return handleToolError(error, logger); 245 | } 246 | } 247 | ``` 248 | 249 | ### 6. File Operations 250 | 251 | **Current Limitation**: No file handling capabilities. 252 | 253 | **Proposed Improvements**: 254 | - Add file upload/download 255 | - Support attachments to documents 256 | - Implement file metadata management 257 | - Add image processing utilities 258 | 259 | **Implementation**: 260 | ```typescript 261 | // New tool: upload_attachment 262 | 263 | export async function handleUploadAttachment(request, erpnext, logger) { 264 | const doctype = request.params.arguments?.doctype; 265 | const name = request.params.arguments?.name; 266 | const fileName = request.params.arguments?.fileName; 267 | const fileContent = request.params.arguments?.fileContent; // Base64 encoded 268 | const fileType = request.params.arguments?.fileType; 269 | 270 | if (!doctype || !name || !fileName || !fileContent) { 271 | throw new McpError(ErrorCode.InvalidParams, "DocType, document name, fileName, and fileContent are required"); 272 | } 273 | 274 | try { 275 | // Upload attachment 276 | const attachment = await erpnext.uploadAttachment( 277 | doctype, 278 | name, 279 | fileName, 280 | Buffer.from(fileContent, 'base64'), 281 | fileType 282 | ); 283 | 284 | return { 285 | content: [{ 286 | type: "text", 287 | text: JSON.stringify({ 288 | message: `Attached ${fileName} to ${doctype} ${name}`, 289 | attachment 290 | }, null, 2) 291 | }] 292 | }; 293 | } catch (error) { 294 | return handleToolError(error, logger); 295 | } 296 | } 297 | ``` 298 | 299 | ### 7. Data Synchronization 300 | 301 | **Current Limitation**: No synchronization utilities. 302 | 303 | **Proposed Improvements**: 304 | - Add data synchronization capabilities 305 | - Implement change tracking 306 | - Support incremental updates 307 | - Create data migration tools 308 | 309 | **Implementation**: 310 | ```typescript 311 | // New tool: sync_data 312 | 313 | export async function handleSyncData(request, erpnext, logger) { 314 | const doctype = request.params.arguments?.doctype; 315 | const lastSyncTime = request.params.arguments?.lastSyncTime; 316 | 317 | if (!doctype) { 318 | throw new McpError(ErrorCode.InvalidParams, "DocType is required"); 319 | } 320 | 321 | try { 322 | // Get changes since last sync 323 | const changes = await erpnext.getChangesSince(doctype, lastSyncTime); 324 | 325 | return { 326 | content: [{ 327 | type: "text", 328 | text: JSON.stringify({ 329 | message: `Retrieved ${changes.length} changes for ${doctype} since ${lastSyncTime || 'beginning'}`, 330 | currentTime: new Date().toISOString(), 331 | changes 332 | }, null, 2) 333 | }] 334 | }; 335 | } catch (error) { 336 | return handleToolError(error, logger); 337 | } 338 | } 339 | ``` 340 | 341 | ### 8. Custom Scripts and Server Actions 342 | 343 | **Current Limitation**: No support for custom scripts or actions. 344 | 345 | **Proposed Improvements**: 346 | - Add support for executing custom server scripts 347 | - Implement custom actions 348 | - Support script parameters 349 | - Create script management tools 350 | 351 | **Implementation**: 352 | ```typescript 353 | // New tool: execute_server_script 354 | 355 | export async function handleExecuteServerScript(request, erpnext, logger) { 356 | const scriptName = request.params.arguments?.scriptName; 357 | const parameters = request.params.arguments?.parameters || {}; 358 | 359 | if (!scriptName) { 360 | throw new McpError(ErrorCode.InvalidParams, "Script name is required"); 361 | } 362 | 363 | try { 364 | // Execute server script 365 | const result = await erpnext.executeServerScript(scriptName, parameters); 366 | 367 | return { 368 | content: [{ 369 | type: "text", 370 | text: JSON.stringify({ 371 | message: `Executed server script ${scriptName}`, 372 | result 373 | }, null, 2) 374 | }] 375 | }; 376 | } catch (error) { 377 | return handleToolError(error, logger); 378 | } 379 | } 380 | ``` 381 | 382 | ### 9. ERPNext Printing and PDF Generation 383 | 384 | **Current Limitation**: No document formatting or printing. 385 | 386 | **Proposed Improvements**: 387 | - Add print format rendering 388 | - Implement PDF generation 389 | - Support custom print templates 390 | - Create document export tools 391 | 392 | **Implementation**: 393 | ```typescript 394 | // New tool: generate_pdf 395 | 396 | export async function handleGeneratePdf(request, erpnext, logger) { 397 | const doctype = request.params.arguments?.doctype; 398 | const name = request.params.arguments?.name; 399 | const printFormat = request.params.arguments?.printFormat; 400 | const letterhead = request.params.arguments?.letterhead; 401 | 402 | if (!doctype || !name) { 403 | throw new McpError(ErrorCode.InvalidParams, "DocType and document name are required"); 404 | } 405 | 406 | try { 407 | // Generate PDF 408 | const pdfData = await erpnext.generatePdf(doctype, name, printFormat, letterhead); 409 | 410 | return { 411 | content: [{ 412 | type: "text", 413 | text: JSON.stringify({ 414 | message: `Generated PDF for ${doctype} ${name}`, 415 | pdf: pdfData // Base64 encoded 416 | }, null, 2) 417 | }] 418 | }; 419 | } catch (error) { 420 | return handleToolError(error, logger); 421 | } 422 | } 423 | ``` 424 | 425 | ### 10. Interactive Tools 426 | 427 | **Current Limitation**: All operations are direct API calls without user interaction. 428 | 429 | **Proposed Improvements**: 430 | - Add support for multi-step interactive operations 431 | - Implement wizard-like flows 432 | - Support form generation 433 | - Create contextual suggestions 434 | 435 | **Implementation**: 436 | ```typescript 437 | // New tool: start_interactive_flow 438 | 439 | export async function handleStartInteractiveFlow(request, erpnext, logger) { 440 | const flowType = request.params.arguments?.flowType; 441 | const initialData = request.params.arguments?.initialData || {}; 442 | 443 | if (!flowType) { 444 | throw new McpError(ErrorCode.InvalidParams, "Flow type is required"); 445 | } 446 | 447 | try { 448 | // Get flow definition 449 | const flow = getFlowDefinition(flowType); 450 | 451 | // Initialize flow state 452 | const flowState = initializeFlowState(flow, initialData); 453 | 454 | return { 455 | content: [{ 456 | type: "text", 457 | text: JSON.stringify({ 458 | message: `Started interactive flow: ${flowType}`, 459 | currentStep: flowState.currentStep, 460 | steps: flowState.steps, 461 | totalSteps: flowState.totalSteps, 462 | data: flowState.data, 463 | form: generateFormForStep(flowState.currentStep, flow, flowState.data) 464 | }, null, 2) 465 | }] 466 | }; 467 | } catch (error) { 468 | return handleToolError(error, logger); 469 | } 470 | } 471 | ``` 472 | 473 | ## Implementation Strategy 474 | 475 | To implement these feature improvements, we recommend: 476 | 477 | 1. **Prioritization**: Focus on features that provide the most value with the least implementation complexity first: 478 | - Enhanced DocType discovery and metadata 479 | - Advanced querying 480 | - File operations 481 | 482 | 2. **Phased Implementation**: 483 | - Phase 1: Metadata and querying enhancements 484 | - Phase 2: Bulk operations and file handling 485 | - Phase 3: Workflows and synchronization 486 | - Phase 4: Interactive tools and custom scripts 487 | 488 | 3. **Dependency Handling**: 489 | - Identify ERPNext API dependencies for each feature 490 | - Document required permissions and configuration 491 | - Handle version compatibility 492 | 493 | 4. **Documentation and Examples**: 494 | - Provide detailed documentation for each new feature 495 | - Include usage examples 496 | - Create tutorials for complex features 497 | 498 | ## Conclusion 499 | 500 | These feature improvements will significantly enhance the ERPNext MCP server's capabilities, making it a more powerful and flexible integration point for ERPNext. By implementing these features in a phased approach, we can incrementally add functionality while maintaining stability and backward compatibility. 501 | ``` -------------------------------------------------------------------------------- /docs/error-handling-and-logging.md: -------------------------------------------------------------------------------- ```markdown 1 | # Error Handling and Logging Improvements 2 | 3 | This document outlines recommendations for improving error handling and logging in the ERPNext MCP server. 4 | 5 | ## Current Status 6 | 7 | The current implementation has several limitations in its error handling and logging approach: 8 | 9 | 1. Basic error handling with inconsistent patterns 10 | 2. Console logging without structured format 11 | 3. No distinct log levels for different environments 12 | 4. No dedicated error mapping between ERPNext and MCP error codes 13 | 5. Limited contextual information in error messages 14 | 15 | ## Proposed Improvements 16 | 17 | ### 1. Structured Error Handling 18 | 19 | #### Create a dedicated Error Handling Module 20 | 21 | ```typescript 22 | // src/utils/error-handler.ts 23 | import { McpError, ErrorCode } from "@modelcontextprotocol/sdk/types.js"; 24 | import { AxiosError } from "axios"; 25 | import { Logger } from "./logger"; 26 | 27 | export enum ERPNextErrorType { 28 | Authentication = "authentication", 29 | Permission = "permission", 30 | NotFound = "not_found", 31 | Validation = "validation", 32 | Server = "server", 33 | Network = "network", 34 | Unknown = "unknown" 35 | } 36 | 37 | export class ERPNextError extends Error { 38 | constructor( 39 | public readonly type: ERPNextErrorType, 40 | message: string, 41 | public readonly originalError?: Error 42 | ) { 43 | super(message); 44 | this.name = "ERPNextError"; 45 | } 46 | } 47 | 48 | /** 49 | * Maps ERPNext errors to MCP error codes 50 | */ 51 | export function mapToMcpError(error: ERPNextError): McpError { 52 | switch (error.type) { 53 | case ERPNextErrorType.Authentication: 54 | return new McpError(ErrorCode.Unauthorized, error.message); 55 | case ERPNextErrorType.Permission: 56 | return new McpError(ErrorCode.Forbidden, error.message); 57 | case ERPNextErrorType.NotFound: 58 | return new McpError(ErrorCode.NotFound, error.message); 59 | case ERPNextErrorType.Validation: 60 | return new McpError(ErrorCode.InvalidParams, error.message); 61 | case ERPNextErrorType.Server: 62 | return new McpError(ErrorCode.InternalError, error.message); 63 | case ERPNextErrorType.Network: 64 | return new McpError(ErrorCode.InternalError, `Network error: ${error.message}`); 65 | default: 66 | return new McpError(ErrorCode.InternalError, `Unknown error: ${error.message}`); 67 | } 68 | } 69 | 70 | /** 71 | * Categorizes an error based on its properties and returns an ERPNextError 72 | */ 73 | export function categorizeError(error: any): ERPNextError { 74 | if (error instanceof ERPNextError) { 75 | return error; 76 | } 77 | 78 | // Handle Axios errors 79 | if (error?.isAxiosError) { 80 | const axiosError = error as AxiosError; 81 | const statusCode = axiosError.response?.status; 82 | const responseData = axiosError.response?.data as any; 83 | const message = responseData?.message || responseData?.error || axiosError.message; 84 | 85 | if (!statusCode) { 86 | return new ERPNextError(ERPNextErrorType.Network, 87 | `Network error connecting to ERPNext: ${message}`, 88 | error); 89 | } 90 | 91 | switch (statusCode) { 92 | case 401: 93 | return new ERPNextError(ERPNextErrorType.Authentication, 94 | `Authentication failed: ${message}`, 95 | error); 96 | case 403: 97 | return new ERPNextError(ERPNextErrorType.Permission, 98 | `Permission denied: ${message}`, 99 | error); 100 | case 404: 101 | return new ERPNextError(ERPNextErrorType.NotFound, 102 | `Resource not found: ${message}`, 103 | error); 104 | case 400: 105 | return new ERPNextError(ERPNextErrorType.Validation, 106 | `Validation error: ${message}`, 107 | error); 108 | case 500: 109 | case 502: 110 | case 503: 111 | case 504: 112 | return new ERPNextError(ERPNextErrorType.Server, 113 | `ERPNext server error: ${message}`, 114 | error); 115 | default: 116 | return new ERPNextError(ERPNextErrorType.Unknown, 117 | `Unknown error: ${message}`, 118 | error); 119 | } 120 | } 121 | 122 | // Handle generic errors 123 | return new ERPNextError( 124 | ERPNextErrorType.Unknown, 125 | error?.message || 'An unknown error occurred', 126 | error instanceof Error ? error : undefined 127 | ); 128 | } 129 | 130 | /** 131 | * Handles errors in tool handlers, returning appropriate response format 132 | */ 133 | export function handleToolError(error: any, logger: Logger) { 134 | const erpError = categorizeError(error); 135 | 136 | // Log the error with appropriate context 137 | if (erpError.originalError) { 138 | logger.error(`${erpError.message}`, { 139 | errorType: erpError.type, 140 | originalError: erpError.originalError.message, 141 | stack: erpError.originalError.stack 142 | }); 143 | } else { 144 | logger.error(`${erpError.message}`, { 145 | errorType: erpError.type, 146 | stack: erpError.stack 147 | }); 148 | } 149 | 150 | // Return a formatted error response 151 | return { 152 | content: [{ 153 | type: "text", 154 | text: erpError.message 155 | }], 156 | isError: true 157 | }; 158 | } 159 | 160 | /** 161 | * Creates a correlation ID for tracking requests through the system 162 | */ 163 | export function createCorrelationId(): string { 164 | return `mcp-${Date.now()}-${Math.random().toString(36).substring(2, 7)}`; 165 | } 166 | ``` 167 | 168 | ### 2. Enhanced Logging System 169 | 170 | #### Structured Logger Implementation 171 | 172 | ```typescript 173 | // src/utils/logger.ts 174 | export enum LogLevel { 175 | ERROR = 0, 176 | WARN = 1, 177 | INFO = 2, 178 | DEBUG = 3 179 | } 180 | 181 | export interface LogMetadata { 182 | [key: string]: any; 183 | } 184 | 185 | export interface LogEntry { 186 | timestamp: string; 187 | level: string; 188 | message: string; 189 | correlationId?: string; 190 | metadata?: LogMetadata; 191 | } 192 | 193 | export class Logger { 194 | private level: LogLevel; 195 | private serviceName: string; 196 | 197 | constructor(level: LogLevel = LogLevel.INFO, serviceName: string = "erpnext-mcp") { 198 | this.level = level; 199 | this.serviceName = serviceName; 200 | } 201 | 202 | setLevel(level: LogLevel) { 203 | this.level = level; 204 | } 205 | 206 | private formatLog(level: string, message: string, metadata?: LogMetadata): LogEntry { 207 | return { 208 | timestamp: new Date().toISOString(), 209 | level, 210 | message, 211 | correlationId: metadata?.correlationId, 212 | metadata: metadata ? { ...metadata, service: this.serviceName } : { service: this.serviceName } 213 | }; 214 | } 215 | 216 | private outputLog(logEntry: LogEntry) { 217 | // In production, you might want to use a more sophisticated logging system 218 | // For now, we'll use console with JSON formatting 219 | const logJson = JSON.stringify(logEntry); 220 | 221 | switch (logEntry.level) { 222 | case 'ERROR': 223 | console.error(logJson); 224 | break; 225 | case 'WARN': 226 | console.warn(logJson); 227 | break; 228 | case 'INFO': 229 | console.info(logJson); 230 | break; 231 | case 'DEBUG': 232 | console.debug(logJson); 233 | break; 234 | default: 235 | console.log(logJson); 236 | } 237 | } 238 | 239 | error(message: string, metadata?: LogMetadata) { 240 | if (this.level >= LogLevel.ERROR) { 241 | this.outputLog(this.formatLog('ERROR', message, metadata)); 242 | } 243 | } 244 | 245 | warn(message: string, metadata?: LogMetadata) { 246 | if (this.level >= LogLevel.WARN) { 247 | this.outputLog(this.formatLog('WARN', message, metadata)); 248 | } 249 | } 250 | 251 | info(message: string, metadata?: LogMetadata) { 252 | if (this.level >= LogLevel.INFO) { 253 | this.outputLog(this.formatLog('INFO', message, metadata)); 254 | } 255 | } 256 | 257 | debug(message: string, metadata?: LogMetadata) { 258 | if (this.level >= LogLevel.DEBUG) { 259 | this.outputLog(this.formatLog('DEBUG', message, metadata)); 260 | } 261 | } 262 | } 263 | ``` 264 | 265 | ### 3. Request Context and Correlation 266 | 267 | To track requests through the system: 268 | 269 | ```typescript 270 | // src/middleware/context.ts 271 | import { createCorrelationId } from "../utils/error-handler"; 272 | 273 | export interface RequestContext { 274 | correlationId: string; 275 | startTime: number; 276 | [key: string]: any; 277 | } 278 | 279 | export class RequestContextManager { 280 | private static contextMap = new Map<string, RequestContext>(); 281 | 282 | static createContext(requestId: string): RequestContext { 283 | const context: RequestContext = { 284 | correlationId: createCorrelationId(), 285 | startTime: Date.now() 286 | }; 287 | 288 | this.contextMap.set(requestId, context); 289 | return context; 290 | } 291 | 292 | static getContext(requestId: string): RequestContext | undefined { 293 | return this.contextMap.get(requestId); 294 | } 295 | 296 | static removeContext(requestId: string): void { 297 | this.contextMap.delete(requestId); 298 | } 299 | } 300 | ``` 301 | 302 | ### 4. Integration with MCP Server 303 | 304 | Use the error handling and logging systems with the MCP server: 305 | 306 | ```typescript 307 | // src/index.ts 308 | import { Server } from "@modelcontextprotocol/sdk/server/index.js"; 309 | import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"; 310 | import { Config } from "./utils/config"; 311 | import { Logger, LogLevel } from "./utils/logger"; 312 | import { RequestContextManager } from "./middleware/context"; 313 | 314 | async function main() { 315 | // Initialize logger 316 | const logger = new Logger( 317 | process.env.DEBUG ? LogLevel.DEBUG : LogLevel.INFO 318 | ); 319 | 320 | try { 321 | logger.info("Initializing ERPNext MCP server"); 322 | 323 | // Create server 324 | const server = new Server( 325 | { 326 | name: "erpnext-server", 327 | version: "0.1.0" 328 | }, 329 | { 330 | capabilities: { 331 | resources: {}, 332 | tools: {} 333 | } 334 | } 335 | ); 336 | 337 | // Set up request interceptors for correlation and context 338 | server.onRequest = (request) => { 339 | const context = RequestContextManager.createContext(request.id); 340 | logger.debug(`Received request: ${request.method}`, { 341 | correlationId: context.correlationId, 342 | request: { 343 | id: request.id, 344 | method: request.method, 345 | params: JSON.stringify(request.params) 346 | } 347 | }); 348 | }; 349 | 350 | // Set up response interceptors for timing and cleanup 351 | server.onResponse = (response) => { 352 | const context = RequestContextManager.getContext(response.id); 353 | if (context) { 354 | const duration = Date.now() - context.startTime; 355 | logger.debug(`Sending response`, { 356 | correlationId: context.correlationId, 357 | response: { 358 | id: response.id, 359 | duration: `${duration}ms`, 360 | hasError: !!response.error 361 | } 362 | }); 363 | 364 | // Clean up context 365 | RequestContextManager.removeContext(response.id); 366 | } 367 | }; 368 | 369 | // Set up error handler for server errors 370 | server.onerror = (error) => { 371 | logger.error("Server error", { error: error?.message, stack: error?.stack }); 372 | }; 373 | 374 | // Connect server with stdio transport 375 | const transport = new StdioServerTransport(); 376 | await server.connect(transport); 377 | logger.info('ERPNext MCP server running on stdio'); 378 | 379 | } catch (error) { 380 | logger.error("Failed to start server", { error: error?.message, stack: error?.stack }); 381 | process.exit(1); 382 | } 383 | } 384 | 385 | main(); 386 | ``` 387 | 388 | ### 5. Integrating with Client 389 | 390 | Update the ERPNext client to use the enhanced error handling: 391 | 392 | ```typescript 393 | // src/client/erpnext-client.ts 394 | import axios, { AxiosInstance } from "axios"; 395 | import { Logger } from "../utils/logger"; 396 | import { Config } from "../utils/config"; 397 | import { categorizeError, ERPNextError, ERPNextErrorType } from "../utils/error-handler"; 398 | 399 | export class ERPNextClient { 400 | private baseUrl: string; 401 | private axiosInstance: AxiosInstance; 402 | private authenticated: boolean = false; 403 | private logger: Logger; 404 | 405 | constructor(config: Config, logger: Logger) { 406 | this.logger = logger; 407 | this.baseUrl = config.getERPNextUrl(); 408 | 409 | // Initialize axios instance 410 | this.axiosInstance = axios.create({ 411 | baseURL: this.baseUrl, 412 | withCredentials: true, 413 | headers: { 414 | 'Content-Type': 'application/json', 415 | 'Accept': 'application/json' 416 | } 417 | }); 418 | 419 | // Add request interceptor for logging 420 | this.axiosInstance.interceptors.request.use( 421 | (config) => { 422 | this.logger.debug(`Sending ${config.method?.toUpperCase()} request to ${config.url}`, { 423 | api: { 424 | method: config.method, 425 | url: config.url, 426 | hasData: !!config.data, 427 | hasParams: !!config.params 428 | } 429 | }); 430 | return config; 431 | }, 432 | (error) => { 433 | this.logger.error(`Request error: ${error.message}`); 434 | return Promise.reject(error); 435 | } 436 | ); 437 | 438 | // Add response interceptor for error handling 439 | this.axiosInstance.interceptors.response.use( 440 | (response) => { 441 | return response; 442 | }, 443 | (error) => { 444 | const erpError = categorizeError(error); 445 | this.logger.error(`API error: ${erpError.message}`, { 446 | errorType: erpError.type, 447 | statusCode: error.response?.status, 448 | data: error.response?.data 449 | }); 450 | return Promise.reject(erpError); 451 | } 452 | ); 453 | 454 | // Configure authentication if credentials provided 455 | const apiKey = config.getERPNextApiKey(); 456 | const apiSecret = config.getERPNextApiSecret(); 457 | 458 | if (apiKey && apiSecret) { 459 | this.axiosInstance.defaults.headers.common['Authorization'] = 460 | `token ${apiKey}:${apiSecret}`; 461 | this.authenticated = true; 462 | this.logger.info("Initialized with API key authentication"); 463 | } 464 | } 465 | 466 | // Client methods would use the same error handling pattern... 467 | async login(username: string, password: string): Promise<void> { 468 | try { 469 | this.logger.info(`Attempting login for user ${username}`); 470 | const response = await this.axiosInstance.post('/api/method/login', { 471 | usr: username, 472 | pwd: password 473 | }); 474 | 475 | if (response.data.message === 'Logged In') { 476 | this.authenticated = true; 477 | this.logger.info(`Successfully authenticated user ${username}`); 478 | } else { 479 | throw new ERPNextError( 480 | ERPNextErrorType.Authentication, 481 | "Login response did not confirm successful authentication" 482 | ); 483 | } 484 | } catch (error) { 485 | // Let the interceptor handle the error categorization 486 | this.authenticated = false; 487 | throw error; 488 | } 489 | } 490 | 491 | // Other methods would follow the same pattern... 492 | } 493 | ``` 494 | 495 | ## Benefits of Improved Error Handling and Logging 496 | 497 | 1. **Better Diagnostics**: Structured logging with correlation IDs makes it easier to track requests through the system and diagnose issues. 498 | 499 | 2. **Consistent Error Responses**: Standardized error handling ensures that clients receive consistent and informative error messages. 500 | 501 | 3. **Categorized Errors**: Errors are properly categorized, making it easier to handle different types of failures appropriately. 502 | 503 | 4. **Contextual Information**: Additional metadata is included with logs, providing more context for troubleshooting. 504 | 505 | 5. **Performance Tracking**: Request timing information helps identify performance bottlenecks. 506 | 507 | 6. **Environment-specific Logging**: Different log levels can be used in development vs. production environments. 508 | 509 | ## Implementation Plan 510 | 511 | 1. Create the error handling utility module 512 | 2. Implement the structured logger 513 | 3. Update the client to use the enhanced error handling 514 | 4. Update resource and tool handlers to leverage the new error system 515 | 5. Add request context management 516 | 6. Update the main server to integrate all components 517 | 518 | These changes will significantly improve the reliability and maintainability of the ERPNext MCP server by making errors more traceable and logs more informative. 519 | ``` -------------------------------------------------------------------------------- /docs/testing-strategy.md: -------------------------------------------------------------------------------- ```markdown 1 | # Testing Strategy 2 | 3 | This document outlines a comprehensive testing strategy for the ERPNext MCP server to ensure reliability, correctness, and maintainability. 4 | 5 | ## Current Status 6 | 7 | The current implementation lacks any automated testing, which poses several risks: 8 | 9 | - No way to verify that changes don't break existing functionality 10 | - Difficulty in detecting regressions 11 | - Challenges in maintaining code quality as the codebase grows 12 | - Decreased confidence when making changes or adding features 13 | 14 | ## Proposed Testing Structure 15 | 16 | ### Directory Structure 17 | 18 | ``` 19 | tests/ 20 | ├── unit/ 21 | │ ├── client/ 22 | │ │ └── erpnext-client.test.ts 23 | │ ├── handlers/ 24 | │ │ ├── resource-handlers.test.ts 25 | │ │ └── tool-handlers.test.ts 26 | │ └── utils/ 27 | │ ├── cache.test.ts 28 | │ ├── config.test.ts 29 | │ └── logger.test.ts 30 | ├── integration/ 31 | │ └── api-integration.test.ts 32 | ├── e2e/ 33 | │ └── full-workflow.test.ts 34 | └── mocks/ 35 | ├── erpnext-api.ts 36 | └── test-data.ts 37 | ``` 38 | 39 | ## Testing Levels 40 | 41 | ### 1. Unit Tests 42 | 43 | Unit tests should focus on testing individual components in isolation, mocking external dependencies. These tests should be fast and focused. 44 | 45 | #### Example: Testing the Cache Utility 46 | 47 | ```typescript 48 | // tests/unit/utils/cache.test.ts 49 | import { Cache } from '../../../src/utils/cache'; 50 | import { jest } from '@jest/globals'; 51 | 52 | describe('Cache', () => { 53 | let cache: Cache; 54 | 55 | beforeEach(() => { 56 | cache = new Cache(); 57 | jest.useFakeTimers(); 58 | }); 59 | 60 | afterEach(() => { 61 | jest.useRealTimers(); 62 | }); 63 | 64 | test('should store and retrieve values', () => { 65 | // Arrange 66 | const key = 'test-key'; 67 | const value = { data: 'test-data' }; 68 | 69 | // Act 70 | cache.set(key, value); 71 | const result = cache.get(key); 72 | 73 | // Assert 74 | expect(result).toEqual(value); 75 | }); 76 | 77 | test('should expire values after TTL', () => { 78 | // Arrange 79 | const key = 'test-key'; 80 | const value = { data: 'test-data' }; 81 | const ttl = 1000; // 1 second 82 | 83 | // Act 84 | cache.set(key, value, ttl); 85 | 86 | // Move time forward 87 | jest.advanceTimersByTime(1001); 88 | 89 | const result = cache.get(key); 90 | 91 | // Assert 92 | expect(result).toBeUndefined(); 93 | }); 94 | 95 | test('should invalidate keys with prefix', () => { 96 | // Arrange 97 | cache.set('prefix:key1', 'value1'); 98 | cache.set('prefix:key2', 'value2'); 99 | cache.set('other:key', 'value3'); 100 | 101 | // Act 102 | cache.invalidate('prefix:'); 103 | 104 | // Assert 105 | expect(cache.get('prefix:key1')).toBeUndefined(); 106 | expect(cache.get('prefix:key2')).toBeUndefined(); 107 | expect(cache.get('other:key')).toBe('value3'); 108 | }); 109 | }); 110 | ``` 111 | 112 | #### Example: Testing the ERPNext Client 113 | 114 | ```typescript 115 | // tests/unit/client/erpnext-client.test.ts 116 | import { ERPNextClient } from '../../../src/client/erpnext-client'; 117 | import { Config } from '../../../src/utils/config'; 118 | import { Logger } from '../../../src/utils/logger'; 119 | import axios from 'axios'; 120 | import { jest } from '@jest/globals'; 121 | 122 | // Mock axios 123 | jest.mock('axios'); 124 | const mockedAxios = axios as jest.Mocked<typeof axios>; 125 | 126 | describe('ERPNextClient', () => { 127 | let client: ERPNextClient; 128 | let mockConfig: jest.Mocked<Config>; 129 | let mockLogger: jest.Mocked<Logger>; 130 | 131 | beforeEach(() => { 132 | // Setup mock config 133 | mockConfig = { 134 | getERPNextUrl: jest.fn().mockReturnValue('https://test-erpnext.com'), 135 | getERPNextApiKey: jest.fn().mockReturnValue('test-key'), 136 | getERPNextApiSecret: jest.fn().mockReturnValue('test-secret') 137 | } as unknown as jest.Mocked<Config>; 138 | 139 | // Setup mock logger 140 | mockLogger = { 141 | info: jest.fn(), 142 | error: jest.fn(), 143 | debug: jest.fn(), 144 | warn: jest.fn() 145 | } as unknown as jest.Mocked<Logger>; 146 | 147 | // Setup axios mock 148 | mockedAxios.create.mockReturnValue({ 149 | defaults: { headers: { common: {} } }, 150 | get: jest.fn(), 151 | post: jest.fn(), 152 | put: jest.fn() 153 | } as any); 154 | 155 | // Create client instance 156 | client = new ERPNextClient(mockConfig, mockLogger); 157 | }); 158 | 159 | test('should initialize with API key authentication', () => { 160 | expect(mockLogger.info).toHaveBeenCalledWith( 161 | expect.stringContaining('API key authentication') 162 | ); 163 | expect(client.isAuthenticated()).toBe(true); 164 | }); 165 | 166 | test('login should authenticate the client', async () => { 167 | // Setup mock response 168 | const axiosInstance = mockedAxios.create.mock.results[0].value; 169 | axiosInstance.post.mockResolvedValue({ 170 | data: { message: 'Logged In' } 171 | }); 172 | 173 | // Act 174 | await client.login('testuser', 'testpass'); 175 | 176 | // Assert 177 | expect(axiosInstance.post).toHaveBeenCalledWith( 178 | '/api/method/login', 179 | { usr: 'testuser', pwd: 'testpass' } 180 | ); 181 | expect(client.isAuthenticated()).toBe(true); 182 | }); 183 | 184 | test('login should handle authentication failure', async () => { 185 | // Setup mock response for failure 186 | const axiosInstance = mockedAxios.create.mock.results[0].value; 187 | axiosInstance.post.mockRejectedValue(new Error('Authentication failed')); 188 | 189 | // Act & Assert 190 | await expect(client.login('testuser', 'wrongpass')).rejects.toThrow('Authentication failed'); 191 | expect(client.isAuthenticated()).toBe(false); 192 | }); 193 | }); 194 | ``` 195 | 196 | ### 2. Integration Tests 197 | 198 | Integration tests should verify that different components work correctly together. For the ERPNext MCP server, integration tests should focus on the interaction between the server, handlers, and the ERPNext client. 199 | 200 | ```typescript 201 | // tests/integration/api-integration.test.ts 202 | import { Server } from "@modelcontextprotocol/sdk/server/index.js"; 203 | import { MemoryTransport } from "@modelcontextprotocol/sdk/server/memory.js"; 204 | import { ERPNextClient } from '../../src/client/erpnext-client'; 205 | import { registerResourceHandlers } from '../../src/handlers/resource-handlers'; 206 | import { registerToolHandlers } from '../../src/handlers/tool-handlers'; 207 | import { Logger } from '../../src/utils/logger'; 208 | import { Cache } from '../../src/utils/cache'; 209 | import { jest } from '@jest/globals'; 210 | 211 | // Use mock ERPNext client 212 | jest.mock('../../src/client/erpnext-client'); 213 | 214 | describe('MCP Server Integration', () => { 215 | let server: Server; 216 | let transport: MemoryTransport; 217 | let mockErpnextClient: jest.Mocked<ERPNextClient>; 218 | let logger: Logger; 219 | let cache: Cache; 220 | 221 | beforeEach(async () => { 222 | // Setup mocks 223 | mockErpnextClient = { 224 | isAuthenticated: jest.fn().mockReturnValue(true), 225 | login: jest.fn(), 226 | getDocument: jest.fn(), 227 | getDocList: jest.fn(), 228 | createDocument: jest.fn(), 229 | updateDocument: jest.fn(), 230 | runReport: jest.fn(), 231 | getAllDocTypes: jest.fn() 232 | } as unknown as jest.Mocked<ERPNextClient>; 233 | 234 | logger = new Logger(); 235 | cache = new Cache(); 236 | 237 | // Create server 238 | server = new Server( 239 | { 240 | name: "erpnext-server-test", 241 | version: "0.1.0" 242 | }, 243 | { 244 | capabilities: { 245 | resources: {}, 246 | tools: {} 247 | } 248 | } 249 | ); 250 | 251 | // Register handlers 252 | registerResourceHandlers(server, mockErpnextClient, cache, logger); 253 | registerToolHandlers(server, mockErpnextClient, logger); 254 | 255 | // Setup transport 256 | transport = new MemoryTransport(); 257 | await server.connect(transport); 258 | }); 259 | 260 | afterEach(async () => { 261 | await server.close(); 262 | }); 263 | 264 | test('List tools should return all tools', async () => { 265 | // Act 266 | const response = await transport.sendRequestAndWaitForResponse({ 267 | jsonrpc: "2.0", 268 | id: "test-id", 269 | method: "mcp.listTools", 270 | params: {} 271 | }); 272 | 273 | // Assert 274 | expect(response.result).toBeDefined(); 275 | expect(response.result.tools).toBeInstanceOf(Array); 276 | expect(response.result.tools.length).toBeGreaterThan(0); 277 | expect(response.result.tools.map(t => t.name)).toContain('authenticate_erpnext'); 278 | }); 279 | 280 | test('Get documents tool should fetch documents', async () => { 281 | // Arrange 282 | const mockDocuments = [ 283 | { name: "CUST-001", customer_name: "Test Customer" } 284 | ]; 285 | 286 | mockErpnextClient.getDocList.mockResolvedValue(mockDocuments); 287 | 288 | // Act 289 | const response = await transport.sendRequestAndWaitForResponse({ 290 | jsonrpc: "2.0", 291 | id: "test-id", 292 | method: "mcp.callTool", 293 | params: { 294 | name: "get_documents", 295 | arguments: { 296 | doctype: "Customer" 297 | } 298 | } 299 | }); 300 | 301 | // Assert 302 | expect(mockErpnextClient.getDocList).toHaveBeenCalledWith( 303 | "Customer", undefined, undefined, undefined 304 | ); 305 | expect(response.result).toBeDefined(); 306 | expect(JSON.parse(response.result.content[0].text)).toEqual(mockDocuments); 307 | }); 308 | }); 309 | ``` 310 | 311 | ### 3. End-to-End Tests 312 | 313 | E2E tests should verify the entire system works correctly from the user's perspective. These tests should use a real or mock ERPNext server and execute complete workflows. 314 | 315 | ```typescript 316 | // tests/e2e/full-workflow.test.ts 317 | import { spawn, ChildProcess } from 'child_process'; 318 | import axios from 'axios'; 319 | import { jest } from '@jest/globals'; 320 | import path from 'path'; 321 | import { startMockErpnextServer, stopMockErpnextServer } from '../mocks/erpnext-api'; 322 | 323 | describe('E2E Tests', () => { 324 | let serverProcess: ChildProcess; 325 | let mockServerUrl: string; 326 | 327 | beforeAll(async () => { 328 | // Start mock ERPNext server 329 | mockServerUrl = await startMockErpnextServer(); 330 | 331 | // Start MCP server with environment pointing to mock server 332 | const serverPath = path.resolve(__dirname, '../../build/index.js'); 333 | serverProcess = spawn('node', [serverPath], { 334 | env: { 335 | ...process.env, 336 | ERPNEXT_URL: mockServerUrl, 337 | ERPNEXT_API_KEY: 'test-key', 338 | ERPNEXT_API_SECRET: 'test-secret', 339 | DEBUG: 'true' 340 | }, 341 | stdio: ['pipe', 'pipe', 'pipe'] 342 | }); 343 | 344 | // Wait for server to start 345 | await new Promise(resolve => setTimeout(resolve, 1000)); 346 | }); 347 | 348 | afterAll(async () => { 349 | // Terminate the server process 350 | if (serverProcess) { 351 | serverProcess.kill(); 352 | } 353 | 354 | // Stop mock server 355 | await stopMockErpnextServer(); 356 | }); 357 | 358 | test('Complete workflow test', async () => { 359 | // Setup a test client that communicates with the MCP server 360 | 361 | // This would test a complete workflow: 362 | // 1. Authentication 363 | // 2. Document retrieval 364 | // 3. Document creation 365 | // 4. Document update 366 | // 5. Running a report 367 | }); 368 | }); 369 | ``` 370 | 371 | ## Test Mocks 372 | 373 | Creating proper mocks is essential for effective testing: 374 | 375 | ```typescript 376 | // tests/mocks/erpnext-api.ts 377 | import express from 'express'; 378 | import http from 'http'; 379 | import bodyParser from 'body-parser'; 380 | 381 | let server: http.Server; 382 | let app: express.Express; 383 | 384 | // Mock data storage 385 | const mockData: Record<string, any[]> = { 386 | 'Customer': [ 387 | { 388 | name: 'CUST-001', 389 | customer_name: 'Test Customer 1', 390 | customer_type: 'Company', 391 | customer_group: 'Commercial', 392 | territory: 'United States' 393 | } 394 | ], 395 | 'Item': [ 396 | { 397 | name: 'ITEM-001', 398 | item_code: 'ITEM-001', 399 | item_name: 'Test Item 1', 400 | item_group: 'Products', 401 | stock_uom: 'Nos' 402 | } 403 | ] 404 | }; 405 | 406 | export async function startMockErpnextServer(): Promise<string> { 407 | app = express(); 408 | app.use(bodyParser.json()); 409 | 410 | // Setup API endpoints that mimic ERPNext 411 | 412 | // Login endpoint 413 | app.post('/api/method/login', (req, res) => { 414 | const { usr, pwd } = req.body; 415 | 416 | if (usr === 'testuser' && pwd === 'testpass') { 417 | res.json({ message: 'Logged In' }); 418 | } else { 419 | res.status(401).json({ message: 'Authentication failed' }); 420 | } 421 | }); 422 | 423 | // Document listing 424 | app.get('/api/resource/:doctype', (req, res) => { 425 | const doctype = req.params.doctype; 426 | res.json({ data: mockData[doctype] || [] }); 427 | }); 428 | 429 | // Document retrieval 430 | app.get('/api/resource/:doctype/:name', (req, res) => { 431 | const { doctype, name } = req.params; 432 | const docs = mockData[doctype] || []; 433 | const doc = docs.find(d => d.name === name); 434 | 435 | if (doc) { 436 | res.json({ data: doc }); 437 | } else { 438 | res.status(404).json({ message: 'Not found' }); 439 | } 440 | }); 441 | 442 | // Document creation 443 | app.post('/api/resource/:doctype', (req, res) => { 444 | const { doctype } = req.params; 445 | const data = req.body.data; 446 | 447 | if (!mockData[doctype]) { 448 | mockData[doctype] = []; 449 | } 450 | 451 | mockData[doctype].push(data); 452 | res.json({ data }); 453 | }); 454 | 455 | // Document update 456 | app.put('/api/resource/:doctype/:name', (req, res) => { 457 | const { doctype, name } = req.params; 458 | const data = req.body.data; 459 | 460 | if (!mockData[doctype]) { 461 | return res.status(404).json({ message: 'DocType not found' }); 462 | } 463 | 464 | const index = mockData[doctype].findIndex(d => d.name === name); 465 | if (index === -1) { 466 | return res.status(404).json({ message: 'Document not found' }); 467 | } 468 | 469 | mockData[doctype][index] = { ...mockData[doctype][index], ...data }; 470 | res.json({ data: mockData[doctype][index] }); 471 | }); 472 | 473 | // Start server on a random port 474 | return new Promise<string>((resolve) => { 475 | server = app.listen(0, () => { 476 | const address = server.address(); 477 | const port = typeof address === 'object' ? address?.port : 0; 478 | resolve(`http://localhost:${port}`); 479 | }); 480 | }); 481 | } 482 | 483 | export async function stopMockErpnextServer(): Promise<void> { 484 | return new Promise<void>((resolve) => { 485 | if (server) { 486 | server.close(() => resolve()); 487 | } else { 488 | resolve(); 489 | } 490 | }); 491 | } 492 | ``` 493 | 494 | ## Setting Up Testing Infrastructure 495 | 496 | ### 1. Dependencies 497 | 498 | Add the following development dependencies to `package.json`: 499 | 500 | ```json 501 | "devDependencies": { 502 | "@types/jest": "^29.5.0", 503 | "@types/express": "^4.17.17", 504 | "body-parser": "^1.20.2", 505 | "express": "^4.18.2", 506 | "jest": "^29.5.0", 507 | "ts-jest": "^29.1.0", 508 | "@types/node": "^20.11.24", 509 | "typescript": "^5.3.3" 510 | } 511 | ``` 512 | 513 | ### 2. Jest Configuration 514 | 515 | Add Jest configuration in `package.json`: 516 | 517 | ```json 518 | "jest": { 519 | "preset": "ts-jest", 520 | "testEnvironment": "node", 521 | "roots": [ 522 | "<rootDir>/tests" 523 | ], 524 | "collectCoverage": true, 525 | "collectCoverageFrom": [ 526 | "src/**/*.ts" 527 | ], 528 | "coverageThreshold": { 529 | "global": { 530 | "branches": 80, 531 | "functions": 80, 532 | "lines": 80, 533 | "statements": 80 534 | } 535 | } 536 | } 537 | ``` 538 | 539 | ### 3. Add Test Scripts 540 | 541 | Add test scripts to `package.json`: 542 | 543 | ```json 544 | "scripts": { 545 | "test": "jest", 546 | "test:watch": "jest --watch", 547 | "test:coverage": "jest --coverage", 548 | "test:unit": "jest tests/unit", 549 | "test:integration": "jest tests/integration", 550 | "test:e2e": "jest tests/e2e" 551 | } 552 | ``` 553 | 554 | ## CI/CD Integration 555 | 556 | Implement continuous integration using GitHub Actions or similar CI platform: 557 | 558 | ```yaml 559 | # .github/workflows/ci.yml 560 | name: CI 561 | 562 | on: 563 | push: 564 | branches: [ main ] 565 | pull_request: 566 | branches: [ main ] 567 | 568 | jobs: 569 | test: 570 | runs-on: ubuntu-latest 571 | 572 | steps: 573 | - uses: actions/checkout@v3 574 | 575 | - name: Setup Node.js 576 | uses: actions/setup-node@v3 577 | with: 578 | node-version: '18' 579 | 580 | - name: Install dependencies 581 | run: npm ci 582 | 583 | - name: Run tests 584 | run: npm test 585 | 586 | - name: Upload coverage 587 | uses: codecov/codecov-action@v3 588 | ``` 589 | 590 | ## Testing Best Practices 591 | 592 | 1. **Write tests before code** - Consider test-driven development (TDD) for new features 593 | 2. **Test edge cases** - Ensure error scenarios and unusual inputs are handled correctly 594 | 3. **Keep tests independent** - Each test should run in isolation 595 | 4. **Use descriptive test names** - Tests should document what functionality is being verified 596 | 5. **Mock external dependencies** - Don't rely on external services in unit tests 597 | 6. **Aim for high coverage** - But focus on meaningful coverage rather than arbitrary metrics 598 | 7. **Maintain tests** - Update tests when functionality changes 599 | 8. **Run tests regularly** - Integrate in CI/CD pipeline and run locally before commits 600 | 601 | ## Conclusion 602 | 603 | Implementing a comprehensive testing strategy will significantly improve the reliability and maintainability of the ERPNext MCP server. By using a combination of unit, integration, and end-to-end tests, we can ensure that the server behaves correctly under different scenarios and that changes don't introduce regressions. 604 | ``` -------------------------------------------------------------------------------- /docs/security-and-authentication.md: -------------------------------------------------------------------------------- ```markdown 1 | # Security and Authentication Improvements 2 | 3 | This document outlines recommendations for enhancing security and authentication in the ERPNext MCP server. 4 | 5 | ## Current Status 6 | 7 | The current implementation has several limitations in its security and authentication approach: 8 | 9 | 1. Simple username/password authentication without token refresh mechanisms 10 | 2. Basic API key/secret handling without proper validation 11 | 3. Passwords transmitted and handled in plain text 12 | 4. No input validation or sanitization 13 | 5. No token/session management 14 | 6. Limited security headers 15 | 7. No protection against common security vulnerabilities 16 | 17 | ## Proposed Improvements 18 | 19 | ### 1. Enhanced Authentication System 20 | 21 | #### Robust Authentication Mechanisms 22 | 23 | ```typescript 24 | // src/auth/authenticator.ts 25 | import { Logger } from "../utils/logger"; 26 | import { Config } from "../utils/config"; 27 | import { ERPNextClient } from "../client/erpnext-client"; 28 | import { ERPNextError, ERPNextErrorType } from "../utils/error-handler"; 29 | 30 | export interface AuthTokens { 31 | accessToken: string; 32 | refreshToken?: string; 33 | expiresAt?: number; 34 | } 35 | 36 | export class AuthManager { 37 | private tokens: AuthTokens | null = null; 38 | private authenticated: boolean = false; 39 | private refreshTimer: NodeJS.Timeout | null = null; 40 | private client: ERPNextClient; 41 | private logger: Logger; 42 | 43 | constructor( 44 | client: ERPNextClient, 45 | logger: Logger, 46 | private readonly config: Config 47 | ) { 48 | this.client = client; 49 | this.logger = logger; 50 | 51 | // Initialize with API key/secret if available 52 | const apiKey = this.config.getERPNextApiKey(); 53 | const apiSecret = this.config.getERPNextApiSecret(); 54 | 55 | if (apiKey && apiSecret) { 56 | this.authenticated = true; 57 | this.logger.info("Initialized with API key authentication"); 58 | } 59 | } 60 | 61 | /** 62 | * Check if the client is authenticated 63 | */ 64 | isAuthenticated(): boolean { 65 | return this.authenticated; 66 | } 67 | 68 | /** 69 | * Authenticate using username and password 70 | */ 71 | async authenticate(username: string, password: string): Promise<void> { 72 | try { 73 | if (!username || !password) { 74 | throw new ERPNextError( 75 | ERPNextErrorType.Authentication, 76 | "Username and password are required" 77 | ); 78 | } 79 | 80 | // Mask password in logs 81 | this.logger.info(`Attempting to authenticate user: ${username}`); 82 | 83 | // Authenticate with ERPNext 84 | await this.client.login(username, password); 85 | 86 | this.authenticated = true; 87 | this.logger.info(`Authentication successful for user: ${username}`); 88 | 89 | // In a real implementation, we would store tokens and set up refresh 90 | // ERPNext doesn't have a standard token-based auth, but we're 91 | // setting up the structure for future enhancement 92 | } catch (error) { 93 | this.authenticated = false; 94 | this.logger.error(`Authentication failed for user: ${username}`); 95 | throw error; 96 | } 97 | } 98 | 99 | /** 100 | * Set up token refresh mechanism 101 | * This is a placeholder for future enhancement as ERPNext's 102 | * standard API doesn't use refresh tokens, but the structure 103 | * is here for custom implementations or future changes 104 | */ 105 | private setupTokenRefresh(tokens: AuthTokens): void { 106 | // Clear any existing refresh timer 107 | if (this.refreshTimer) { 108 | clearTimeout(this.refreshTimer); 109 | } 110 | 111 | // If no expiry, don't set up refresh 112 | if (!tokens.expiresAt || !tokens.refreshToken) { 113 | return; 114 | } 115 | 116 | // Calculate time until refresh (5 minutes before expiry) 117 | const now = Date.now(); 118 | const expiryTime = tokens.expiresAt; 119 | const timeUntilRefresh = Math.max(0, expiryTime - now - 5 * 60 * 1000); 120 | 121 | this.logger.debug(`Setting up token refresh in ${timeUntilRefresh / 1000} seconds`); 122 | 123 | this.refreshTimer = setTimeout(async () => { 124 | try { 125 | // Here we would implement the token refresh logic 126 | this.logger.debug("Refreshing authentication token"); 127 | 128 | // For future implementation: 129 | // const newTokens = await this.client.refreshToken(tokens.refreshToken); 130 | // this.setTokens(newTokens); 131 | } catch (error) { 132 | this.logger.error("Failed to refresh token", { error }); 133 | this.authenticated = false; 134 | } 135 | }, timeUntilRefresh); 136 | } 137 | 138 | /** 139 | * Store authentication tokens 140 | */ 141 | private setTokens(tokens: AuthTokens): void { 142 | this.tokens = tokens; 143 | this.authenticated = true; 144 | 145 | // Set up token refresh if applicable 146 | this.setupTokenRefresh(tokens); 147 | } 148 | 149 | /** 150 | * Get current auth token for API requests 151 | */ 152 | getAuthToken(): string | null { 153 | return this.tokens?.accessToken || null; 154 | } 155 | 156 | /** 157 | * Log out and clear authentication 158 | */ 159 | logout(): void { 160 | this.authenticated = false; 161 | this.tokens = null; 162 | 163 | if (this.refreshTimer) { 164 | clearTimeout(this.refreshTimer); 165 | this.refreshTimer = null; 166 | } 167 | 168 | this.logger.info("User logged out"); 169 | } 170 | } 171 | ``` 172 | 173 | ### 2. Input Validation and Sanitization 174 | 175 | Create a dedicated validation module to ensure inputs are properly validated before being used: 176 | 177 | ```typescript 178 | // src/utils/validation.ts 179 | import { z } from "zod"; // Using Zod for schema validation 180 | 181 | // Schema for authentication credentials 182 | export const AuthCredentialsSchema = z.object({ 183 | username: z.string().min(1, "Username is required"), 184 | password: z.string().min(1, "Password is required") 185 | }); 186 | 187 | // Schema for doctype 188 | export const DoctypeSchema = z.string().min(1, "DocType is required"); 189 | 190 | // Schema for document name 191 | export const DocumentNameSchema = z.string().min(1, "Document name is required"); 192 | 193 | // Schema for document data 194 | export const DocumentDataSchema = z.record(z.unknown()); 195 | 196 | // Schema for document filters 197 | export const FiltersSchema = z.record(z.unknown()).optional(); 198 | 199 | // Schema for field list 200 | export const FieldsSchema = z.array(z.string()).optional(); 201 | 202 | // Schema for pagination 203 | export const PaginationSchema = z.object({ 204 | limit: z.number().positive().optional(), 205 | page: z.number().positive().optional() 206 | }); 207 | 208 | // Schema for report 209 | export const ReportSchema = z.object({ 210 | report_name: z.string().min(1, "Report name is required"), 211 | filters: z.record(z.unknown()).optional() 212 | }); 213 | 214 | /** 215 | * Validates input against a schema and returns the validated data 216 | * or throws an error if validation fails 217 | */ 218 | export function validateInput<T>(schema: z.Schema<T>, data: unknown): T { 219 | try { 220 | return schema.parse(data); 221 | } catch (error) { 222 | if (error instanceof z.ZodError) { 223 | // Convert zod error to a more user-friendly format 224 | const issues = error.errors.map(err => `${err.path.join('.')}: ${err.message}`).join(", "); 225 | throw new Error(`Validation error: ${issues}`); 226 | } 227 | throw error; 228 | } 229 | } 230 | 231 | /** 232 | * Sanitizes string input to prevent injection attacks 233 | */ 234 | export function sanitizeString(input: string): string { 235 | // Basic sanitization to prevent script injection 236 | return input 237 | .replace(/</g, '<') 238 | .replace(/>/g, '>') 239 | .replace(/"/g, '"') 240 | .replace(/'/g, '''); 241 | } 242 | 243 | /** 244 | * Sanitizes an object by applying string sanitization to all string properties 245 | */ 246 | export function sanitizeObject(obj: Record<string, any>): Record<string, any> { 247 | const result: Record<string, any> = {}; 248 | 249 | Object.entries(obj).forEach(([key, value]) => { 250 | if (typeof value === 'string') { 251 | result[key] = sanitizeString(value); 252 | } else if (value && typeof value === 'object' && !Array.isArray(value)) { 253 | result[key] = sanitizeObject(value); 254 | } else { 255 | result[key] = value; 256 | } 257 | }); 258 | 259 | return result; 260 | } 261 | ``` 262 | 263 | ### 3. Secure Configuration Management 264 | 265 | Enhance the configuration module with better security practices: 266 | 267 | ```typescript 268 | // src/utils/config.ts 269 | import fs from 'fs'; 270 | import path from 'path'; 271 | import dotenv from 'dotenv'; 272 | 273 | export class Config { 274 | private config: Record<string, string> = {}; 275 | 276 | constructor(configPath?: string) { 277 | // Load environment variables from .env file if it exists 278 | if (configPath && fs.existsSync(configPath)) { 279 | const envConfig = dotenv.parse(fs.readFileSync(configPath)); 280 | this.config = { ...this.config, ...envConfig }; 281 | } 282 | 283 | // Override with actual environment variables 284 | Object.assign(this.config, process.env); 285 | 286 | // Validate required configuration 287 | this.validateConfig(); 288 | } 289 | 290 | private validateConfig() { 291 | // Validate required configuration 292 | const requiredVars = ['ERPNEXT_URL']; 293 | const missing = requiredVars.filter(key => !this.get(key)); 294 | 295 | if (missing.length > 0) { 296 | throw new Error(`Missing required configuration: ${missing.join(', ')}`); 297 | } 298 | 299 | // Validate URL format 300 | try { 301 | new URL(this.getERPNextUrl()); 302 | } catch (error) { 303 | throw new Error(`Invalid ERPNEXT_URL: ${this.getERPNextUrl()}`); 304 | } 305 | 306 | // Validate API key and secret (both or neither) 307 | const hasApiKey = !!this.get('ERPNEXT_API_KEY'); 308 | const hasApiSecret = !!this.get('ERPNEXT_API_SECRET'); 309 | 310 | if ((hasApiKey && !hasApiSecret) || (!hasApiKey && hasApiSecret)) { 311 | throw new Error('Both ERPNEXT_API_KEY and ERPNEXT_API_SECRET must be provided if using API key authentication'); 312 | } 313 | } 314 | 315 | /** 316 | * Get a configuration value 317 | */ 318 | get(key: string): string | undefined { 319 | return this.config[key]; 320 | } 321 | 322 | /** 323 | * Get a configuration value or throw if not found 324 | */ 325 | getRequired(key: string): string { 326 | const value = this.get(key); 327 | if (value === undefined) { 328 | throw new Error(`Required configuration "${key}" not found`); 329 | } 330 | return value; 331 | } 332 | 333 | /** 334 | * Get ERPNext URL, ensuring it doesn't end with a trailing slash 335 | */ 336 | getERPNextUrl(): string { 337 | return this.getRequired('ERPNEXT_URL').replace(/\/$/, ''); 338 | } 339 | 340 | /** 341 | * Get ERPNext API key 342 | */ 343 | getERPNextApiKey(): string | undefined { 344 | return this.get('ERPNEXT_API_KEY'); 345 | } 346 | 347 | /** 348 | * Get ERPNext API secret 349 | */ 350 | getERPNextApiSecret(): string | undefined { 351 | return this.get('ERPNEXT_API_SECRET'); 352 | } 353 | 354 | /** 355 | * Get log level (defaults to "info") 356 | */ 357 | getLogLevel(): string { 358 | return this.get('LOG_LEVEL') || 'info'; 359 | } 360 | } 361 | ``` 362 | 363 | ### 4. Security Middleware 364 | 365 | Add security middleware to protect against common vulnerabilities: 366 | 367 | ```typescript 368 | // src/middleware/security.ts 369 | import { Logger } from "../utils/logger"; 370 | 371 | export interface RateLimitConfig { 372 | windowMs: number; 373 | maxRequests: number; 374 | } 375 | 376 | /** 377 | * Simple rate limiting implementation for tools 378 | */ 379 | export class RateLimiter { 380 | private windowMs: number; 381 | private maxRequests: number; 382 | private requests: Map<string, number[]> = new Map(); 383 | private logger: Logger; 384 | 385 | constructor(config: RateLimitConfig, logger: Logger) { 386 | this.windowMs = config.windowMs; 387 | this.maxRequests = config.maxRequests; 388 | this.logger = logger; 389 | } 390 | 391 | /** 392 | * Check if a request should be rate limited 393 | * @param key Identifier for the rate limit bucket (e.g. tool name, user ID) 394 | * @returns Whether the request should be allowed 395 | */ 396 | allowRequest(key: string): boolean { 397 | const now = Date.now(); 398 | 399 | // Get existing timestamps for this key 400 | let timestamps = this.requests.get(key) || []; 401 | 402 | // Filter out timestamps outside the current window 403 | timestamps = timestamps.filter(time => now - time < this.windowMs); 404 | 405 | // Check if we've exceeded the limit 406 | if (timestamps.length >= this.maxRequests) { 407 | this.logger.warn(`Rate limit exceeded for ${key}`); 408 | return false; 409 | } 410 | 411 | // Add the new timestamp and update the map 412 | timestamps.push(now); 413 | this.requests.set(key, timestamps); 414 | 415 | return true; 416 | } 417 | 418 | /** 419 | * Reset rate limit for a specific key 420 | */ 421 | reset(key: string): void { 422 | this.requests.delete(key); 423 | } 424 | } 425 | 426 | /** 427 | * Security headers to add to responses 428 | */ 429 | export const securityHeaders = { 430 | 'X-Content-Type-Options': 'nosniff', 431 | 'X-Frame-Options': 'DENY', 432 | 'Content-Security-Policy': "default-src 'none'", 433 | 'X-XSS-Protection': '1; mode=block', 434 | 'Strict-Transport-Security': 'max-age=31536000; includeSubDomains' 435 | }; 436 | ``` 437 | 438 | ### 5. Secure Resource and Tool Handlers 439 | 440 | Update the tool handlers to include validation, rate limiting, and better credential handling: 441 | 442 | ```typescript 443 | // src/handlers/tool-handlers.ts 444 | import { Server } from "@modelcontextprotocol/sdk/server/index.js"; 445 | import { 446 | CallToolRequestSchema, 447 | ListToolsRequestSchema, 448 | McpError, 449 | ErrorCode 450 | } from "@modelcontextprotocol/sdk/types.js"; 451 | import { ERPNextClient } from "../client/erpnext-client"; 452 | import { AuthManager } from "../auth/authenticator"; 453 | import { Logger } from "../utils/logger"; 454 | import { handleToolError } from "../utils/error-handler"; 455 | import { RateLimiter } from "../middleware/security"; 456 | import * as validation from "../utils/validation"; 457 | import { RequestContextManager } from "../middleware/context"; 458 | 459 | export function registerToolHandlers( 460 | server: Server, 461 | erpnext: ERPNextClient, 462 | auth: AuthManager, 463 | logger: Logger 464 | ) { 465 | // Create rate limiter for authentication attempts 466 | const authRateLimiter = new RateLimiter({ 467 | windowMs: 60 * 1000, // 1 minute 468 | maxRequests: 5 // 5 attempts per minute 469 | }, logger); 470 | 471 | // Create rate limiter for other operations 472 | const toolRateLimiter = new RateLimiter({ 473 | windowMs: 60 * 1000, // 1 minute 474 | maxRequests: 30 // 30 requests per minute 475 | }, logger); 476 | 477 | // Handler for listing tools 478 | server.setRequestHandler(ListToolsRequestSchema, async () => { 479 | logger.debug("Handling ListToolsRequest"); 480 | 481 | return { 482 | tools: [ 483 | { 484 | name: "get_doctypes", 485 | description: "Get a list of all available DocTypes", 486 | inputSchema: { 487 | type: "object", 488 | properties: {} 489 | } 490 | }, 491 | { 492 | name: "authenticate_erpnext", 493 | description: "Authenticate with ERPNext using username and password", 494 | inputSchema: { 495 | type: "object", 496 | properties: { 497 | username: { 498 | type: "string", 499 | description: "ERPNext username" 500 | }, 501 | password: { 502 | type: "string", 503 | description: "ERPNext password" 504 | } 505 | }, 506 | required: ["username", "password"] 507 | } 508 | }, 509 | // Other tools... 510 | ] 511 | }; 512 | }); 513 | 514 | // Handler for tool calls 515 | server.setRequestHandler(CallToolRequestSchema, async (request) => { 516 | const requestId = request.id; 517 | const context = RequestContextManager.getContext(requestId); 518 | const correlationId = context?.correlationId; 519 | 520 | logger.debug(`Handling CallToolRequest: ${request.params.name}`, { 521 | correlationId, 522 | tool: request.params.name 523 | }); 524 | 525 | try { 526 | // Apply rate limiting based on tool 527 | const toolName = request.params.name; 528 | 529 | if (toolName === 'authenticate_erpnext') { 530 | // Use stricter rate limiting for authentication 531 | if (!authRateLimiter.allowRequest(toolName)) { 532 | throw new McpError( 533 | ErrorCode.TooManyRequests, 534 | "Too many authentication attempts. Please try again later." 535 | ); 536 | } 537 | } else { 538 | // Use standard rate limiting for other tools 539 | if (!toolRateLimiter.allowRequest(toolName)) { 540 | throw new McpError( 541 | ErrorCode.TooManyRequests, 542 | "Too many requests. Please try again later." 543 | ); 544 | } 545 | } 546 | 547 | // Handle specific tool requests with proper validation 548 | switch (toolName) { 549 | case "authenticate_erpnext": { 550 | const credentials = validation.validateInput( 551 | validation.AuthCredentialsSchema, 552 | request.params.arguments 553 | ); 554 | 555 | try { 556 | await auth.authenticate(credentials.username, credentials.password); 557 | 558 | return { 559 | content: [{ 560 | type: "text", 561 | text: `Successfully authenticated with ERPNext as ${credentials.username}` 562 | }] 563 | }; 564 | } catch (error) { 565 | // Don't expose details of authentication failures 566 | return { 567 | content: [{ 568 | type: "text", 569 | text: "Authentication failed. Please check your credentials and try again." 570 | }], 571 | isError: true 572 | }; 573 | } 574 | } 575 | 576 | case "get_documents": { 577 | // First check authentication 578 | if (!auth.isAuthenticated()) { 579 | throw new McpError( 580 | ErrorCode.Unauthorized, 581 | "Not authenticated with ERPNext. Use the authenticate_erpnext tool first." 582 | ); 583 | } 584 | 585 | // Validate doctype 586 | const doctype = validation.validateInput( 587 | validation.DoctypeSchema, 588 | request.params.arguments?.doctype 589 | ); 590 | 591 | // Validate optional parameters 592 | const fields = request.params.arguments?.fields ? 593 | validation.validateInput(validation.FieldsSchema, request.params.arguments.fields) : 594 | undefined; 595 | 596 | const filters = request.params.arguments?.filters ? 597 | validation.validateInput(validation.FiltersSchema, request.params.arguments.filters) : 598 | undefined; 599 | 600 | const limit = request.params.arguments?.limit as number | undefined; 601 | 602 | // Get documents 603 | const documents = await erpnext.getDocList(doctype, filters, fields, limit); 604 | 605 | return { 606 | content: [{ 607 | type: "text", 608 | text: JSON.stringify(documents, null, 2) 609 | }] 610 | }; 611 | } 612 | 613 | // Other tool handlers... 614 | 615 | default: 616 | throw new McpError( 617 | ErrorCode.MethodNotFound, 618 | `Unknown tool: ${request.params.name}` 619 | ); 620 | } 621 | } catch (error) { 622 | return handleToolError(error, logger); 623 | } 624 | }); 625 | } 626 | ``` 627 | 628 | ## Security Best Practices 629 | 630 | ### 1. Credential Handling 631 | 632 | - Never log passwords or sensitive information 633 | - Use secure environment variables for storing secrets 634 | - Implement token refresh mechanisms where possible 635 | - Consider using a secrets manager for production deployments 636 | 637 | ### 2. Input Validation 638 | 639 | - Validate all inputs with proper schemas 640 | - Sanitize inputs where necessary to prevent injection attacks 641 | - Apply validation early in the request flow 642 | 643 | ### 3. Rate Limiting 644 | 645 | - Apply rate limits to prevent abuse 646 | - Use stricter limits for sensitive operations like authentication 647 | - Provide informative feedback when limits are exceeded 648 | 649 | ### 4. Secure Configuration 650 | 651 | - Validate configuration at startup 652 | - Support multiple configuration sources (env vars, config files) 653 | - Use a secure method for loading credentials 654 | 655 | ### 5. Authentication Best Practices 656 | 657 | - Implement proper token handling 658 | - Support multiple authentication methods 659 | - Add contextual information for authentication events 660 | - Log authentication events (success, failure) without exposing sensitive details 661 | 662 | ## Implementation Plan 663 | 664 | 1. Add the validation utility module 665 | 2. Implement the secure configuration module 666 | 3. Create the authentication manager 667 | 4. Add security middleware (rate limiting) 668 | 5. Update tool handlers to use validation and rate limiting 669 | 6. Add security headers to responses 670 | 671 | These security improvements will help protect the ERPNext MCP server against common security vulnerabilities and provide better protection for user credentials and data. 672 | ``` -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- ```typescript 1 | #!/usr/bin/env node 2 | 3 | /** 4 | * ERPNext MCP Server 5 | * This server provides integration with the ERPNext/Frappe API, allowing: 6 | * - Authentication with ERPNext 7 | * - Fetching documents from ERPNext 8 | * - Querying lists of documents 9 | * - Creating and updating documents 10 | * - Running reports 11 | */ 12 | 13 | import { Server } from "@modelcontextprotocol/sdk/server/index.js"; 14 | import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"; 15 | import { 16 | CallToolRequestSchema, 17 | ErrorCode, 18 | ListResourcesRequestSchema, 19 | ListResourceTemplatesRequestSchema, 20 | ListToolsRequestSchema, 21 | McpError, 22 | ReadResourceRequestSchema 23 | } from "@modelcontextprotocol/sdk/types.js"; 24 | import axios, { AxiosInstance } from "axios"; 25 | 26 | // ERPNext API client configuration 27 | class ERPNextClient { 28 | private baseUrl: string; 29 | private axiosInstance: AxiosInstance; 30 | private authenticated: boolean = false; 31 | 32 | constructor() { 33 | // Get ERPNext configuration from environment variables 34 | this.baseUrl = process.env.ERPNEXT_URL || ''; 35 | 36 | // Validate configuration 37 | if (!this.baseUrl) { 38 | throw new Error("ERPNEXT_URL environment variable is required"); 39 | } 40 | 41 | // Remove trailing slash if present 42 | this.baseUrl = this.baseUrl.replace(/\/$/, ''); 43 | 44 | // Initialize axios instance 45 | this.axiosInstance = axios.create({ 46 | baseURL: this.baseUrl, 47 | withCredentials: true, 48 | headers: { 49 | 'Content-Type': 'application/json', 50 | 'Accept': 'application/json' 51 | } 52 | }); 53 | 54 | // Configure authentication if credentials provided 55 | const apiKey = process.env.ERPNEXT_API_KEY; 56 | const apiSecret = process.env.ERPNEXT_API_SECRET; 57 | 58 | if (apiKey && apiSecret) { 59 | this.axiosInstance.defaults.headers.common['Authorization'] = 60 | `token ${apiKey}:${apiSecret}`; 61 | this.authenticated = true; 62 | } 63 | } 64 | 65 | isAuthenticated(): boolean { 66 | return this.authenticated; 67 | } 68 | 69 | // Get a document by doctype and name 70 | async getDocument(doctype: string, name: string): Promise<any> { 71 | try { 72 | const response = await this.axiosInstance.get(`/api/resource/${doctype}/${name}`); 73 | return response.data.data; 74 | } catch (error: any) { 75 | throw new Error(`Failed to get ${doctype} ${name}: ${error?.message || 'Unknown error'}`); 76 | } 77 | } 78 | 79 | // Get list of documents for a doctype 80 | async getDocList(doctype: string, filters?: Record<string, any>, fields?: string[], limit?: number): Promise<any[]> { 81 | try { 82 | let params: Record<string, any> = {}; 83 | 84 | if (fields && fields.length) { 85 | params['fields'] = JSON.stringify(fields); 86 | } 87 | 88 | if (filters) { 89 | params['filters'] = JSON.stringify(filters); 90 | } 91 | 92 | if (limit) { 93 | params['limit_page_length'] = limit; 94 | } 95 | 96 | const response = await this.axiosInstance.get(`/api/resource/${doctype}`, { params }); 97 | return response.data.data; 98 | } catch (error: any) { 99 | throw new Error(`Failed to get ${doctype} list: ${error?.message || 'Unknown error'}`); 100 | } 101 | } 102 | 103 | // Create a new document 104 | async createDocument(doctype: string, doc: Record<string, any>): Promise<any> { 105 | try { 106 | const response = await this.axiosInstance.post(`/api/resource/${doctype}`, { 107 | data: doc 108 | }); 109 | return response.data.data; 110 | } catch (error: any) { 111 | throw new Error(`Failed to create ${doctype}: ${error?.message || 'Unknown error'}`); 112 | } 113 | } 114 | 115 | // Update an existing document 116 | async updateDocument(doctype: string, name: string, doc: Record<string, any>): Promise<any> { 117 | try { 118 | const response = await this.axiosInstance.put(`/api/resource/${doctype}/${name}`, { 119 | data: doc 120 | }); 121 | return response.data.data; 122 | } catch (error: any) { 123 | throw new Error(`Failed to update ${doctype} ${name}: ${error?.message || 'Unknown error'}`); 124 | } 125 | } 126 | 127 | // Run a report 128 | async runReport(reportName: string, filters?: Record<string, any>): Promise<any> { 129 | try { 130 | const response = await this.axiosInstance.get(`/api/method/frappe.desk.query_report.run`, { 131 | params: { 132 | report_name: reportName, 133 | filters: filters ? JSON.stringify(filters) : undefined 134 | } 135 | }); 136 | return response.data.message; 137 | } catch (error: any) { 138 | throw new Error(`Failed to run report ${reportName}: ${error?.message || 'Unknown error'}`); 139 | } 140 | } 141 | 142 | // Get all available DocTypes 143 | async getAllDocTypes(): Promise<string[]> { 144 | try { 145 | // Use the standard REST API to fetch DocTypes 146 | const response = await this.axiosInstance.get('/api/resource/DocType', { 147 | params: { 148 | fields: JSON.stringify(["name"]), 149 | limit_page_length: 500 // Get more doctypes at once 150 | } 151 | }); 152 | 153 | if (response.data && response.data.data) { 154 | return response.data.data.map((item: any) => item.name); 155 | } 156 | 157 | return []; 158 | } catch (error: any) { 159 | console.error("Failed to get DocTypes:", error?.message || 'Unknown error'); 160 | 161 | // Try an alternative approach if the first one fails 162 | try { 163 | // Try using the method API to get doctypes 164 | const altResponse = await this.axiosInstance.get('/api/method/frappe.desk.search.search_link', { 165 | params: { 166 | doctype: 'DocType', 167 | txt: '', 168 | limit: 500 169 | } 170 | }); 171 | 172 | if (altResponse.data && altResponse.data.results) { 173 | return altResponse.data.results.map((item: any) => item.value); 174 | } 175 | 176 | return []; 177 | } catch (altError: any) { 178 | console.error("Alternative DocType fetch failed:", altError?.message || 'Unknown error'); 179 | 180 | // Fallback: Return a list of common DocTypes 181 | return [ 182 | "Customer", "Supplier", "Item", "Sales Order", "Purchase Order", 183 | "Sales Invoice", "Purchase Invoice", "Employee", "Lead", "Opportunity", 184 | "Quotation", "Payment Entry", "Journal Entry", "Stock Entry" 185 | ]; 186 | } 187 | } 188 | } 189 | } 190 | 191 | // Cache for doctype metadata 192 | const doctypeCache = new Map<string, any>(); 193 | 194 | // Initialize ERPNext client 195 | const erpnext = new ERPNextClient(); 196 | 197 | // Create an MCP server with capabilities for resources and tools 198 | const server = new Server( 199 | { 200 | name: "erpnext-server", 201 | version: "0.1.0" 202 | }, 203 | { 204 | capabilities: { 205 | resources: {}, 206 | tools: {} 207 | } 208 | } 209 | ); 210 | 211 | /** 212 | * Handler for listing available ERPNext resources. 213 | * Exposes DocTypes list as a resource and common doctypes as individual resources. 214 | */ 215 | server.setRequestHandler(ListResourcesRequestSchema, async () => { 216 | // List of common DocTypes to expose as individual resources 217 | const commonDoctypes = [ 218 | "Customer", 219 | "Supplier", 220 | "Item", 221 | "Sales Order", 222 | "Purchase Order", 223 | "Sales Invoice", 224 | "Purchase Invoice", 225 | "Employee" 226 | ]; 227 | 228 | const resources = [ 229 | // Add a resource to get all doctypes 230 | { 231 | uri: "erpnext://DocTypes", 232 | name: "All DocTypes", 233 | mimeType: "application/json", 234 | description: "List of all available DocTypes in the ERPNext instance" 235 | } 236 | ]; 237 | 238 | return { 239 | resources 240 | }; 241 | }); 242 | 243 | /** 244 | * Handler for resource templates. 245 | * Allows querying ERPNext documents by doctype and name. 246 | */ 247 | server.setRequestHandler(ListResourceTemplatesRequestSchema, async () => { 248 | const resourceTemplates = [ 249 | { 250 | uriTemplate: "erpnext://{doctype}/{name}", 251 | name: "ERPNext Document", 252 | mimeType: "application/json", 253 | description: "Fetch an ERPNext document by doctype and name" 254 | } 255 | ]; 256 | 257 | return { resourceTemplates }; 258 | }); 259 | 260 | /** 261 | * Handler for reading ERPNext resources. 262 | */ 263 | server.setRequestHandler(ReadResourceRequestSchema, async (request) => { 264 | if (!erpnext.isAuthenticated()) { 265 | throw new McpError( 266 | ErrorCode.InvalidRequest, 267 | "Not authenticated with ERPNext. Please configure API key authentication." 268 | ); 269 | } 270 | 271 | const uri = request.params.uri; 272 | let result: any; 273 | 274 | // Handle special resource: erpnext://DocTypes (list of all doctypes) 275 | if (uri === "erpnext://DocTypes") { 276 | try { 277 | const doctypes = await erpnext.getAllDocTypes(); 278 | result = { doctypes }; 279 | } catch (error: any) { 280 | throw new McpError( 281 | ErrorCode.InternalError, 282 | `Failed to fetch DocTypes: ${error?.message || 'Unknown error'}` 283 | ); 284 | } 285 | } else { 286 | // Handle document access: erpnext://{doctype}/{name} 287 | const documentMatch = uri.match(/^erpnext:\/\/([^\/]+)\/(.+)$/); 288 | if (documentMatch) { 289 | const doctype = decodeURIComponent(documentMatch[1]); 290 | const name = decodeURIComponent(documentMatch[2]); 291 | 292 | try { 293 | result = await erpnext.getDocument(doctype, name); 294 | } catch (error: any) { 295 | throw new McpError( 296 | ErrorCode.InvalidRequest, 297 | `Failed to fetch ${doctype} ${name}: ${error?.message || 'Unknown error'}` 298 | ); 299 | } 300 | } 301 | } 302 | 303 | if (!result) { 304 | throw new McpError( 305 | ErrorCode.InvalidRequest, 306 | `Invalid ERPNext resource URI: ${uri}` 307 | ); 308 | } 309 | 310 | return { 311 | contents: [{ 312 | uri: request.params.uri, 313 | mimeType: "application/json", 314 | text: JSON.stringify(result, null, 2) 315 | }] 316 | }; 317 | }); 318 | 319 | /** 320 | * Handler that lists available tools. 321 | */ 322 | server.setRequestHandler(ListToolsRequestSchema, async () => { 323 | return { 324 | tools: [ 325 | { 326 | name: "get_doctypes", 327 | description: "Get a list of all available DocTypes", 328 | inputSchema: { 329 | type: "object", 330 | properties: {} 331 | } 332 | }, 333 | { 334 | name: "get_doctype_fields", 335 | description: "Get fields list for a specific DocType", 336 | inputSchema: { 337 | type: "object", 338 | properties: { 339 | doctype: { 340 | type: "string", 341 | description: "ERPNext DocType (e.g., Customer, Item)" 342 | } 343 | }, 344 | required: ["doctype"] 345 | } 346 | }, 347 | { 348 | name: "get_documents", 349 | description: "Get a list of documents for a specific doctype", 350 | inputSchema: { 351 | type: "object", 352 | properties: { 353 | doctype: { 354 | type: "string", 355 | description: "ERPNext DocType (e.g., Customer, Item)" 356 | }, 357 | fields: { 358 | type: "array", 359 | items: { 360 | type: "string" 361 | }, 362 | description: "Fields to include (optional)" 363 | }, 364 | filters: { 365 | type: "object", 366 | additionalProperties: true, 367 | description: "Filters in the format {field: value} (optional)" 368 | }, 369 | limit: { 370 | type: "number", 371 | description: "Maximum number of documents to return (optional)" 372 | } 373 | }, 374 | required: ["doctype"] 375 | } 376 | }, 377 | { 378 | name: "create_document", 379 | description: "Create a new document in ERPNext", 380 | inputSchema: { 381 | type: "object", 382 | properties: { 383 | doctype: { 384 | type: "string", 385 | description: "ERPNext DocType (e.g., Customer, Item)" 386 | }, 387 | data: { 388 | type: "object", 389 | additionalProperties: true, 390 | description: "Document data" 391 | } 392 | }, 393 | required: ["doctype", "data"] 394 | } 395 | }, 396 | { 397 | name: "update_document", 398 | description: "Update an existing document in ERPNext", 399 | inputSchema: { 400 | type: "object", 401 | properties: { 402 | doctype: { 403 | type: "string", 404 | description: "ERPNext DocType (e.g., Customer, Item)" 405 | }, 406 | name: { 407 | type: "string", 408 | description: "Document name/ID" 409 | }, 410 | data: { 411 | type: "object", 412 | additionalProperties: true, 413 | description: "Document data to update" 414 | } 415 | }, 416 | required: ["doctype", "name", "data"] 417 | } 418 | }, 419 | { 420 | name: "run_report", 421 | description: "Run an ERPNext report", 422 | inputSchema: { 423 | type: "object", 424 | properties: { 425 | report_name: { 426 | type: "string", 427 | description: "Name of the report" 428 | }, 429 | filters: { 430 | type: "object", 431 | additionalProperties: true, 432 | description: "Report filters (optional)" 433 | } 434 | }, 435 | required: ["report_name"] 436 | } 437 | } 438 | ] 439 | }; 440 | }); 441 | 442 | /** 443 | * Handler for tool calls. 444 | */ 445 | server.setRequestHandler(CallToolRequestSchema, async (request) => { 446 | switch (request.params.name) { 447 | case "get_documents": { 448 | if (!erpnext.isAuthenticated()) { 449 | return { 450 | content: [{ 451 | type: "text", 452 | text: "Not authenticated with ERPNext. Please configure API key authentication." 453 | }], 454 | isError: true 455 | }; 456 | } 457 | 458 | const doctype = String(request.params.arguments?.doctype); 459 | const fields = request.params.arguments?.fields as string[] | undefined; 460 | const filters = request.params.arguments?.filters as Record<string, any> | undefined; 461 | const limit = request.params.arguments?.limit as number | undefined; 462 | 463 | if (!doctype) { 464 | throw new McpError( 465 | ErrorCode.InvalidParams, 466 | "Doctype is required" 467 | ); 468 | } 469 | 470 | try { 471 | const documents = await erpnext.getDocList(doctype, filters, fields, limit); 472 | return { 473 | content: [{ 474 | type: "text", 475 | text: JSON.stringify(documents, null, 2) 476 | }] 477 | }; 478 | } catch (error: any) { 479 | return { 480 | content: [{ 481 | type: "text", 482 | text: `Failed to get ${doctype} documents: ${error?.message || 'Unknown error'}` 483 | }], 484 | isError: true 485 | }; 486 | } 487 | } 488 | 489 | case "create_document": { 490 | if (!erpnext.isAuthenticated()) { 491 | return { 492 | content: [{ 493 | type: "text", 494 | text: "Not authenticated with ERPNext. Please configure API key authentication." 495 | }], 496 | isError: true 497 | }; 498 | } 499 | 500 | const doctype = String(request.params.arguments?.doctype); 501 | const data = request.params.arguments?.data as Record<string, any> | undefined; 502 | 503 | if (!doctype || !data) { 504 | throw new McpError( 505 | ErrorCode.InvalidParams, 506 | "Doctype and data are required" 507 | ); 508 | } 509 | 510 | try { 511 | const result = await erpnext.createDocument(doctype, data); 512 | return { 513 | content: [{ 514 | type: "text", 515 | text: `Created ${doctype}: ${result.name}\n\n${JSON.stringify(result, null, 2)}` 516 | }] 517 | }; 518 | } catch (error: any) { 519 | return { 520 | content: [{ 521 | type: "text", 522 | text: `Failed to create ${doctype}: ${error?.message || 'Unknown error'}` 523 | }], 524 | isError: true 525 | }; 526 | } 527 | } 528 | 529 | case "update_document": { 530 | if (!erpnext.isAuthenticated()) { 531 | return { 532 | content: [{ 533 | type: "text", 534 | text: "Not authenticated with ERPNext. Please configure API key authentication." 535 | }], 536 | isError: true 537 | }; 538 | } 539 | 540 | const doctype = String(request.params.arguments?.doctype); 541 | const name = String(request.params.arguments?.name); 542 | const data = request.params.arguments?.data as Record<string, any> | undefined; 543 | 544 | if (!doctype || !name || !data) { 545 | throw new McpError( 546 | ErrorCode.InvalidParams, 547 | "Doctype, name, and data are required" 548 | ); 549 | } 550 | 551 | try { 552 | const result = await erpnext.updateDocument(doctype, name, data); 553 | return { 554 | content: [{ 555 | type: "text", 556 | text: `Updated ${doctype} ${name}\n\n${JSON.stringify(result, null, 2)}` 557 | }] 558 | }; 559 | } catch (error: any) { 560 | return { 561 | content: [{ 562 | type: "text", 563 | text: `Failed to update ${doctype} ${name}: ${error?.message || 'Unknown error'}` 564 | }], 565 | isError: true 566 | }; 567 | } 568 | } 569 | 570 | case "run_report": { 571 | if (!erpnext.isAuthenticated()) { 572 | return { 573 | content: [{ 574 | type: "text", 575 | text: "Not authenticated with ERPNext. Please configure API key authentication." 576 | }], 577 | isError: true 578 | }; 579 | } 580 | 581 | const reportName = String(request.params.arguments?.report_name); 582 | const filters = request.params.arguments?.filters as Record<string, any> | undefined; 583 | 584 | if (!reportName) { 585 | throw new McpError( 586 | ErrorCode.InvalidParams, 587 | "Report name is required" 588 | ); 589 | } 590 | 591 | try { 592 | const result = await erpnext.runReport(reportName, filters); 593 | return { 594 | content: [{ 595 | type: "text", 596 | text: JSON.stringify(result, null, 2) 597 | }] 598 | }; 599 | } catch (error: any) { 600 | return { 601 | content: [{ 602 | type: "text", 603 | text: `Failed to run report ${reportName}: ${error?.message || 'Unknown error'}` 604 | }], 605 | isError: true 606 | }; 607 | } 608 | } 609 | 610 | case "get_doctype_fields": { 611 | if (!erpnext.isAuthenticated()) { 612 | return { 613 | content: [{ 614 | type: "text", 615 | text: "Not authenticated with ERPNext. Please configure API key authentication." 616 | }], 617 | isError: true 618 | }; 619 | } 620 | 621 | const doctype = String(request.params.arguments?.doctype); 622 | 623 | if (!doctype) { 624 | throw new McpError( 625 | ErrorCode.InvalidParams, 626 | "Doctype is required" 627 | ); 628 | } 629 | 630 | try { 631 | // Get a sample document to understand the fields 632 | const documents = await erpnext.getDocList(doctype, {}, ["*"], 1); 633 | 634 | if (!documents || documents.length === 0) { 635 | return { 636 | content: [{ 637 | type: "text", 638 | text: `No documents found for ${doctype}. Cannot determine fields.` 639 | }], 640 | isError: true 641 | }; 642 | } 643 | 644 | // Extract field names from the first document 645 | const sampleDoc = documents[0]; 646 | const fields = Object.keys(sampleDoc).map(field => ({ 647 | fieldname: field, 648 | value: typeof sampleDoc[field], 649 | sample: sampleDoc[field]?.toString()?.substring(0, 50) || null 650 | })); 651 | 652 | return { 653 | content: [{ 654 | type: "text", 655 | text: JSON.stringify(fields, null, 2) 656 | }] 657 | }; 658 | } catch (error: any) { 659 | return { 660 | content: [{ 661 | type: "text", 662 | text: `Failed to get fields for ${doctype}: ${error?.message || 'Unknown error'}` 663 | }], 664 | isError: true 665 | }; 666 | } 667 | } 668 | 669 | case "get_doctypes": { 670 | if (!erpnext.isAuthenticated()) { 671 | return { 672 | content: [{ 673 | type: "text", 674 | text: "Not authenticated with ERPNext. Please configure API key authentication." 675 | }], 676 | isError: true 677 | }; 678 | } 679 | 680 | try { 681 | const doctypes = await erpnext.getAllDocTypes(); 682 | return { 683 | content: [{ 684 | type: "text", 685 | text: JSON.stringify(doctypes, null, 2) 686 | }] 687 | }; 688 | } catch (error: any) { 689 | return { 690 | content: [{ 691 | type: "text", 692 | text: `Failed to get DocTypes: ${error?.message || 'Unknown error'}` 693 | }], 694 | isError: true 695 | }; 696 | } 697 | } 698 | 699 | default: 700 | throw new McpError( 701 | ErrorCode.MethodNotFound, 702 | `Unknown tool: ${request.params.name}` 703 | ); 704 | } 705 | }); 706 | 707 | /** 708 | * Start the server using stdio transport. 709 | */ 710 | async function main() { 711 | const transport = new StdioServerTransport(); 712 | await server.connect(transport); 713 | console.error('ERPNext MCP server running on stdio'); 714 | } 715 | 716 | main().catch((error) => { 717 | console.error("Server error:", error); 718 | process.exit(1); 719 | }); 720 | ```