# 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: -------------------------------------------------------------------------------- ``` node_modules/ build/ *.log .env* ``` -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- ```markdown # ERPNext MCP Server A Model Context Protocol server for ERPNext integration 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. ## Features ### Resources - Access ERPNext documents via `erpnext://{doctype}/{name}` URIs - JSON format for structured data access ### Tools - `authenticate_erpnext` - Authenticate with ERPNext using username and password - `get_documents` - Get a list of documents for a specific doctype - `create_document` - Create a new document in ERPNext - `update_document` - Update an existing document in ERPNext - `run_report` - Run an ERPNext report - `get_doctype_fields` - Get fields list for a specific DocType - `get_doctypes` - Get a list of all available DocTypes ## Configuration The server requires the following environment variables: - `ERPNEXT_URL` - The base URL of your ERPNext instance - `ERPNEXT_API_KEY` (optional) - API key for authentication - `ERPNEXT_API_SECRET` (optional) - API secret for authentication ## Development Install dependencies: ```bash npm install ``` Build the server: ```bash npm run build ``` For development with auto-rebuild: ```bash npm run watch ``` ## Installation To use with Claude Desktop, add the server config: On MacOS: `~/Library/Application Support/Claude/claude_desktop_config.json` On Windows: `%APPDATA%/Claude/claude_desktop_config.json` ```json { "mcpServers": { "erpnext": { "command": "node", "args": ["/path/to/erpnext-server/build/index.js"], "env": { "ERPNEXT_URL": "http://your-erpnext-instance.com", "ERPNEXT_API_KEY": "your-api-key", "ERPNEXT_API_SECRET": "your-api-secret" } } } } ``` To use with Claude in VSCode, add the server config to: On MacOS: `~/Library/Application Support/Code/User/globalStorage/saoudrizwan.claude-dev/settings/cline_mcp_settings.json` On Windows: `%APPDATA%/Code/User/globalStorage/saoudrizwan.claude-dev/settings/cline_mcp_settings.json` ### Debugging 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: ```bash npm run inspector ``` The Inspector will provide a URL to access debugging tools in your browser. ## Usage Examples ### Authentication ``` <use_mcp_tool> <server_name>erpnext</server_name> <tool_name>authenticate_erpnext</tool_name> <arguments> { "username": "your-username", "password": "your-password" } </arguments> </use_mcp_tool> ``` ### Get Customer List ``` <use_mcp_tool> <server_name>erpnext</server_name> <tool_name>get_documents</tool_name> <arguments> { "doctype": "Customer" } </arguments> </use_mcp_tool> ``` ### Get Customer Details ``` <access_mcp_resource> <server_name>erpnext</server_name> <uri>erpnext://Customer/CUSTOMER001</uri> </access_mcp_resource> ``` ### Create New Item ``` <use_mcp_tool> <server_name>erpnext</server_name> <tool_name>create_document</tool_name> <arguments> { "doctype": "Item", "data": { "item_code": "ITEM001", "item_name": "Test Item", "item_group": "Products", "stock_uom": "Nos" } } </arguments> </use_mcp_tool> ``` ### Get Item Fields ``` <use_mcp_tool> <server_name>erpnext</server_name> <tool_name>get_doctype_fields</tool_name> <arguments> { "doctype": "Item" } </arguments> </use_mcp_tool> ``` -------------------------------------------------------------------------------- /docs/README.md: -------------------------------------------------------------------------------- ```markdown # ERPNext MCP Server Improvement Documentation 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. ## Document Overview ### [Analysis](./analysis.md) 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. ### [Code Restructuring](./code-restructuring.md) 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. ### [Testing Strategy](./testing-strategy.md) 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. ### [Error Handling and Logging](./error-handling-and-logging.md) Strategy for implementing structured error handling and logging, including error categorization, correlation IDs, contextual logging, and integration with the MCP server. ### [Security and Authentication](./security-and-authentication.md) Detailed recommendations for enhancing security and authentication, covering input validation, secure configuration management, rate limiting, security middleware, and best practices for credential handling. ### [Implementation Roadmap](./implementation-roadmap.md) Phased approach for implementing all recommended improvements, with timeline estimates, dependencies, task prioritization, and risk management considerations. ### [Feature Improvements](./feature-improvements.md) Detailed recommendations for enhancing the server's functionality with new features like advanced querying, bulk operations, webhooks, document workflows, file operations, and more. ## Getting Started 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. ## Key Improvement Areas 1. **Code Organization**: Transition from a monolithic design to a modular architecture 2. **Error Handling**: Implement structured error handling with proper categorization 3. **Testing**: Add comprehensive test coverage with unit, integration, and E2E tests 4. **Logging**: Enhance logging with structured formats and contextual information 5. **Security**: Improve authentication, add input validation, and implement rate limiting 6. **Performance**: Add caching and optimize API interactions 7. **Maintainability**: Improve documentation and implement best practices ## Implementation Guide 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. ## Additional Resources - [Model Context Protocol Documentation](https://github.com/modelcontextprotocol/mcp) - [ERPNext API Reference](https://frappeframework.com/docs/user/en/api) - [Frappe Framework Documentation](https://frappeframework.com/docs) ``` -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- ```json { "compilerOptions": { "target": "ES2022", "module": "Node16", "moduleResolution": "Node16", "outDir": "./build", "rootDir": "./src", "strict": true, "esModuleInterop": true, "skipLibCheck": true, "forceConsistentCasingInFileNames": true }, "include": ["src/**/*"], "exclude": ["node_modules"] } ``` -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- ```json { "name": "erpnext-server", "version": "0.1.0", "description": "A Model Context Protocol server", "private": true, "type": "module", "bin": { "erpnext-server": "./build/index.js" }, "files": [ "build" ], "scripts": { "build": "tsc && node -e \"require('fs').chmodSync('build/index.js', '755')\"", "prepare": "npm run build", "watch": "tsc --watch", "inspector": "npx @modelcontextprotocol/inspector build/index.js" }, "dependencies": { "@modelcontextprotocol/sdk": "0.6.0", "axios": "^1.8.4" }, "devDependencies": { "@types/node": "^20.11.24", "typescript": "^5.3.3" } } ``` -------------------------------------------------------------------------------- /docs/analysis.md: -------------------------------------------------------------------------------- ```markdown # ERPNext MCP Server Analysis This document provides an analysis of the current ERPNext MCP server implementation and suggests improvements for better reliability, maintainability, and extensibility. ## Current Implementation Overview The ERPNext MCP server currently provides integration with the ERPNext/Frappe API through: - **Resource access**: Documents can be accessed via `erpnext://{doctype}/{name}` URIs - **Tools**: Several tools for authentication, document manipulation, and report running - **Single-file implementation**: All logic is contained in `src/index.ts` ## Areas for Improvement ### 1. Code Organization **Issues:** - The entire server implementation is in a single file, making it difficult to maintain as it grows - No separation of concerns between API client, MCP server, and request handlers - Mixed business logic and protocol handling **Recommendations:** - Restructure the codebase into multiple modules: ``` src/ ├── client/ │ └── erpnext-client.ts # ERPNext API client logic ├── handlers/ │ ├── resource-handlers.ts # Resource request handlers │ └── tool-handlers.ts # Tool request handlers ├── models/ │ └── types.ts # TypeScript interfaces and types ├── utils/ │ ├── error-handler.ts # Error handling utilities │ ├── logger.ts # Logging utilities │ └── config.ts # Configuration management └── index.ts # Server bootstrap ``` ### 2. Error Handling **Issues:** - Basic error handling with limited categorization - Inconsistent error response formats - No handling of network-specific errors vs. business logic errors **Recommendations:** - Create a dedicated error handling module - Map ERPNext API errors to appropriate MCP error codes - Implement consistent error logging with appropriate detail levels - Add correlation IDs to track errors across requests ### 3. Authentication **Issues:** - Only username/password authentication supported - No token refresh mechanism - API key/secret handling is basic **Recommendations:** - Support modern OAuth-based authentication - Implement token refresh strategy - Add secure credential storage options - Consider implementing session management ### 4. Caching **Issues:** - `doctypeCache` is defined but never used - No caching strategy for frequently accessed data - Each request makes a fresh API call **Recommendations:** - Implement in-memory cache for DocType metadata - Add time-based cache expiration - Consider using a dedicated cache library for more complex scenarios - Add cache invalidation when documents are updated ### 5. Testing **Issues:** - No test suite - No mocking of external dependencies **Recommendations:** - Add unit tests for core functionality - Implement integration tests for ERPNext client - Create mock ERPNext API for testing - Set up CI/CD pipeline with test automation ### 6. Logging **Issues:** - Basic console logging - No structured log format - No log levels for different environments **Recommendations:** - Implement structured logging - Add configurable log levels - Include request/response details for debugging - Consider using a dedicated logging library ### 7. Documentation **Issues:** - Basic README with limited examples - No JSDoc comments for most functions - No detailed API documentation **Recommendations:** - Add comprehensive JSDoc comments - Create detailed API reference - Add code examples for common scenarios - Document error codes and their meanings - Consider generating API docs with TypeDoc ### 8. Configuration **Issues:** - Environment variables handled in an ad-hoc way - No configuration validation - Limited configuration options **Recommendations:** - Create a dedicated configuration module - Add validation for required configuration - Support multiple configuration sources (env vars, config files) - Add configuration schema validation ### 9. Rate Limiting **Issues:** - No protection against excessive API calls - Potential for unintended DoS on ERPNext instance **Recommendations:** - Implement rate limiting for API calls - Add configurable limits per operation - Provide feedback on rate limit status ### 10. Tool Implementation **Issues:** - `get_doctype_fields` has a hacky implementation that loads a sample document - Limited error handling in tool implementations - No pagination support for large result sets **Recommendations:** - Use proper metadata APIs to get DocType fields - Implement pagination for list operations - Add better validation of tool inputs - Consider adding additional useful tools (e.g., bulk operations) ### 11. Security **Issues:** - Passwords are passed directly with no handling recommendations - No security headers or CORS configuration - No input sanitization **Recommendations:** - Add input validation and sanitization - Improve credential handling and provide secure usage guidelines - Document security best practices ### 12. Resource Management **Issues:** - Limited resource implementations compared to tools - No dynamic resource discovery **Recommendations:** - Expand resource capabilities - Implement more resource templates - Add resource discovery mechanism ### 13. Dependency Management **Issues:** - No dependency injection for better testability - Direct dependency on axios without abstraction **Recommendations:** - Implement dependency injection pattern - Create HTTP client abstraction to allow switching libraries - Centralize external dependency management ## Conclusion 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. ``` -------------------------------------------------------------------------------- /docs/implementation-roadmap.md: -------------------------------------------------------------------------------- ```markdown # Implementation Roadmap This document outlines a comprehensive roadmap for implementing the improvements suggested in the analysis of the ERPNext MCP server. ## Overview 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. ## Phase 1: Code Restructuring and Basic Improvements **Objective**: Establish a solid foundation by restructuring the codebase into a more maintainable architecture. **Duration**: 2-3 weeks ### Tasks 1. **Project Structure Reorganization** - Create directory structure as outlined in [`code-restructuring.md`](./code-restructuring.md) - Split the monolithic `src/index.ts` into modular components 2. **Core Module Extraction** - Extract ERPNext client into `src/client/erpnext-client.ts` - Extract resource handlers into `src/handlers/resource-handlers.ts` - Extract tool handlers into `src/handlers/tool-handlers.ts` 3. **Configuration Management** - Create dedicated configuration module in `src/utils/config.ts` - Implement validation for required configuration - Add support for multiple configuration sources 4. **Basic Logging** - Implement structured logging in `src/utils/logger.ts` - Add different log levels for development and production - Add contextual information to logs ### Deliverables - Modular codebase with clear separation of concerns - Improved configuration management - Basic structured logging ## Phase 2: Enhanced Error Handling and Testing **Objective**: Improve reliability through better error handling and establish a testing framework. **Duration**: 3-4 weeks ### Tasks 1. **Error Handling** - Implement error categorization as outlined in [`error-handling-and-logging.md`](./error-handling-and-logging.md) - Create error mapping between ERPNext and MCP error codes - Add correlation IDs for tracking errors across the system 2. **Testing Framework Setup** - Set up Jest for testing - Create test directory structure - Add test utilities and mocks 3. **Unit Tests** - Write unit tests for core modules (client, utils) - Achieve at least 70% code coverage 4. **Integration Tests** - Add integration tests for API interactions - Create mock ERPNext API for testing ### Deliverables - Robust error handling system - Test suite with good coverage - Mock ERPNext API for testing ## Phase 3: Security and Authentication Enhancements **Objective**: Strengthen security and improve authentication mechanisms. **Duration**: 2-3 weeks ### Tasks 1. **Input Validation** - Create validation module as outlined in [`security-and-authentication.md`](./security-and-authentication.md) - Implement schema validation for all inputs - Add input sanitization to prevent injection attacks 2. **Authentication Improvements** - Implement authentication manager - Add token management - Implement secure credential handling 3. **Rate Limiting** - Add rate limiting middleware - Implement different limits for different operations - Add protection against brute force attacks 4. **Security Headers** - Add security headers to responses - Implement secure defaults ### Deliverables - Secure input validation - Enhanced authentication - Protection against common attacks ## Phase 4: Advanced Features and Performance **Objective**: Add advanced features and optimize performance. **Duration**: 4-6 weeks ### Tasks 1. **Caching Implementation** - Implement in-memory cache - Add cache invalidation logic - Optimize frequently accessed resources 2. **Documentation** - Add comprehensive JSDoc comments - Generate API documentation - Update README with examples 3. **Pagination Support** - Add pagination for list operations - Implement cursor-based pagination for large result sets 4. **Resource Expansion** - Enhance resource capabilities - Add more resource templates - Improve discovery mechanism 5. **Performance Optimization** - Implement request batching - Optimize network requests - Add performance metrics 6. **Feature Enhancements** (as outlined in [`feature-improvements.md`](./feature-improvements.md)) - Implement enhanced DocType discovery and metadata - Add advanced querying capabilities - Develop bulk operations support - Create file operations functionality - Add webhook and event support - Implement document workflows - Develop data synchronization utilities ### Deliverables - Caching system for improved performance - Comprehensive documentation - Enhanced resource capabilities - Performance metrics and optimizations - New functional capabilities as detailed in feature improvements ## Phase 5: CI/CD and DevOps **Objective**: Set up continuous integration and deployment pipeline. **Duration**: 1-2 weeks ### Tasks 1. **CI Setup** - Configure GitHub Actions or similar CI platform - Automate testing - Implement code quality checks 2. **Automated Builds** - Set up automated builds - Create release versioning - Generate build artifacts 3. **Deployment Automation** - Create deployment scripts - Add environment configuration templates - Document deployment process ### Deliverables - Automated CI/CD pipeline - Code quality checks - Streamlined release process ## Timeline Overview ``` Week 1-3: Phase 1 - Code Restructuring Week 4-7: Phase 2 - Error Handling and Testing Week 8-10: Phase 3 - Security Enhancements Week 11-14: Phase 4 - Advanced Features Week 15-16: Phase 5 - CI/CD and DevOps ``` ## Implementation Priorities When implementing these improvements, follow these priorities: 1. **Critical**: Code restructuring, basic error handling 2. **High**: Testing setup, security improvements 3. **Medium**: Caching, pagination, documentation 4. **Low**: Advanced features, CI/CD ## Dependencies and Requirements For implementing these improvements, ensure: 1. Access to ERPNext instance for testing 2. Node.js development environment 3. Knowledge of TypeScript and MCP SDK 4. Understanding of ERPNext API ## Risk Management Some potential risks to consider: 1. **API Changes**: ERPNext may update its API, requiring adjustments 2. **Backward Compatibility**: Ensure changes don't break existing clients 3. **Performance Impact**: Monitor performance impacts of changes ## Conclusion 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. ``` -------------------------------------------------------------------------------- /docs/code-restructuring.md: -------------------------------------------------------------------------------- ```markdown # Code Restructuring Plan This document outlines a plan for restructuring the ERPNext MCP server codebase to improve maintainability, testability, and extensibility. ## Current Structure Currently, the entire implementation is in a single file (`src/index.ts`), which contains: - ERPNext API client - MCP server setup - Resource handlers - Tool handlers - Error handling - Authentication logic This monolithic approach makes the code difficult to maintain, test, and extend. ## Proposed Structure ### Directory Structure ``` src/ ├── client/ │ └── erpnext-client.ts # ERPNext API client ├── handlers/ │ ├── resource-handlers.ts # Resource request handlers │ └── tool-handlers.ts # Tool request handlers ├── models/ │ ├── doctype.ts # DocType interfaces │ └── errors.ts # Error types ├── utils/ │ ├── cache.ts # Caching utilities │ ├── config.ts # Configuration management │ ├── error-handler.ts # Error handling utilities │ └── logger.ts # Logging utilities ├── constants/ │ └── error-codes.ts # Error code definitions ├── middleware/ │ ├── auth.ts # Authentication middleware │ └── rate-limiter.ts # Rate limiting middleware └── index.ts # Server bootstrap ``` ### Key Components #### 1. ERPNext Client (`src/client/erpnext-client.ts`) Extract the ERPNext API client into a dedicated module: ```typescript // src/client/erpnext-client.ts import axios, { AxiosInstance } from "axios"; import { Logger } from "../utils/logger"; import { Config } from "../utils/config"; import { ERPNextError } from "../models/errors"; export class ERPNextClient { private baseUrl: string; private axiosInstance: AxiosInstance; private authenticated: boolean = false; private logger: Logger; constructor(config: Config, logger: Logger) { this.logger = logger; this.baseUrl = config.getERPNextUrl(); // Initialize axios instance this.axiosInstance = axios.create({ baseURL: this.baseUrl, withCredentials: true, headers: { 'Content-Type': 'application/json', 'Accept': 'application/json' } }); // Configure authentication if credentials provided const apiKey = config.getERPNextApiKey(); const apiSecret = config.getERPNextApiSecret(); if (apiKey && apiSecret) { this.axiosInstance.defaults.headers.common['Authorization'] = `token ${apiKey}:${apiSecret}`; this.authenticated = true; this.logger.info("Initialized with API key authentication"); } } // Methods for API operations... } ``` #### 2. Resource Handlers (`src/handlers/resource-handlers.ts`) Move the resource-related request handlers to a dedicated module: ```typescript // src/handlers/resource-handlers.ts import { Server } from "@modelcontextprotocol/sdk/server/index.js"; import { ListResourcesRequestSchema, ListResourceTemplatesRequestSchema, ReadResourceRequestSchema, McpError, ErrorCode } from "@modelcontextprotocol/sdk/types.js"; import { ERPNextClient } from "../client/erpnext-client"; import { Logger } from "../utils/logger"; import { Cache } from "../utils/cache"; export function registerResourceHandlers( server: Server, erpnext: ERPNextClient, cache: Cache, logger: Logger ) { // Handler for listing resources server.setRequestHandler(ListResourcesRequestSchema, async () => { logger.debug("Handling ListResourcesRequest"); const resources = [ { uri: "erpnext://DocTypes", name: "All DocTypes", mimeType: "application/json", description: "List of all available DocTypes in the ERPNext instance" } ]; return { resources }; }); // Other resource handlers... } ``` #### 3. Tool Handlers (`src/handlers/tool-handlers.ts`) Similarly, move the tool-related request handlers: ```typescript // src/handlers/tool-handlers.ts import { Server } from "@modelcontextprotocol/sdk/server/index.js"; import { CallToolRequestSchema, ListToolsRequestSchema, McpError, ErrorCode } from "@modelcontextprotocol/sdk/types.js"; import { ERPNextClient } from "../client/erpnext-client"; import { Logger } from "../utils/logger"; import { handleErrors } from "../utils/error-handler"; export function registerToolHandlers( server: Server, erpnext: ERPNextClient, logger: Logger ) { // Handler for listing tools server.setRequestHandler(ListToolsRequestSchema, async () => { logger.debug("Handling ListToolsRequest"); return { tools: [ { name: "get_doctypes", description: "Get a list of all available DocTypes", inputSchema: { type: "object", properties: {} } }, // Other tools... ] }; }); // Handler for tool calls with proper error handling server.setRequestHandler(CallToolRequestSchema, async (request) => { logger.debug(`Handling CallToolRequest: ${request.params.name}`); try { switch (request.params.name) { case "authenticate_erpnext": return await handleAuthenticateErpnext(request, erpnext, logger); // Other tool handlers... default: throw new McpError( ErrorCode.MethodNotFound, `Unknown tool: ${request.params.name}` ); } } catch (error) { return handleErrors(error, logger); } }); } // Individual tool handler functions async function handleAuthenticateErpnext(request, erpnext, logger) { // Implementation... } ``` #### 4. Cache Utility (`src/utils/cache.ts`) Implement the previously defined but unused cache: ```typescript // src/utils/cache.ts export class Cache { private cache: Map<string, CacheEntry>; private defaultTTLMs: number; constructor(defaultTTLMs: number = 5 * 60 * 1000) { // 5 minutes default this.cache = new Map(); this.defaultTTLMs = defaultTTLMs; } set(key: string, value: any, ttlMs?: number): void { const expiryTime = Date.now() + (ttlMs || this.defaultTTLMs); this.cache.set(key, { value, expiryTime }); } get<T>(key: string): T | undefined { const entry = this.cache.get(key); if (!entry) { return undefined; } if (Date.now() > entry.expiryTime) { this.cache.delete(key); return undefined; } return entry.value as T; } invalidate(keyPrefix: string): void { for (const key of this.cache.keys()) { if (key.startsWith(keyPrefix)) { this.cache.delete(key); } } } } interface CacheEntry { value: any; expiryTime: number; } ``` #### 5. Configuration Module (`src/utils/config.ts`) Create a dedicated configuration module: ```typescript // src/utils/config.ts export class Config { private erpnextUrl: string; private apiKey?: string; private apiSecret?: string; constructor() { this.erpnextUrl = this.getRequiredEnv("ERPNEXT_URL"); // Remove trailing slash if present this.erpnextUrl = this.erpnextUrl.replace(/\/$/, ''); this.apiKey = process.env.ERPNEXT_API_KEY; this.apiSecret = process.env.ERPNEXT_API_SECRET; this.validate(); } private getRequiredEnv(name: string): string { const value = process.env[name]; if (!value) { throw new Error(`${name} environment variable is required`); } return value; } private validate() { if (!this.erpnextUrl.startsWith("http")) { throw new Error("ERPNEXT_URL must include protocol (http:// or https://)"); } // If one of API key/secret is provided, both must be provided if ((this.apiKey && !this.apiSecret) || (!this.apiKey && this.apiSecret)) { throw new Error("Both ERPNEXT_API_KEY and ERPNEXT_API_SECRET must be provided if using API key authentication"); } } getERPNextUrl(): string { return this.erpnextUrl; } getERPNextApiKey(): string | undefined { return this.apiKey; } getERPNextApiSecret(): string | undefined { return this.apiSecret; } } ``` #### 6. Logger Module (`src/utils/logger.ts`) Implement a proper logging utility: ```typescript // src/utils/logger.ts export enum LogLevel { ERROR = 0, WARN = 1, INFO = 2, DEBUG = 3, } export class Logger { private level: LogLevel; constructor(level: LogLevel = LogLevel.INFO) { this.level = level; } setLevel(level: LogLevel) { this.level = level; } error(message: string, ...meta: any[]) { if (this.level >= LogLevel.ERROR) { console.error(`[ERROR] ${message}`, ...meta); } } warn(message: string, ...meta: any[]) { if (this.level >= LogLevel.WARN) { console.warn(`[WARN] ${message}`, ...meta); } } info(message: string, ...meta: any[]) { if (this.level >= LogLevel.INFO) { console.info(`[INFO] ${message}`, ...meta); } } debug(message: string, ...meta: any[]) { if (this.level >= LogLevel.DEBUG) { console.debug(`[DEBUG] ${message}`, ...meta); } } } ``` #### 7. Main File (`src/index.ts`) The main file becomes much simpler: ```typescript #!/usr/bin/env node import { Server } from "@modelcontextprotocol/sdk/server/index.js"; import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"; import { Config } from "./utils/config"; import { Logger, LogLevel } from "./utils/logger"; import { Cache } from "./utils/cache"; import { ERPNextClient } from "./client/erpnext-client"; import { registerResourceHandlers } from "./handlers/resource-handlers"; import { registerToolHandlers } from "./handlers/tool-handlers"; async function main() { // Initialize components const logger = new Logger( process.env.DEBUG ? LogLevel.DEBUG : LogLevel.INFO ); try { logger.info("Initializing ERPNext MCP server"); const config = new Config(); const cache = new Cache(); const erpnext = new ERPNextClient(config, logger); // Create server const server = new Server( { name: "erpnext-server", version: "0.1.0" }, { capabilities: { resources: {}, tools: {} } } ); // Register handlers registerResourceHandlers(server, erpnext, cache, logger); registerToolHandlers(server, erpnext, logger); // Setup error handling server.onerror = (error) => { logger.error("Server error:", error); }; // Start server const transport = new StdioServerTransport(); await server.connect(transport); logger.info('ERPNext MCP server running on stdio'); // Handle graceful shutdown process.on('SIGINT', async () => { logger.info("Shutting down..."); await server.close(); process.exit(0); }); } catch (error) { logger.error("Failed to start server:", error); process.exit(1); } } main(); ``` ## Implementation Plan 1. Create the directory structure 2. Move the ERPNext client to its own module 3. Create utility modules (config, logger, cache) 4. Split out the resource and tool handlers 5. Update the main file to use the new modules 6. Add tests for each module 7. Update documentation This restructuring will make the code more maintainable, easier to test, and facilitate future enhancements. ``` -------------------------------------------------------------------------------- /docs/feature-improvements.md: -------------------------------------------------------------------------------- ```markdown # Feature Improvements This document outlines potential feature improvements for the ERPNext MCP server to enhance its functionality, usability, and integration capabilities with ERPNext. ## Current Feature Set Currently, the ERPNext MCP server provides these core features: 1. **Authentication** with ERPNext using username/password or API keys 2. **Document Operations** (get, list, create, update) 3. **Report Running** 4. **DocType Information** (listing DocTypes and fields) 5. **Basic Resource Access** via URIs ## Proposed Feature Improvements ### 1. Enhanced DocType Discovery and Metadata **Current Limitation**: Basic DocType listing without metadata or relationships. **Proposed Improvements**: - Add metadata about each DocType (description, icon, module) - Include field type information and validations - Show relationships between DocTypes (links, child tables) - Provide DocType-specific operations and permissions **Implementation**: ```typescript // src/handlers/tool-handlers.ts // New tool: get_doctype_metadata export async function handleGetDoctypeMetadata(request, erpnext, logger) { const doctype = request.params.arguments?.doctype; if (!doctype) { throw new McpError(ErrorCode.InvalidParams, "DocType is required"); } try { // Get DocType metadata including fields with types const metadata = await erpnext.getDocTypeMetadata(doctype); // Get relationship information const relationships = await erpnext.getDocTypeRelationships(doctype); return { content: [{ type: "text", text: JSON.stringify({ doctype, metadata, relationships, operations: getAvailableOperations(doctype) }, null, 2) }] }; } catch (error) { return handleToolError(error, logger); } } ``` ### 2. Bulk Operations **Current Limitation**: Operations are limited to single documents. **Proposed Improvements**: - Add bulk document creation - Implement batch updates - Support bulk data import/export - Add atomic transaction support **Implementation**: ```typescript // New tool: bulk_create_documents export async function handleBulkCreateDocuments(request, erpnext, logger) { const doctype = request.params.arguments?.doctype; const documents = request.params.arguments?.documents; if (!doctype || !Array.isArray(documents)) { throw new McpError(ErrorCode.InvalidParams, "DocType and array of documents are required"); } try { // Create documents in a transaction if possible const results = await erpnext.bulkCreateDocuments(doctype, documents); return { content: [{ type: "text", text: JSON.stringify({ message: `Created ${results.length} ${doctype} documents`, results }, null, 2) }] }; } catch (error) { return handleToolError(error, logger); } } ``` ### 3. Advanced Querying **Current Limitation**: Basic filtering without complex queries. **Proposed Improvements**: - Support complex query conditions - Add sorting and grouping options - Implement flexible field selection - Add query templates for common operations **Implementation**: ```typescript // New tool: advanced_query export async function handleAdvancedQuery(request, erpnext, logger) { const doctype = request.params.arguments?.doctype; const query = request.params.arguments?.query || {}; if (!doctype) { throw new McpError(ErrorCode.InvalidParams, "DocType is required"); } try { // Transform query to ERPNext format const erpnextQuery = transformQuery(query); // Execute query const results = await erpnext.advancedQuery(doctype, erpnextQuery); return { content: [{ type: "text", text: JSON.stringify(results, null, 2) }] }; } catch (error) { return handleToolError(error, logger); } } function transformQuery(query) { // Transform from MCP query format to ERPNext query format const result = { filters: query.filters || {}, fields: query.fields || ["*"], limit: query.limit, offset: query.offset, order_by: query.orderBy, group_by: query.groupBy }; // Handle complex conditions if (query.conditions) { result.filters = buildComplexFilters(query.conditions); } return result; } ``` ### 4. Webhooks and Events **Current Limitation**: No support for event-driven operations. **Proposed Improvements**: - Add webhook registration for ERPNext events - Implement event listeners - Support callback URLs for async operations - Create notification tools **Implementation**: ```typescript // New tool: register_webhook export async function handleRegisterWebhook(request, erpnext, logger) { const doctype = request.params.arguments?.doctype; const event = request.params.arguments?.event; const callbackUrl = request.params.arguments?.callbackUrl; if (!doctype || !event || !callbackUrl) { throw new McpError(ErrorCode.InvalidParams, "DocType, event, and callbackUrl are required"); } try { // Register webhook with ERPNext const webhook = await erpnext.registerWebhook(doctype, event, callbackUrl); return { content: [{ type: "text", text: JSON.stringify({ message: `Webhook registered for ${doctype} ${event} events`, webhook }, null, 2) }] }; } catch (error) { return handleToolError(error, logger); } } ``` ### 5. Document Workflows **Current Limitation**: No workflow or process automation. **Proposed Improvements**: - Add workflow status tracking - Implement state transitions - Support approval processes - Create multi-step operations **Implementation**: ```typescript // New tool: execute_workflow_action export async function handleExecuteWorkflowAction(request, erpnext, logger) { const doctype = request.params.arguments?.doctype; const name = request.params.arguments?.name; const action = request.params.arguments?.action; const comments = request.params.arguments?.comments; if (!doctype || !name || !action) { throw new McpError(ErrorCode.InvalidParams, "DocType, document name, and action are required"); } try { // Execute workflow action const result = await erpnext.executeWorkflowAction(doctype, name, action, comments); return { content: [{ type: "text", text: JSON.stringify({ message: `Executed ${action} on ${doctype} ${name}`, result }, null, 2) }] }; } catch (error) { return handleToolError(error, logger); } } ``` ### 6. File Operations **Current Limitation**: No file handling capabilities. **Proposed Improvements**: - Add file upload/download - Support attachments to documents - Implement file metadata management - Add image processing utilities **Implementation**: ```typescript // New tool: upload_attachment export async function handleUploadAttachment(request, erpnext, logger) { const doctype = request.params.arguments?.doctype; const name = request.params.arguments?.name; const fileName = request.params.arguments?.fileName; const fileContent = request.params.arguments?.fileContent; // Base64 encoded const fileType = request.params.arguments?.fileType; if (!doctype || !name || !fileName || !fileContent) { throw new McpError(ErrorCode.InvalidParams, "DocType, document name, fileName, and fileContent are required"); } try { // Upload attachment const attachment = await erpnext.uploadAttachment( doctype, name, fileName, Buffer.from(fileContent, 'base64'), fileType ); return { content: [{ type: "text", text: JSON.stringify({ message: `Attached ${fileName} to ${doctype} ${name}`, attachment }, null, 2) }] }; } catch (error) { return handleToolError(error, logger); } } ``` ### 7. Data Synchronization **Current Limitation**: No synchronization utilities. **Proposed Improvements**: - Add data synchronization capabilities - Implement change tracking - Support incremental updates - Create data migration tools **Implementation**: ```typescript // New tool: sync_data export async function handleSyncData(request, erpnext, logger) { const doctype = request.params.arguments?.doctype; const lastSyncTime = request.params.arguments?.lastSyncTime; if (!doctype) { throw new McpError(ErrorCode.InvalidParams, "DocType is required"); } try { // Get changes since last sync const changes = await erpnext.getChangesSince(doctype, lastSyncTime); return { content: [{ type: "text", text: JSON.stringify({ message: `Retrieved ${changes.length} changes for ${doctype} since ${lastSyncTime || 'beginning'}`, currentTime: new Date().toISOString(), changes }, null, 2) }] }; } catch (error) { return handleToolError(error, logger); } } ``` ### 8. Custom Scripts and Server Actions **Current Limitation**: No support for custom scripts or actions. **Proposed Improvements**: - Add support for executing custom server scripts - Implement custom actions - Support script parameters - Create script management tools **Implementation**: ```typescript // New tool: execute_server_script export async function handleExecuteServerScript(request, erpnext, logger) { const scriptName = request.params.arguments?.scriptName; const parameters = request.params.arguments?.parameters || {}; if (!scriptName) { throw new McpError(ErrorCode.InvalidParams, "Script name is required"); } try { // Execute server script const result = await erpnext.executeServerScript(scriptName, parameters); return { content: [{ type: "text", text: JSON.stringify({ message: `Executed server script ${scriptName}`, result }, null, 2) }] }; } catch (error) { return handleToolError(error, logger); } } ``` ### 9. ERPNext Printing and PDF Generation **Current Limitation**: No document formatting or printing. **Proposed Improvements**: - Add print format rendering - Implement PDF generation - Support custom print templates - Create document export tools **Implementation**: ```typescript // New tool: generate_pdf export async function handleGeneratePdf(request, erpnext, logger) { const doctype = request.params.arguments?.doctype; const name = request.params.arguments?.name; const printFormat = request.params.arguments?.printFormat; const letterhead = request.params.arguments?.letterhead; if (!doctype || !name) { throw new McpError(ErrorCode.InvalidParams, "DocType and document name are required"); } try { // Generate PDF const pdfData = await erpnext.generatePdf(doctype, name, printFormat, letterhead); return { content: [{ type: "text", text: JSON.stringify({ message: `Generated PDF for ${doctype} ${name}`, pdf: pdfData // Base64 encoded }, null, 2) }] }; } catch (error) { return handleToolError(error, logger); } } ``` ### 10. Interactive Tools **Current Limitation**: All operations are direct API calls without user interaction. **Proposed Improvements**: - Add support for multi-step interactive operations - Implement wizard-like flows - Support form generation - Create contextual suggestions **Implementation**: ```typescript // New tool: start_interactive_flow export async function handleStartInteractiveFlow(request, erpnext, logger) { const flowType = request.params.arguments?.flowType; const initialData = request.params.arguments?.initialData || {}; if (!flowType) { throw new McpError(ErrorCode.InvalidParams, "Flow type is required"); } try { // Get flow definition const flow = getFlowDefinition(flowType); // Initialize flow state const flowState = initializeFlowState(flow, initialData); return { content: [{ type: "text", text: JSON.stringify({ message: `Started interactive flow: ${flowType}`, currentStep: flowState.currentStep, steps: flowState.steps, totalSteps: flowState.totalSteps, data: flowState.data, form: generateFormForStep(flowState.currentStep, flow, flowState.data) }, null, 2) }] }; } catch (error) { return handleToolError(error, logger); } } ``` ## Implementation Strategy To implement these feature improvements, we recommend: 1. **Prioritization**: Focus on features that provide the most value with the least implementation complexity first: - Enhanced DocType discovery and metadata - Advanced querying - File operations 2. **Phased Implementation**: - Phase 1: Metadata and querying enhancements - Phase 2: Bulk operations and file handling - Phase 3: Workflows and synchronization - Phase 4: Interactive tools and custom scripts 3. **Dependency Handling**: - Identify ERPNext API dependencies for each feature - Document required permissions and configuration - Handle version compatibility 4. **Documentation and Examples**: - Provide detailed documentation for each new feature - Include usage examples - Create tutorials for complex features ## Conclusion 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. ``` -------------------------------------------------------------------------------- /docs/error-handling-and-logging.md: -------------------------------------------------------------------------------- ```markdown # Error Handling and Logging Improvements This document outlines recommendations for improving error handling and logging in the ERPNext MCP server. ## Current Status The current implementation has several limitations in its error handling and logging approach: 1. Basic error handling with inconsistent patterns 2. Console logging without structured format 3. No distinct log levels for different environments 4. No dedicated error mapping between ERPNext and MCP error codes 5. Limited contextual information in error messages ## Proposed Improvements ### 1. Structured Error Handling #### Create a dedicated Error Handling Module ```typescript // src/utils/error-handler.ts import { McpError, ErrorCode } from "@modelcontextprotocol/sdk/types.js"; import { AxiosError } from "axios"; import { Logger } from "./logger"; export enum ERPNextErrorType { Authentication = "authentication", Permission = "permission", NotFound = "not_found", Validation = "validation", Server = "server", Network = "network", Unknown = "unknown" } export class ERPNextError extends Error { constructor( public readonly type: ERPNextErrorType, message: string, public readonly originalError?: Error ) { super(message); this.name = "ERPNextError"; } } /** * Maps ERPNext errors to MCP error codes */ export function mapToMcpError(error: ERPNextError): McpError { switch (error.type) { case ERPNextErrorType.Authentication: return new McpError(ErrorCode.Unauthorized, error.message); case ERPNextErrorType.Permission: return new McpError(ErrorCode.Forbidden, error.message); case ERPNextErrorType.NotFound: return new McpError(ErrorCode.NotFound, error.message); case ERPNextErrorType.Validation: return new McpError(ErrorCode.InvalidParams, error.message); case ERPNextErrorType.Server: return new McpError(ErrorCode.InternalError, error.message); case ERPNextErrorType.Network: return new McpError(ErrorCode.InternalError, `Network error: ${error.message}`); default: return new McpError(ErrorCode.InternalError, `Unknown error: ${error.message}`); } } /** * Categorizes an error based on its properties and returns an ERPNextError */ export function categorizeError(error: any): ERPNextError { if (error instanceof ERPNextError) { return error; } // Handle Axios errors if (error?.isAxiosError) { const axiosError = error as AxiosError; const statusCode = axiosError.response?.status; const responseData = axiosError.response?.data as any; const message = responseData?.message || responseData?.error || axiosError.message; if (!statusCode) { return new ERPNextError(ERPNextErrorType.Network, `Network error connecting to ERPNext: ${message}`, error); } switch (statusCode) { case 401: return new ERPNextError(ERPNextErrorType.Authentication, `Authentication failed: ${message}`, error); case 403: return new ERPNextError(ERPNextErrorType.Permission, `Permission denied: ${message}`, error); case 404: return new ERPNextError(ERPNextErrorType.NotFound, `Resource not found: ${message}`, error); case 400: return new ERPNextError(ERPNextErrorType.Validation, `Validation error: ${message}`, error); case 500: case 502: case 503: case 504: return new ERPNextError(ERPNextErrorType.Server, `ERPNext server error: ${message}`, error); default: return new ERPNextError(ERPNextErrorType.Unknown, `Unknown error: ${message}`, error); } } // Handle generic errors return new ERPNextError( ERPNextErrorType.Unknown, error?.message || 'An unknown error occurred', error instanceof Error ? error : undefined ); } /** * Handles errors in tool handlers, returning appropriate response format */ export function handleToolError(error: any, logger: Logger) { const erpError = categorizeError(error); // Log the error with appropriate context if (erpError.originalError) { logger.error(`${erpError.message}`, { errorType: erpError.type, originalError: erpError.originalError.message, stack: erpError.originalError.stack }); } else { logger.error(`${erpError.message}`, { errorType: erpError.type, stack: erpError.stack }); } // Return a formatted error response return { content: [{ type: "text", text: erpError.message }], isError: true }; } /** * Creates a correlation ID for tracking requests through the system */ export function createCorrelationId(): string { return `mcp-${Date.now()}-${Math.random().toString(36).substring(2, 7)}`; } ``` ### 2. Enhanced Logging System #### Structured Logger Implementation ```typescript // src/utils/logger.ts export enum LogLevel { ERROR = 0, WARN = 1, INFO = 2, DEBUG = 3 } export interface LogMetadata { [key: string]: any; } export interface LogEntry { timestamp: string; level: string; message: string; correlationId?: string; metadata?: LogMetadata; } export class Logger { private level: LogLevel; private serviceName: string; constructor(level: LogLevel = LogLevel.INFO, serviceName: string = "erpnext-mcp") { this.level = level; this.serviceName = serviceName; } setLevel(level: LogLevel) { this.level = level; } private formatLog(level: string, message: string, metadata?: LogMetadata): LogEntry { return { timestamp: new Date().toISOString(), level, message, correlationId: metadata?.correlationId, metadata: metadata ? { ...metadata, service: this.serviceName } : { service: this.serviceName } }; } private outputLog(logEntry: LogEntry) { // In production, you might want to use a more sophisticated logging system // For now, we'll use console with JSON formatting const logJson = JSON.stringify(logEntry); switch (logEntry.level) { case 'ERROR': console.error(logJson); break; case 'WARN': console.warn(logJson); break; case 'INFO': console.info(logJson); break; case 'DEBUG': console.debug(logJson); break; default: console.log(logJson); } } error(message: string, metadata?: LogMetadata) { if (this.level >= LogLevel.ERROR) { this.outputLog(this.formatLog('ERROR', message, metadata)); } } warn(message: string, metadata?: LogMetadata) { if (this.level >= LogLevel.WARN) { this.outputLog(this.formatLog('WARN', message, metadata)); } } info(message: string, metadata?: LogMetadata) { if (this.level >= LogLevel.INFO) { this.outputLog(this.formatLog('INFO', message, metadata)); } } debug(message: string, metadata?: LogMetadata) { if (this.level >= LogLevel.DEBUG) { this.outputLog(this.formatLog('DEBUG', message, metadata)); } } } ``` ### 3. Request Context and Correlation To track requests through the system: ```typescript // src/middleware/context.ts import { createCorrelationId } from "../utils/error-handler"; export interface RequestContext { correlationId: string; startTime: number; [key: string]: any; } export class RequestContextManager { private static contextMap = new Map<string, RequestContext>(); static createContext(requestId: string): RequestContext { const context: RequestContext = { correlationId: createCorrelationId(), startTime: Date.now() }; this.contextMap.set(requestId, context); return context; } static getContext(requestId: string): RequestContext | undefined { return this.contextMap.get(requestId); } static removeContext(requestId: string): void { this.contextMap.delete(requestId); } } ``` ### 4. Integration with MCP Server Use the error handling and logging systems with the MCP server: ```typescript // src/index.ts import { Server } from "@modelcontextprotocol/sdk/server/index.js"; import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"; import { Config } from "./utils/config"; import { Logger, LogLevel } from "./utils/logger"; import { RequestContextManager } from "./middleware/context"; async function main() { // Initialize logger const logger = new Logger( process.env.DEBUG ? LogLevel.DEBUG : LogLevel.INFO ); try { logger.info("Initializing ERPNext MCP server"); // Create server const server = new Server( { name: "erpnext-server", version: "0.1.0" }, { capabilities: { resources: {}, tools: {} } } ); // Set up request interceptors for correlation and context server.onRequest = (request) => { const context = RequestContextManager.createContext(request.id); logger.debug(`Received request: ${request.method}`, { correlationId: context.correlationId, request: { id: request.id, method: request.method, params: JSON.stringify(request.params) } }); }; // Set up response interceptors for timing and cleanup server.onResponse = (response) => { const context = RequestContextManager.getContext(response.id); if (context) { const duration = Date.now() - context.startTime; logger.debug(`Sending response`, { correlationId: context.correlationId, response: { id: response.id, duration: `${duration}ms`, hasError: !!response.error } }); // Clean up context RequestContextManager.removeContext(response.id); } }; // Set up error handler for server errors server.onerror = (error) => { logger.error("Server error", { error: error?.message, stack: error?.stack }); }; // Connect server with stdio transport const transport = new StdioServerTransport(); await server.connect(transport); logger.info('ERPNext MCP server running on stdio'); } catch (error) { logger.error("Failed to start server", { error: error?.message, stack: error?.stack }); process.exit(1); } } main(); ``` ### 5. Integrating with Client Update the ERPNext client to use the enhanced error handling: ```typescript // src/client/erpnext-client.ts import axios, { AxiosInstance } from "axios"; import { Logger } from "../utils/logger"; import { Config } from "../utils/config"; import { categorizeError, ERPNextError, ERPNextErrorType } from "../utils/error-handler"; export class ERPNextClient { private baseUrl: string; private axiosInstance: AxiosInstance; private authenticated: boolean = false; private logger: Logger; constructor(config: Config, logger: Logger) { this.logger = logger; this.baseUrl = config.getERPNextUrl(); // Initialize axios instance this.axiosInstance = axios.create({ baseURL: this.baseUrl, withCredentials: true, headers: { 'Content-Type': 'application/json', 'Accept': 'application/json' } }); // Add request interceptor for logging this.axiosInstance.interceptors.request.use( (config) => { this.logger.debug(`Sending ${config.method?.toUpperCase()} request to ${config.url}`, { api: { method: config.method, url: config.url, hasData: !!config.data, hasParams: !!config.params } }); return config; }, (error) => { this.logger.error(`Request error: ${error.message}`); return Promise.reject(error); } ); // Add response interceptor for error handling this.axiosInstance.interceptors.response.use( (response) => { return response; }, (error) => { const erpError = categorizeError(error); this.logger.error(`API error: ${erpError.message}`, { errorType: erpError.type, statusCode: error.response?.status, data: error.response?.data }); return Promise.reject(erpError); } ); // Configure authentication if credentials provided const apiKey = config.getERPNextApiKey(); const apiSecret = config.getERPNextApiSecret(); if (apiKey && apiSecret) { this.axiosInstance.defaults.headers.common['Authorization'] = `token ${apiKey}:${apiSecret}`; this.authenticated = true; this.logger.info("Initialized with API key authentication"); } } // Client methods would use the same error handling pattern... async login(username: string, password: string): Promise<void> { try { this.logger.info(`Attempting login for user ${username}`); const response = await this.axiosInstance.post('/api/method/login', { usr: username, pwd: password }); if (response.data.message === 'Logged In') { this.authenticated = true; this.logger.info(`Successfully authenticated user ${username}`); } else { throw new ERPNextError( ERPNextErrorType.Authentication, "Login response did not confirm successful authentication" ); } } catch (error) { // Let the interceptor handle the error categorization this.authenticated = false; throw error; } } // Other methods would follow the same pattern... } ``` ## Benefits of Improved Error Handling and Logging 1. **Better Diagnostics**: Structured logging with correlation IDs makes it easier to track requests through the system and diagnose issues. 2. **Consistent Error Responses**: Standardized error handling ensures that clients receive consistent and informative error messages. 3. **Categorized Errors**: Errors are properly categorized, making it easier to handle different types of failures appropriately. 4. **Contextual Information**: Additional metadata is included with logs, providing more context for troubleshooting. 5. **Performance Tracking**: Request timing information helps identify performance bottlenecks. 6. **Environment-specific Logging**: Different log levels can be used in development vs. production environments. ## Implementation Plan 1. Create the error handling utility module 2. Implement the structured logger 3. Update the client to use the enhanced error handling 4. Update resource and tool handlers to leverage the new error system 5. Add request context management 6. Update the main server to integrate all components These changes will significantly improve the reliability and maintainability of the ERPNext MCP server by making errors more traceable and logs more informative. ``` -------------------------------------------------------------------------------- /docs/testing-strategy.md: -------------------------------------------------------------------------------- ```markdown # Testing Strategy This document outlines a comprehensive testing strategy for the ERPNext MCP server to ensure reliability, correctness, and maintainability. ## Current Status The current implementation lacks any automated testing, which poses several risks: - No way to verify that changes don't break existing functionality - Difficulty in detecting regressions - Challenges in maintaining code quality as the codebase grows - Decreased confidence when making changes or adding features ## Proposed Testing Structure ### Directory Structure ``` tests/ ├── unit/ │ ├── client/ │ │ └── erpnext-client.test.ts │ ├── handlers/ │ │ ├── resource-handlers.test.ts │ │ └── tool-handlers.test.ts │ └── utils/ │ ├── cache.test.ts │ ├── config.test.ts │ └── logger.test.ts ├── integration/ │ └── api-integration.test.ts ├── e2e/ │ └── full-workflow.test.ts └── mocks/ ├── erpnext-api.ts └── test-data.ts ``` ## Testing Levels ### 1. Unit Tests Unit tests should focus on testing individual components in isolation, mocking external dependencies. These tests should be fast and focused. #### Example: Testing the Cache Utility ```typescript // tests/unit/utils/cache.test.ts import { Cache } from '../../../src/utils/cache'; import { jest } from '@jest/globals'; describe('Cache', () => { let cache: Cache; beforeEach(() => { cache = new Cache(); jest.useFakeTimers(); }); afterEach(() => { jest.useRealTimers(); }); test('should store and retrieve values', () => { // Arrange const key = 'test-key'; const value = { data: 'test-data' }; // Act cache.set(key, value); const result = cache.get(key); // Assert expect(result).toEqual(value); }); test('should expire values after TTL', () => { // Arrange const key = 'test-key'; const value = { data: 'test-data' }; const ttl = 1000; // 1 second // Act cache.set(key, value, ttl); // Move time forward jest.advanceTimersByTime(1001); const result = cache.get(key); // Assert expect(result).toBeUndefined(); }); test('should invalidate keys with prefix', () => { // Arrange cache.set('prefix:key1', 'value1'); cache.set('prefix:key2', 'value2'); cache.set('other:key', 'value3'); // Act cache.invalidate('prefix:'); // Assert expect(cache.get('prefix:key1')).toBeUndefined(); expect(cache.get('prefix:key2')).toBeUndefined(); expect(cache.get('other:key')).toBe('value3'); }); }); ``` #### Example: Testing the ERPNext Client ```typescript // tests/unit/client/erpnext-client.test.ts import { ERPNextClient } from '../../../src/client/erpnext-client'; import { Config } from '../../../src/utils/config'; import { Logger } from '../../../src/utils/logger'; import axios from 'axios'; import { jest } from '@jest/globals'; // Mock axios jest.mock('axios'); const mockedAxios = axios as jest.Mocked<typeof axios>; describe('ERPNextClient', () => { let client: ERPNextClient; let mockConfig: jest.Mocked<Config>; let mockLogger: jest.Mocked<Logger>; beforeEach(() => { // Setup mock config mockConfig = { getERPNextUrl: jest.fn().mockReturnValue('https://test-erpnext.com'), getERPNextApiKey: jest.fn().mockReturnValue('test-key'), getERPNextApiSecret: jest.fn().mockReturnValue('test-secret') } as unknown as jest.Mocked<Config>; // Setup mock logger mockLogger = { info: jest.fn(), error: jest.fn(), debug: jest.fn(), warn: jest.fn() } as unknown as jest.Mocked<Logger>; // Setup axios mock mockedAxios.create.mockReturnValue({ defaults: { headers: { common: {} } }, get: jest.fn(), post: jest.fn(), put: jest.fn() } as any); // Create client instance client = new ERPNextClient(mockConfig, mockLogger); }); test('should initialize with API key authentication', () => { expect(mockLogger.info).toHaveBeenCalledWith( expect.stringContaining('API key authentication') ); expect(client.isAuthenticated()).toBe(true); }); test('login should authenticate the client', async () => { // Setup mock response const axiosInstance = mockedAxios.create.mock.results[0].value; axiosInstance.post.mockResolvedValue({ data: { message: 'Logged In' } }); // Act await client.login('testuser', 'testpass'); // Assert expect(axiosInstance.post).toHaveBeenCalledWith( '/api/method/login', { usr: 'testuser', pwd: 'testpass' } ); expect(client.isAuthenticated()).toBe(true); }); test('login should handle authentication failure', async () => { // Setup mock response for failure const axiosInstance = mockedAxios.create.mock.results[0].value; axiosInstance.post.mockRejectedValue(new Error('Authentication failed')); // Act & Assert await expect(client.login('testuser', 'wrongpass')).rejects.toThrow('Authentication failed'); expect(client.isAuthenticated()).toBe(false); }); }); ``` ### 2. Integration Tests 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. ```typescript // tests/integration/api-integration.test.ts import { Server } from "@modelcontextprotocol/sdk/server/index.js"; import { MemoryTransport } from "@modelcontextprotocol/sdk/server/memory.js"; import { ERPNextClient } from '../../src/client/erpnext-client'; import { registerResourceHandlers } from '../../src/handlers/resource-handlers'; import { registerToolHandlers } from '../../src/handlers/tool-handlers'; import { Logger } from '../../src/utils/logger'; import { Cache } from '../../src/utils/cache'; import { jest } from '@jest/globals'; // Use mock ERPNext client jest.mock('../../src/client/erpnext-client'); describe('MCP Server Integration', () => { let server: Server; let transport: MemoryTransport; let mockErpnextClient: jest.Mocked<ERPNextClient>; let logger: Logger; let cache: Cache; beforeEach(async () => { // Setup mocks mockErpnextClient = { isAuthenticated: jest.fn().mockReturnValue(true), login: jest.fn(), getDocument: jest.fn(), getDocList: jest.fn(), createDocument: jest.fn(), updateDocument: jest.fn(), runReport: jest.fn(), getAllDocTypes: jest.fn() } as unknown as jest.Mocked<ERPNextClient>; logger = new Logger(); cache = new Cache(); // Create server server = new Server( { name: "erpnext-server-test", version: "0.1.0" }, { capabilities: { resources: {}, tools: {} } } ); // Register handlers registerResourceHandlers(server, mockErpnextClient, cache, logger); registerToolHandlers(server, mockErpnextClient, logger); // Setup transport transport = new MemoryTransport(); await server.connect(transport); }); afterEach(async () => { await server.close(); }); test('List tools should return all tools', async () => { // Act const response = await transport.sendRequestAndWaitForResponse({ jsonrpc: "2.0", id: "test-id", method: "mcp.listTools", params: {} }); // Assert expect(response.result).toBeDefined(); expect(response.result.tools).toBeInstanceOf(Array); expect(response.result.tools.length).toBeGreaterThan(0); expect(response.result.tools.map(t => t.name)).toContain('authenticate_erpnext'); }); test('Get documents tool should fetch documents', async () => { // Arrange const mockDocuments = [ { name: "CUST-001", customer_name: "Test Customer" } ]; mockErpnextClient.getDocList.mockResolvedValue(mockDocuments); // Act const response = await transport.sendRequestAndWaitForResponse({ jsonrpc: "2.0", id: "test-id", method: "mcp.callTool", params: { name: "get_documents", arguments: { doctype: "Customer" } } }); // Assert expect(mockErpnextClient.getDocList).toHaveBeenCalledWith( "Customer", undefined, undefined, undefined ); expect(response.result).toBeDefined(); expect(JSON.parse(response.result.content[0].text)).toEqual(mockDocuments); }); }); ``` ### 3. End-to-End Tests 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. ```typescript // tests/e2e/full-workflow.test.ts import { spawn, ChildProcess } from 'child_process'; import axios from 'axios'; import { jest } from '@jest/globals'; import path from 'path'; import { startMockErpnextServer, stopMockErpnextServer } from '../mocks/erpnext-api'; describe('E2E Tests', () => { let serverProcess: ChildProcess; let mockServerUrl: string; beforeAll(async () => { // Start mock ERPNext server mockServerUrl = await startMockErpnextServer(); // Start MCP server with environment pointing to mock server const serverPath = path.resolve(__dirname, '../../build/index.js'); serverProcess = spawn('node', [serverPath], { env: { ...process.env, ERPNEXT_URL: mockServerUrl, ERPNEXT_API_KEY: 'test-key', ERPNEXT_API_SECRET: 'test-secret', DEBUG: 'true' }, stdio: ['pipe', 'pipe', 'pipe'] }); // Wait for server to start await new Promise(resolve => setTimeout(resolve, 1000)); }); afterAll(async () => { // Terminate the server process if (serverProcess) { serverProcess.kill(); } // Stop mock server await stopMockErpnextServer(); }); test('Complete workflow test', async () => { // Setup a test client that communicates with the MCP server // This would test a complete workflow: // 1. Authentication // 2. Document retrieval // 3. Document creation // 4. Document update // 5. Running a report }); }); ``` ## Test Mocks Creating proper mocks is essential for effective testing: ```typescript // tests/mocks/erpnext-api.ts import express from 'express'; import http from 'http'; import bodyParser from 'body-parser'; let server: http.Server; let app: express.Express; // Mock data storage const mockData: Record<string, any[]> = { 'Customer': [ { name: 'CUST-001', customer_name: 'Test Customer 1', customer_type: 'Company', customer_group: 'Commercial', territory: 'United States' } ], 'Item': [ { name: 'ITEM-001', item_code: 'ITEM-001', item_name: 'Test Item 1', item_group: 'Products', stock_uom: 'Nos' } ] }; export async function startMockErpnextServer(): Promise<string> { app = express(); app.use(bodyParser.json()); // Setup API endpoints that mimic ERPNext // Login endpoint app.post('/api/method/login', (req, res) => { const { usr, pwd } = req.body; if (usr === 'testuser' && pwd === 'testpass') { res.json({ message: 'Logged In' }); } else { res.status(401).json({ message: 'Authentication failed' }); } }); // Document listing app.get('/api/resource/:doctype', (req, res) => { const doctype = req.params.doctype; res.json({ data: mockData[doctype] || [] }); }); // Document retrieval app.get('/api/resource/:doctype/:name', (req, res) => { const { doctype, name } = req.params; const docs = mockData[doctype] || []; const doc = docs.find(d => d.name === name); if (doc) { res.json({ data: doc }); } else { res.status(404).json({ message: 'Not found' }); } }); // Document creation app.post('/api/resource/:doctype', (req, res) => { const { doctype } = req.params; const data = req.body.data; if (!mockData[doctype]) { mockData[doctype] = []; } mockData[doctype].push(data); res.json({ data }); }); // Document update app.put('/api/resource/:doctype/:name', (req, res) => { const { doctype, name } = req.params; const data = req.body.data; if (!mockData[doctype]) { return res.status(404).json({ message: 'DocType not found' }); } const index = mockData[doctype].findIndex(d => d.name === name); if (index === -1) { return res.status(404).json({ message: 'Document not found' }); } mockData[doctype][index] = { ...mockData[doctype][index], ...data }; res.json({ data: mockData[doctype][index] }); }); // Start server on a random port return new Promise<string>((resolve) => { server = app.listen(0, () => { const address = server.address(); const port = typeof address === 'object' ? address?.port : 0; resolve(`http://localhost:${port}`); }); }); } export async function stopMockErpnextServer(): Promise<void> { return new Promise<void>((resolve) => { if (server) { server.close(() => resolve()); } else { resolve(); } }); } ``` ## Setting Up Testing Infrastructure ### 1. Dependencies Add the following development dependencies to `package.json`: ```json "devDependencies": { "@types/jest": "^29.5.0", "@types/express": "^4.17.17", "body-parser": "^1.20.2", "express": "^4.18.2", "jest": "^29.5.0", "ts-jest": "^29.1.0", "@types/node": "^20.11.24", "typescript": "^5.3.3" } ``` ### 2. Jest Configuration Add Jest configuration in `package.json`: ```json "jest": { "preset": "ts-jest", "testEnvironment": "node", "roots": [ "<rootDir>/tests" ], "collectCoverage": true, "collectCoverageFrom": [ "src/**/*.ts" ], "coverageThreshold": { "global": { "branches": 80, "functions": 80, "lines": 80, "statements": 80 } } } ``` ### 3. Add Test Scripts Add test scripts to `package.json`: ```json "scripts": { "test": "jest", "test:watch": "jest --watch", "test:coverage": "jest --coverage", "test:unit": "jest tests/unit", "test:integration": "jest tests/integration", "test:e2e": "jest tests/e2e" } ``` ## CI/CD Integration Implement continuous integration using GitHub Actions or similar CI platform: ```yaml # .github/workflows/ci.yml name: CI on: push: branches: [ main ] pull_request: branches: [ main ] jobs: test: runs-on: ubuntu-latest steps: - uses: actions/checkout@v3 - name: Setup Node.js uses: actions/setup-node@v3 with: node-version: '18' - name: Install dependencies run: npm ci - name: Run tests run: npm test - name: Upload coverage uses: codecov/codecov-action@v3 ``` ## Testing Best Practices 1. **Write tests before code** - Consider test-driven development (TDD) for new features 2. **Test edge cases** - Ensure error scenarios and unusual inputs are handled correctly 3. **Keep tests independent** - Each test should run in isolation 4. **Use descriptive test names** - Tests should document what functionality is being verified 5. **Mock external dependencies** - Don't rely on external services in unit tests 6. **Aim for high coverage** - But focus on meaningful coverage rather than arbitrary metrics 7. **Maintain tests** - Update tests when functionality changes 8. **Run tests regularly** - Integrate in CI/CD pipeline and run locally before commits ## Conclusion 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. ``` -------------------------------------------------------------------------------- /docs/security-and-authentication.md: -------------------------------------------------------------------------------- ```markdown # Security and Authentication Improvements This document outlines recommendations for enhancing security and authentication in the ERPNext MCP server. ## Current Status The current implementation has several limitations in its security and authentication approach: 1. Simple username/password authentication without token refresh mechanisms 2. Basic API key/secret handling without proper validation 3. Passwords transmitted and handled in plain text 4. No input validation or sanitization 5. No token/session management 6. Limited security headers 7. No protection against common security vulnerabilities ## Proposed Improvements ### 1. Enhanced Authentication System #### Robust Authentication Mechanisms ```typescript // src/auth/authenticator.ts import { Logger } from "../utils/logger"; import { Config } from "../utils/config"; import { ERPNextClient } from "../client/erpnext-client"; import { ERPNextError, ERPNextErrorType } from "../utils/error-handler"; export interface AuthTokens { accessToken: string; refreshToken?: string; expiresAt?: number; } export class AuthManager { private tokens: AuthTokens | null = null; private authenticated: boolean = false; private refreshTimer: NodeJS.Timeout | null = null; private client: ERPNextClient; private logger: Logger; constructor( client: ERPNextClient, logger: Logger, private readonly config: Config ) { this.client = client; this.logger = logger; // Initialize with API key/secret if available const apiKey = this.config.getERPNextApiKey(); const apiSecret = this.config.getERPNextApiSecret(); if (apiKey && apiSecret) { this.authenticated = true; this.logger.info("Initialized with API key authentication"); } } /** * Check if the client is authenticated */ isAuthenticated(): boolean { return this.authenticated; } /** * Authenticate using username and password */ async authenticate(username: string, password: string): Promise<void> { try { if (!username || !password) { throw new ERPNextError( ERPNextErrorType.Authentication, "Username and password are required" ); } // Mask password in logs this.logger.info(`Attempting to authenticate user: ${username}`); // Authenticate with ERPNext await this.client.login(username, password); this.authenticated = true; this.logger.info(`Authentication successful for user: ${username}`); // In a real implementation, we would store tokens and set up refresh // ERPNext doesn't have a standard token-based auth, but we're // setting up the structure for future enhancement } catch (error) { this.authenticated = false; this.logger.error(`Authentication failed for user: ${username}`); throw error; } } /** * Set up token refresh mechanism * This is a placeholder for future enhancement as ERPNext's * standard API doesn't use refresh tokens, but the structure * is here for custom implementations or future changes */ private setupTokenRefresh(tokens: AuthTokens): void { // Clear any existing refresh timer if (this.refreshTimer) { clearTimeout(this.refreshTimer); } // If no expiry, don't set up refresh if (!tokens.expiresAt || !tokens.refreshToken) { return; } // Calculate time until refresh (5 minutes before expiry) const now = Date.now(); const expiryTime = tokens.expiresAt; const timeUntilRefresh = Math.max(0, expiryTime - now - 5 * 60 * 1000); this.logger.debug(`Setting up token refresh in ${timeUntilRefresh / 1000} seconds`); this.refreshTimer = setTimeout(async () => { try { // Here we would implement the token refresh logic this.logger.debug("Refreshing authentication token"); // For future implementation: // const newTokens = await this.client.refreshToken(tokens.refreshToken); // this.setTokens(newTokens); } catch (error) { this.logger.error("Failed to refresh token", { error }); this.authenticated = false; } }, timeUntilRefresh); } /** * Store authentication tokens */ private setTokens(tokens: AuthTokens): void { this.tokens = tokens; this.authenticated = true; // Set up token refresh if applicable this.setupTokenRefresh(tokens); } /** * Get current auth token for API requests */ getAuthToken(): string | null { return this.tokens?.accessToken || null; } /** * Log out and clear authentication */ logout(): void { this.authenticated = false; this.tokens = null; if (this.refreshTimer) { clearTimeout(this.refreshTimer); this.refreshTimer = null; } this.logger.info("User logged out"); } } ``` ### 2. Input Validation and Sanitization Create a dedicated validation module to ensure inputs are properly validated before being used: ```typescript // src/utils/validation.ts import { z } from "zod"; // Using Zod for schema validation // Schema for authentication credentials export const AuthCredentialsSchema = z.object({ username: z.string().min(1, "Username is required"), password: z.string().min(1, "Password is required") }); // Schema for doctype export const DoctypeSchema = z.string().min(1, "DocType is required"); // Schema for document name export const DocumentNameSchema = z.string().min(1, "Document name is required"); // Schema for document data export const DocumentDataSchema = z.record(z.unknown()); // Schema for document filters export const FiltersSchema = z.record(z.unknown()).optional(); // Schema for field list export const FieldsSchema = z.array(z.string()).optional(); // Schema for pagination export const PaginationSchema = z.object({ limit: z.number().positive().optional(), page: z.number().positive().optional() }); // Schema for report export const ReportSchema = z.object({ report_name: z.string().min(1, "Report name is required"), filters: z.record(z.unknown()).optional() }); /** * Validates input against a schema and returns the validated data * or throws an error if validation fails */ export function validateInput<T>(schema: z.Schema<T>, data: unknown): T { try { return schema.parse(data); } catch (error) { if (error instanceof z.ZodError) { // Convert zod error to a more user-friendly format const issues = error.errors.map(err => `${err.path.join('.')}: ${err.message}`).join(", "); throw new Error(`Validation error: ${issues}`); } throw error; } } /** * Sanitizes string input to prevent injection attacks */ export function sanitizeString(input: string): string { // Basic sanitization to prevent script injection return input .replace(/</g, '<') .replace(/>/g, '>') .replace(/"/g, '"') .replace(/'/g, '''); } /** * Sanitizes an object by applying string sanitization to all string properties */ export function sanitizeObject(obj: Record<string, any>): Record<string, any> { const result: Record<string, any> = {}; Object.entries(obj).forEach(([key, value]) => { if (typeof value === 'string') { result[key] = sanitizeString(value); } else if (value && typeof value === 'object' && !Array.isArray(value)) { result[key] = sanitizeObject(value); } else { result[key] = value; } }); return result; } ``` ### 3. Secure Configuration Management Enhance the configuration module with better security practices: ```typescript // src/utils/config.ts import fs from 'fs'; import path from 'path'; import dotenv from 'dotenv'; export class Config { private config: Record<string, string> = {}; constructor(configPath?: string) { // Load environment variables from .env file if it exists if (configPath && fs.existsSync(configPath)) { const envConfig = dotenv.parse(fs.readFileSync(configPath)); this.config = { ...this.config, ...envConfig }; } // Override with actual environment variables Object.assign(this.config, process.env); // Validate required configuration this.validateConfig(); } private validateConfig() { // Validate required configuration const requiredVars = ['ERPNEXT_URL']; const missing = requiredVars.filter(key => !this.get(key)); if (missing.length > 0) { throw new Error(`Missing required configuration: ${missing.join(', ')}`); } // Validate URL format try { new URL(this.getERPNextUrl()); } catch (error) { throw new Error(`Invalid ERPNEXT_URL: ${this.getERPNextUrl()}`); } // Validate API key and secret (both or neither) const hasApiKey = !!this.get('ERPNEXT_API_KEY'); const hasApiSecret = !!this.get('ERPNEXT_API_SECRET'); if ((hasApiKey && !hasApiSecret) || (!hasApiKey && hasApiSecret)) { throw new Error('Both ERPNEXT_API_KEY and ERPNEXT_API_SECRET must be provided if using API key authentication'); } } /** * Get a configuration value */ get(key: string): string | undefined { return this.config[key]; } /** * Get a configuration value or throw if not found */ getRequired(key: string): string { const value = this.get(key); if (value === undefined) { throw new Error(`Required configuration "${key}" not found`); } return value; } /** * Get ERPNext URL, ensuring it doesn't end with a trailing slash */ getERPNextUrl(): string { return this.getRequired('ERPNEXT_URL').replace(/\/$/, ''); } /** * Get ERPNext API key */ getERPNextApiKey(): string | undefined { return this.get('ERPNEXT_API_KEY'); } /** * Get ERPNext API secret */ getERPNextApiSecret(): string | undefined { return this.get('ERPNEXT_API_SECRET'); } /** * Get log level (defaults to "info") */ getLogLevel(): string { return this.get('LOG_LEVEL') || 'info'; } } ``` ### 4. Security Middleware Add security middleware to protect against common vulnerabilities: ```typescript // src/middleware/security.ts import { Logger } from "../utils/logger"; export interface RateLimitConfig { windowMs: number; maxRequests: number; } /** * Simple rate limiting implementation for tools */ export class RateLimiter { private windowMs: number; private maxRequests: number; private requests: Map<string, number[]> = new Map(); private logger: Logger; constructor(config: RateLimitConfig, logger: Logger) { this.windowMs = config.windowMs; this.maxRequests = config.maxRequests; this.logger = logger; } /** * Check if a request should be rate limited * @param key Identifier for the rate limit bucket (e.g. tool name, user ID) * @returns Whether the request should be allowed */ allowRequest(key: string): boolean { const now = Date.now(); // Get existing timestamps for this key let timestamps = this.requests.get(key) || []; // Filter out timestamps outside the current window timestamps = timestamps.filter(time => now - time < this.windowMs); // Check if we've exceeded the limit if (timestamps.length >= this.maxRequests) { this.logger.warn(`Rate limit exceeded for ${key}`); return false; } // Add the new timestamp and update the map timestamps.push(now); this.requests.set(key, timestamps); return true; } /** * Reset rate limit for a specific key */ reset(key: string): void { this.requests.delete(key); } } /** * Security headers to add to responses */ export const securityHeaders = { 'X-Content-Type-Options': 'nosniff', 'X-Frame-Options': 'DENY', 'Content-Security-Policy': "default-src 'none'", 'X-XSS-Protection': '1; mode=block', 'Strict-Transport-Security': 'max-age=31536000; includeSubDomains' }; ``` ### 5. Secure Resource and Tool Handlers Update the tool handlers to include validation, rate limiting, and better credential handling: ```typescript // src/handlers/tool-handlers.ts import { Server } from "@modelcontextprotocol/sdk/server/index.js"; import { CallToolRequestSchema, ListToolsRequestSchema, McpError, ErrorCode } from "@modelcontextprotocol/sdk/types.js"; import { ERPNextClient } from "../client/erpnext-client"; import { AuthManager } from "../auth/authenticator"; import { Logger } from "../utils/logger"; import { handleToolError } from "../utils/error-handler"; import { RateLimiter } from "../middleware/security"; import * as validation from "../utils/validation"; import { RequestContextManager } from "../middleware/context"; export function registerToolHandlers( server: Server, erpnext: ERPNextClient, auth: AuthManager, logger: Logger ) { // Create rate limiter for authentication attempts const authRateLimiter = new RateLimiter({ windowMs: 60 * 1000, // 1 minute maxRequests: 5 // 5 attempts per minute }, logger); // Create rate limiter for other operations const toolRateLimiter = new RateLimiter({ windowMs: 60 * 1000, // 1 minute maxRequests: 30 // 30 requests per minute }, logger); // Handler for listing tools server.setRequestHandler(ListToolsRequestSchema, async () => { logger.debug("Handling ListToolsRequest"); return { tools: [ { name: "get_doctypes", description: "Get a list of all available DocTypes", inputSchema: { type: "object", properties: {} } }, { name: "authenticate_erpnext", description: "Authenticate with ERPNext using username and password", inputSchema: { type: "object", properties: { username: { type: "string", description: "ERPNext username" }, password: { type: "string", description: "ERPNext password" } }, required: ["username", "password"] } }, // Other tools... ] }; }); // Handler for tool calls server.setRequestHandler(CallToolRequestSchema, async (request) => { const requestId = request.id; const context = RequestContextManager.getContext(requestId); const correlationId = context?.correlationId; logger.debug(`Handling CallToolRequest: ${request.params.name}`, { correlationId, tool: request.params.name }); try { // Apply rate limiting based on tool const toolName = request.params.name; if (toolName === 'authenticate_erpnext') { // Use stricter rate limiting for authentication if (!authRateLimiter.allowRequest(toolName)) { throw new McpError( ErrorCode.TooManyRequests, "Too many authentication attempts. Please try again later." ); } } else { // Use standard rate limiting for other tools if (!toolRateLimiter.allowRequest(toolName)) { throw new McpError( ErrorCode.TooManyRequests, "Too many requests. Please try again later." ); } } // Handle specific tool requests with proper validation switch (toolName) { case "authenticate_erpnext": { const credentials = validation.validateInput( validation.AuthCredentialsSchema, request.params.arguments ); try { await auth.authenticate(credentials.username, credentials.password); return { content: [{ type: "text", text: `Successfully authenticated with ERPNext as ${credentials.username}` }] }; } catch (error) { // Don't expose details of authentication failures return { content: [{ type: "text", text: "Authentication failed. Please check your credentials and try again." }], isError: true }; } } case "get_documents": { // First check authentication if (!auth.isAuthenticated()) { throw new McpError( ErrorCode.Unauthorized, "Not authenticated with ERPNext. Use the authenticate_erpnext tool first." ); } // Validate doctype const doctype = validation.validateInput( validation.DoctypeSchema, request.params.arguments?.doctype ); // Validate optional parameters const fields = request.params.arguments?.fields ? validation.validateInput(validation.FieldsSchema, request.params.arguments.fields) : undefined; const filters = request.params.arguments?.filters ? validation.validateInput(validation.FiltersSchema, request.params.arguments.filters) : undefined; const limit = request.params.arguments?.limit as number | undefined; // Get documents const documents = await erpnext.getDocList(doctype, filters, fields, limit); return { content: [{ type: "text", text: JSON.stringify(documents, null, 2) }] }; } // Other tool handlers... default: throw new McpError( ErrorCode.MethodNotFound, `Unknown tool: ${request.params.name}` ); } } catch (error) { return handleToolError(error, logger); } }); } ``` ## Security Best Practices ### 1. Credential Handling - Never log passwords or sensitive information - Use secure environment variables for storing secrets - Implement token refresh mechanisms where possible - Consider using a secrets manager for production deployments ### 2. Input Validation - Validate all inputs with proper schemas - Sanitize inputs where necessary to prevent injection attacks - Apply validation early in the request flow ### 3. Rate Limiting - Apply rate limits to prevent abuse - Use stricter limits for sensitive operations like authentication - Provide informative feedback when limits are exceeded ### 4. Secure Configuration - Validate configuration at startup - Support multiple configuration sources (env vars, config files) - Use a secure method for loading credentials ### 5. Authentication Best Practices - Implement proper token handling - Support multiple authentication methods - Add contextual information for authentication events - Log authentication events (success, failure) without exposing sensitive details ## Implementation Plan 1. Add the validation utility module 2. Implement the secure configuration module 3. Create the authentication manager 4. Add security middleware (rate limiting) 5. Update tool handlers to use validation and rate limiting 6. Add security headers to responses These security improvements will help protect the ERPNext MCP server against common security vulnerabilities and provide better protection for user credentials and data. ``` -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- ```typescript #!/usr/bin/env node /** * ERPNext MCP Server * This server provides integration with the ERPNext/Frappe API, allowing: * - Authentication with ERPNext * - Fetching documents from ERPNext * - Querying lists of documents * - Creating and updating documents * - Running reports */ import { Server } from "@modelcontextprotocol/sdk/server/index.js"; import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"; import { CallToolRequestSchema, ErrorCode, ListResourcesRequestSchema, ListResourceTemplatesRequestSchema, ListToolsRequestSchema, McpError, ReadResourceRequestSchema } from "@modelcontextprotocol/sdk/types.js"; import axios, { AxiosInstance } from "axios"; // ERPNext API client configuration class ERPNextClient { private baseUrl: string; private axiosInstance: AxiosInstance; private authenticated: boolean = false; constructor() { // Get ERPNext configuration from environment variables this.baseUrl = process.env.ERPNEXT_URL || ''; // Validate configuration if (!this.baseUrl) { throw new Error("ERPNEXT_URL environment variable is required"); } // Remove trailing slash if present this.baseUrl = this.baseUrl.replace(/\/$/, ''); // Initialize axios instance this.axiosInstance = axios.create({ baseURL: this.baseUrl, withCredentials: true, headers: { 'Content-Type': 'application/json', 'Accept': 'application/json' } }); // Configure authentication if credentials provided const apiKey = process.env.ERPNEXT_API_KEY; const apiSecret = process.env.ERPNEXT_API_SECRET; if (apiKey && apiSecret) { this.axiosInstance.defaults.headers.common['Authorization'] = `token ${apiKey}:${apiSecret}`; this.authenticated = true; } } isAuthenticated(): boolean { return this.authenticated; } // Get a document by doctype and name async getDocument(doctype: string, name: string): Promise<any> { try { const response = await this.axiosInstance.get(`/api/resource/${doctype}/${name}`); return response.data.data; } catch (error: any) { throw new Error(`Failed to get ${doctype} ${name}: ${error?.message || 'Unknown error'}`); } } // Get list of documents for a doctype async getDocList(doctype: string, filters?: Record<string, any>, fields?: string[], limit?: number): Promise<any[]> { try { let params: Record<string, any> = {}; if (fields && fields.length) { params['fields'] = JSON.stringify(fields); } if (filters) { params['filters'] = JSON.stringify(filters); } if (limit) { params['limit_page_length'] = limit; } const response = await this.axiosInstance.get(`/api/resource/${doctype}`, { params }); return response.data.data; } catch (error: any) { throw new Error(`Failed to get ${doctype} list: ${error?.message || 'Unknown error'}`); } } // Create a new document async createDocument(doctype: string, doc: Record<string, any>): Promise<any> { try { const response = await this.axiosInstance.post(`/api/resource/${doctype}`, { data: doc }); return response.data.data; } catch (error: any) { throw new Error(`Failed to create ${doctype}: ${error?.message || 'Unknown error'}`); } } // Update an existing document async updateDocument(doctype: string, name: string, doc: Record<string, any>): Promise<any> { try { const response = await this.axiosInstance.put(`/api/resource/${doctype}/${name}`, { data: doc }); return response.data.data; } catch (error: any) { throw new Error(`Failed to update ${doctype} ${name}: ${error?.message || 'Unknown error'}`); } } // Run a report async runReport(reportName: string, filters?: Record<string, any>): Promise<any> { try { const response = await this.axiosInstance.get(`/api/method/frappe.desk.query_report.run`, { params: { report_name: reportName, filters: filters ? JSON.stringify(filters) : undefined } }); return response.data.message; } catch (error: any) { throw new Error(`Failed to run report ${reportName}: ${error?.message || 'Unknown error'}`); } } // Get all available DocTypes async getAllDocTypes(): Promise<string[]> { try { // Use the standard REST API to fetch DocTypes const response = await this.axiosInstance.get('/api/resource/DocType', { params: { fields: JSON.stringify(["name"]), limit_page_length: 500 // Get more doctypes at once } }); if (response.data && response.data.data) { return response.data.data.map((item: any) => item.name); } return []; } catch (error: any) { console.error("Failed to get DocTypes:", error?.message || 'Unknown error'); // Try an alternative approach if the first one fails try { // Try using the method API to get doctypes const altResponse = await this.axiosInstance.get('/api/method/frappe.desk.search.search_link', { params: { doctype: 'DocType', txt: '', limit: 500 } }); if (altResponse.data && altResponse.data.results) { return altResponse.data.results.map((item: any) => item.value); } return []; } catch (altError: any) { console.error("Alternative DocType fetch failed:", altError?.message || 'Unknown error'); // Fallback: Return a list of common DocTypes return [ "Customer", "Supplier", "Item", "Sales Order", "Purchase Order", "Sales Invoice", "Purchase Invoice", "Employee", "Lead", "Opportunity", "Quotation", "Payment Entry", "Journal Entry", "Stock Entry" ]; } } } } // Cache for doctype metadata const doctypeCache = new Map<string, any>(); // Initialize ERPNext client const erpnext = new ERPNextClient(); // Create an MCP server with capabilities for resources and tools const server = new Server( { name: "erpnext-server", version: "0.1.0" }, { capabilities: { resources: {}, tools: {} } } ); /** * Handler for listing available ERPNext resources. * Exposes DocTypes list as a resource and common doctypes as individual resources. */ server.setRequestHandler(ListResourcesRequestSchema, async () => { // List of common DocTypes to expose as individual resources const commonDoctypes = [ "Customer", "Supplier", "Item", "Sales Order", "Purchase Order", "Sales Invoice", "Purchase Invoice", "Employee" ]; const resources = [ // Add a resource to get all doctypes { uri: "erpnext://DocTypes", name: "All DocTypes", mimeType: "application/json", description: "List of all available DocTypes in the ERPNext instance" } ]; return { resources }; }); /** * Handler for resource templates. * Allows querying ERPNext documents by doctype and name. */ server.setRequestHandler(ListResourceTemplatesRequestSchema, async () => { const resourceTemplates = [ { uriTemplate: "erpnext://{doctype}/{name}", name: "ERPNext Document", mimeType: "application/json", description: "Fetch an ERPNext document by doctype and name" } ]; return { resourceTemplates }; }); /** * Handler for reading ERPNext resources. */ server.setRequestHandler(ReadResourceRequestSchema, async (request) => { if (!erpnext.isAuthenticated()) { throw new McpError( ErrorCode.InvalidRequest, "Not authenticated with ERPNext. Please configure API key authentication." ); } const uri = request.params.uri; let result: any; // Handle special resource: erpnext://DocTypes (list of all doctypes) if (uri === "erpnext://DocTypes") { try { const doctypes = await erpnext.getAllDocTypes(); result = { doctypes }; } catch (error: any) { throw new McpError( ErrorCode.InternalError, `Failed to fetch DocTypes: ${error?.message || 'Unknown error'}` ); } } else { // Handle document access: erpnext://{doctype}/{name} const documentMatch = uri.match(/^erpnext:\/\/([^\/]+)\/(.+)$/); if (documentMatch) { const doctype = decodeURIComponent(documentMatch[1]); const name = decodeURIComponent(documentMatch[2]); try { result = await erpnext.getDocument(doctype, name); } catch (error: any) { throw new McpError( ErrorCode.InvalidRequest, `Failed to fetch ${doctype} ${name}: ${error?.message || 'Unknown error'}` ); } } } if (!result) { throw new McpError( ErrorCode.InvalidRequest, `Invalid ERPNext resource URI: ${uri}` ); } return { contents: [{ uri: request.params.uri, mimeType: "application/json", text: JSON.stringify(result, null, 2) }] }; }); /** * Handler that lists available tools. */ server.setRequestHandler(ListToolsRequestSchema, async () => { return { tools: [ { name: "get_doctypes", description: "Get a list of all available DocTypes", inputSchema: { type: "object", properties: {} } }, { name: "get_doctype_fields", description: "Get fields list for a specific DocType", inputSchema: { type: "object", properties: { doctype: { type: "string", description: "ERPNext DocType (e.g., Customer, Item)" } }, required: ["doctype"] } }, { name: "get_documents", description: "Get a list of documents for a specific doctype", inputSchema: { type: "object", properties: { doctype: { type: "string", description: "ERPNext DocType (e.g., Customer, Item)" }, fields: { type: "array", items: { type: "string" }, description: "Fields to include (optional)" }, filters: { type: "object", additionalProperties: true, description: "Filters in the format {field: value} (optional)" }, limit: { type: "number", description: "Maximum number of documents to return (optional)" } }, required: ["doctype"] } }, { name: "create_document", description: "Create a new document in ERPNext", inputSchema: { type: "object", properties: { doctype: { type: "string", description: "ERPNext DocType (e.g., Customer, Item)" }, data: { type: "object", additionalProperties: true, description: "Document data" } }, required: ["doctype", "data"] } }, { name: "update_document", description: "Update an existing document in ERPNext", inputSchema: { type: "object", properties: { doctype: { type: "string", description: "ERPNext DocType (e.g., Customer, Item)" }, name: { type: "string", description: "Document name/ID" }, data: { type: "object", additionalProperties: true, description: "Document data to update" } }, required: ["doctype", "name", "data"] } }, { name: "run_report", description: "Run an ERPNext report", inputSchema: { type: "object", properties: { report_name: { type: "string", description: "Name of the report" }, filters: { type: "object", additionalProperties: true, description: "Report filters (optional)" } }, required: ["report_name"] } } ] }; }); /** * Handler for tool calls. */ server.setRequestHandler(CallToolRequestSchema, async (request) => { switch (request.params.name) { case "get_documents": { if (!erpnext.isAuthenticated()) { return { content: [{ type: "text", text: "Not authenticated with ERPNext. Please configure API key authentication." }], isError: true }; } const doctype = String(request.params.arguments?.doctype); const fields = request.params.arguments?.fields as string[] | undefined; const filters = request.params.arguments?.filters as Record<string, any> | undefined; const limit = request.params.arguments?.limit as number | undefined; if (!doctype) { throw new McpError( ErrorCode.InvalidParams, "Doctype is required" ); } try { const documents = await erpnext.getDocList(doctype, filters, fields, limit); return { content: [{ type: "text", text: JSON.stringify(documents, null, 2) }] }; } catch (error: any) { return { content: [{ type: "text", text: `Failed to get ${doctype} documents: ${error?.message || 'Unknown error'}` }], isError: true }; } } case "create_document": { if (!erpnext.isAuthenticated()) { return { content: [{ type: "text", text: "Not authenticated with ERPNext. Please configure API key authentication." }], isError: true }; } const doctype = String(request.params.arguments?.doctype); const data = request.params.arguments?.data as Record<string, any> | undefined; if (!doctype || !data) { throw new McpError( ErrorCode.InvalidParams, "Doctype and data are required" ); } try { const result = await erpnext.createDocument(doctype, data); return { content: [{ type: "text", text: `Created ${doctype}: ${result.name}\n\n${JSON.stringify(result, null, 2)}` }] }; } catch (error: any) { return { content: [{ type: "text", text: `Failed to create ${doctype}: ${error?.message || 'Unknown error'}` }], isError: true }; } } case "update_document": { if (!erpnext.isAuthenticated()) { return { content: [{ type: "text", text: "Not authenticated with ERPNext. Please configure API key authentication." }], isError: true }; } const doctype = String(request.params.arguments?.doctype); const name = String(request.params.arguments?.name); const data = request.params.arguments?.data as Record<string, any> | undefined; if (!doctype || !name || !data) { throw new McpError( ErrorCode.InvalidParams, "Doctype, name, and data are required" ); } try { const result = await erpnext.updateDocument(doctype, name, data); return { content: [{ type: "text", text: `Updated ${doctype} ${name}\n\n${JSON.stringify(result, null, 2)}` }] }; } catch (error: any) { return { content: [{ type: "text", text: `Failed to update ${doctype} ${name}: ${error?.message || 'Unknown error'}` }], isError: true }; } } case "run_report": { if (!erpnext.isAuthenticated()) { return { content: [{ type: "text", text: "Not authenticated with ERPNext. Please configure API key authentication." }], isError: true }; } const reportName = String(request.params.arguments?.report_name); const filters = request.params.arguments?.filters as Record<string, any> | undefined; if (!reportName) { throw new McpError( ErrorCode.InvalidParams, "Report name is required" ); } try { const result = await erpnext.runReport(reportName, filters); return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] }; } catch (error: any) { return { content: [{ type: "text", text: `Failed to run report ${reportName}: ${error?.message || 'Unknown error'}` }], isError: true }; } } case "get_doctype_fields": { if (!erpnext.isAuthenticated()) { return { content: [{ type: "text", text: "Not authenticated with ERPNext. Please configure API key authentication." }], isError: true }; } const doctype = String(request.params.arguments?.doctype); if (!doctype) { throw new McpError( ErrorCode.InvalidParams, "Doctype is required" ); } try { // Get a sample document to understand the fields const documents = await erpnext.getDocList(doctype, {}, ["*"], 1); if (!documents || documents.length === 0) { return { content: [{ type: "text", text: `No documents found for ${doctype}. Cannot determine fields.` }], isError: true }; } // Extract field names from the first document const sampleDoc = documents[0]; const fields = Object.keys(sampleDoc).map(field => ({ fieldname: field, value: typeof sampleDoc[field], sample: sampleDoc[field]?.toString()?.substring(0, 50) || null })); return { content: [{ type: "text", text: JSON.stringify(fields, null, 2) }] }; } catch (error: any) { return { content: [{ type: "text", text: `Failed to get fields for ${doctype}: ${error?.message || 'Unknown error'}` }], isError: true }; } } case "get_doctypes": { if (!erpnext.isAuthenticated()) { return { content: [{ type: "text", text: "Not authenticated with ERPNext. Please configure API key authentication." }], isError: true }; } try { const doctypes = await erpnext.getAllDocTypes(); return { content: [{ type: "text", text: JSON.stringify(doctypes, null, 2) }] }; } catch (error: any) { return { content: [{ type: "text", text: `Failed to get DocTypes: ${error?.message || 'Unknown error'}` }], isError: true }; } } default: throw new McpError( ErrorCode.MethodNotFound, `Unknown tool: ${request.params.name}` ); } }); /** * Start the server using stdio transport. */ async function main() { const transport = new StdioServerTransport(); await server.connect(transport); console.error('ERPNext MCP server running on stdio'); } main().catch((error) => { console.error("Server error:", error); process.exit(1); }); ```