UNPKG

frappe-mcp-server

Version:

Enhanced Model Context Protocol server for Frappe Framework with comprehensive API instructions and helper tools

747 lines 31.6 kB
import { z } from "zod"; import { callMethod } from "./frappe-api.js"; import { createDocument, getDocument, updateDocument, deleteDocument, listDocuments, FrappeApiError } from "./frappe-api.js"; import { getRequiredFields, formatFilters } from "./frappe-helpers.js"; /** * Format error response with detailed information */ function formatErrorResponse(error, operation) { // Include all error diagnostics directly in the response const apiKey = process.env.FRAPPE_API_KEY; const apiSecret = process.env.FRAPPE_API_SECRET; // Build a detailed diagnostic message let diagnostics = [ `Error in ${operation}`, `Error type: ${typeof error}`, `Constructor: ${error.constructor?.name || 'unknown'}`, `Is FrappeApiError: ${error instanceof FrappeApiError}`, `Error properties: ${Object.keys(error).join(', ')}`, `API Key available: ${!!apiKey}`, `API Secret available: ${!!apiSecret}` ].join('\n'); let errorMessage = ''; let errorDetails = null; // Check for missing credentials first as this is likely the issue if (!apiKey || !apiSecret) { errorMessage = `Authentication failed: ${!apiKey && !apiSecret ? 'Both API key and API secret are missing' : !apiKey ? 'API key is missing' : 'API secret is missing'}. API key/secret is the only supported authentication method.`; errorDetails = { error: "Missing credentials", apiKeyAvailable: !!apiKey, apiSecretAvailable: !!apiSecret, authMethod: "API key/secret (token)", diagnostics: diagnostics }; } // Then check if it's a FrappeApiError else if (error instanceof FrappeApiError) { errorMessage = error.message; // Include the full error object properties for debugging errorDetails = { statusCode: error.statusCode, endpoint: error.endpoint, details: error.details, message: error.message, name: error.name, stack: error.stack?.split('\n').slice(0, 3).join('\n'), diagnostics: diagnostics, authError: false // Initialize the property }; // If it's an authentication error, provide more specific guidance if (error.message.includes('Authentication') || error.message.includes('auth') || error.statusCode === 401 || error.statusCode === 403) { errorMessage = `Authentication error: ${error.message}. Please check your API key and secret.`; errorDetails.authError = true; } } // Check for Axios errors else if (error.isAxiosError) { errorMessage = `API request error: ${error.message}`; errorDetails = { status: error.response?.status, statusText: error.response?.statusText, url: error.config?.url, method: error.config?.method, diagnostics: diagnostics }; } // Default error handling else { errorMessage = `Error in ${operation}: ${error.message || 'Unknown error'}`; errorDetails = { diagnostics: diagnostics }; } return { content: [ { type: "text", text: errorMessage, }, ...(errorDetails ? [ { type: "text", text: `\nDetails: ${JSON.stringify(errorDetails, null, 2)}`, } ] : []) ], isError: true, }; } /** * Validate document values against required fields */ async function validateDocumentValues(doctype, values) { try { const requiredFields = await getRequiredFields(doctype); const missingFields = requiredFields .filter(field => !values.hasOwnProperty(field.fieldname)) .map(field => field.fieldname); return missingFields; } catch (error) { console.error(`Error validating document values for ${doctype}:`, error); return []; // Return empty array on error to avoid blocking the operation } } // Define document tools export const DOCUMENT_TOOLS = [ { name: "create_document", description: "Create a new document in Frappe", inputSchema: { type: "object", properties: { doctype: { type: "string", description: "DocType name" }, values: { type: "object", description: "Document field values. Required fields must be included. For Link fields, provide the exact document name. For Table fields, provide an array of row objects.", additionalProperties: true }, }, required: ["doctype", "values"], }, }, { name: "get_document", description: "Retrieve a document from Frappe", inputSchema: { type: "object", properties: { doctype: { type: "string", description: "DocType name" }, name: { type: "string", description: "Document name (case-sensitive)" }, fields: { type: "array", items: { type: "string" }, description: "Fields to retrieve (optional). If not specified, all fields will be returned.", }, }, required: ["doctype", "name"], }, }, { name: "update_document", description: "Update an existing document in Frappe", inputSchema: { type: "object", properties: { doctype: { type: "string", description: "DocType name" }, name: { type: "string", description: "Document name (case-sensitive)" }, values: { type: "object", description: "Document field values to update. Only include fields that need to be updated. For Table fields, provide the entire table data including row IDs for existing rows.", additionalProperties: true }, }, required: ["doctype", "name", "values"], }, }, { name: "delete_document", description: "Delete a document from Frappe", inputSchema: { type: "object", properties: { doctype: { type: "string", description: "DocType name" }, name: { type: "string", description: "Document name (case-sensitive)" }, }, required: ["doctype", "name"], }, }, { name: "list_documents", description: "List documents from Frappe with filters", inputSchema: { type: "object", properties: { doctype: { type: "string", description: "DocType name" }, filters: { type: "object", description: "Filters to apply (optional). Simple format: {\"field\": \"value\"} or with operators: {\"field\": [\">\", \"value\"]}. Available operators: =, !=, <, >, <=, >=, like, not like, in, not in, is, is not, between.", additionalProperties: true }, fields: { type: "array", items: { type: "string" }, description: "Fields to retrieve (optional). For better performance, specify only the fields you need.", }, limit: { type: "number", description: "Maximum number of documents to retrieve (optional). Use with limit_start for pagination.", }, limit_start: { type: "number", description: "Starting offset for pagination (optional). Use with limit for pagination.", }, order_by: { type: "string", description: "Field to order by (optional). Format: \"field_name asc\" or \"field_name desc\".", }, }, required: ["doctype"], }, }, { name: "reconcile_bank_transaction_with_vouchers", description: "Reconciles a Bank Transaction document with specified vouchers by calling a specific Frappe method.", inputSchema: { type: "object", properties: { bank_transaction_name: { type: "string", description: "The ID (name) of the Bank Transaction document to reconcile.", }, vouchers: { type: "array", description: "An array of voucher objects to reconcile against the bank transaction.", items: { type: "object", properties: { payment_doctype: { type: "string", description: "The DocType of the payment voucher (e.g., Payment Entry, Journal Entry).", }, payment_name: { type: "string", description: "The ID (name) of the payment voucher document.", }, amount: { type: "number", description: "The amount from the voucher to reconcile.", }, }, required: ["payment_doctype", "payment_name", "amount"], }, }, }, required: ["bank_transaction_name", "vouchers"], }, }, ]; // Export a handler function for document tool calls export async function handleDocumentToolCall(request) { const { name, arguments: args } = request.params; if (!args) { return { content: [ { type: "text", text: "Missing arguments for tool call", }, ], isError: true, }; } try { console.error("Handling document tool:", name, "with args:", args); // Handle document operations if (name === "create_document") { const doctype = args.doctype; const values = args.values; if (!doctype || !values) { return { content: [ { type: "text", text: "Missing required parameters: doctype and values", }, ], isError: true, }; } // Validate required fields const missingFields = await validateDocumentValues(doctype, values); if (missingFields.length > 0) { return { content: [ { type: "text", text: `Missing required fields: ${missingFields.join(', ')}`, }, { type: "text", text: "\nTip: Use get_required_fields tool to see all required fields for this DocType.", }, ], isError: true, }; } try { console.error(`Calling createDocument for ${doctype} with values:`, JSON.stringify(values, null, 2)); let result; let authMethod = "token"; let verificationSuccess = false; let verificationMessage = ""; // Use API key/secret authentication result = await createDocument(doctype, values); console.error(`Result from createDocument (API key/secret auth):`, JSON.stringify(result, null, 2)); authMethod = "api_key"; // Check for verification result if (result._verification && result._verification.success === false) { verificationSuccess = false; verificationMessage = result._verification.message; delete result._verification; // Remove internal property before returning to client } else { verificationSuccess = true; } // IMPROVED: Return error if verification failed if (!verificationSuccess) { return { content: [ { type: "text", text: `Error: Document creation reported success but verification failed. The document may not have been created.\n\nDetails: ${verificationMessage}`, }, ], isError: true, }; } return { content: [ { type: "text", text: `Document created successfully using ${authMethod} authentication:\n\n${JSON.stringify(result, null, 2)}`, }, ], }; } catch (error) { console.error(`Error in create_document handler:`, error); return formatErrorResponse(error, `create_document(${doctype})`); } } else if (name === "get_document") { const doctype = args.doctype; const docName = args.name; const fields = args.fields; if (!doctype || !docName) { return { content: [ { type: "text", text: "Missing required parameters: doctype and name", }, ], isError: true, }; } try { let document; let authMethod = "token"; // Use API key/secret authentication document = await getDocument(doctype, docName, fields); console.error(`Retrieved document using API key/secret auth:`, JSON.stringify(document, null, 2)); authMethod = "api_key"; return { content: [ { type: "text", text: `Document retrieved using ${authMethod} authentication:\n\n${JSON.stringify(document, null, 2)}`, }, ], }; } catch (error) { return formatErrorResponse(error, `get_document(${doctype}, ${docName})`); } } else if (name === "update_document") { const doctype = args.doctype; const docName = args.name; const values = args.values; if (!doctype || !docName || !values) { return { content: [ { type: "text", text: "Missing required parameters: doctype, name, and values", }, ], isError: true, }; } try { let result; let authMethod = "token"; // Use API key/secret authentication result = await updateDocument(doctype, docName, values); console.error(`Result from updateDocument (API key/secret auth):`, JSON.stringify(result, null, 2)); authMethod = "api_key"; return { content: [ { type: "text", text: `Document updated successfully using ${authMethod} authentication:\n\n${JSON.stringify(result, null, 2)}`, }, ], }; } catch (error) { return formatErrorResponse(error, `update_document(${doctype}, ${docName})`); } } else if (name === "delete_document") { const doctype = args.doctype; const docName = args.name; if (!doctype || !docName) { return { content: [ { type: "text", text: "Missing required parameters: doctype and name", }, ], isError: true, }; } try { let authMethod = "token"; // Use API key/secret authentication await deleteDocument(doctype, docName); console.error(`Document deleted using API key/secret auth`); authMethod = "api_key"; return { content: [ { type: "text", text: JSON.stringify({ success: true, message: `Document ${doctype}/${docName} deleted successfully using ${authMethod} authentication` }, null, 2), }, ], }; } catch (error) { return formatErrorResponse(error, `delete_document(${doctype}, ${docName})`); } } else if (name === "list_documents") { const doctype = args.doctype; const filters = args.filters; const fields = args.fields; const limit = args.limit; const order_by = args.order_by; const limit_start = args.limit_start; if (!doctype) { return { content: [ { type: "text", text: "Missing required parameter: doctype", }, ], isError: true, }; } try { // Format filters if provided const formattedFilters = filters ? formatFilters(filters) : undefined; let documents; let authMethod = "token"; // Use API key/secret authentication documents = await listDocuments(doctype, formattedFilters, fields, limit, order_by, limit_start); console.error(`Retrieved ${documents.length} documents using API key/secret auth`); authMethod = "api_key"; // Add pagination info if applicable let paginationInfo = ""; if (limit) { const startIndex = limit_start || 0; const endIndex = startIndex + documents.length; paginationInfo = `\n\nShowing items ${startIndex + 1}-${endIndex}`; if (documents.length === limit) { paginationInfo += ` (more items may be available, use limit_start=${endIndex} to see next page)`; } } return { content: [ { type: "text", text: `Documents retrieved using ${authMethod} authentication:\n\n${JSON.stringify(documents, null, 2)}${paginationInfo}`, }, ], }; } catch (error) { return formatErrorResponse(error, `list_documents(${doctype})`); } } else if (name === "reconcile_bank_transaction_with_vouchers") { const bankTransactionName = args.bank_transaction_name; const vouchers = args.vouchers; if (!bankTransactionName || !vouchers) { return { content: [ { type: "text", text: "Missing required parameters: bank_transaction_name and vouchers", }, ], isError: true, }; } if (!Array.isArray(vouchers) || vouchers.some(v => !v.payment_doctype || !v.payment_name || typeof v.amount !== 'number')) { return { content: [ { type: "text", text: "Invalid format for 'vouchers' parameter. It must be an array of objects, each with 'payment_doctype' (string), 'payment_name' (string), and 'amount' (number).", }, ], isError: true, }; } try { const frappeMethod = "erpnext.accounts.doctype.bank_reconciliation_tool.bank_reconciliation_tool.reconcile_vouchers"; const params = { bank_transaction_name: bankTransactionName, vouchers: JSON.stringify(vouchers), // Frappe method expects vouchers as a JSON string }; console.error(`Calling Frappe method '${frappeMethod}' with params:`, JSON.stringify(params, null, 2)); const result = await callMethod(frappeMethod, params); console.error(`Result from '${frappeMethod}':`, JSON.stringify(result, null, 2)); return { content: [ { type: "text", text: `Bank transaction '${bankTransactionName}' reconciled successfully with vouchers:\n\n${JSON.stringify(result, null, 2)}`, }, ], }; } catch (error) { console.error(`Error in reconcile_bank_transaction_with_vouchers handler:`, error); return formatErrorResponse(error, `reconcile_bank_transaction_with_vouchers(${bankTransactionName})`); } } return { content: [ { type: "text", text: `Document operations module doesn't handle tool: ${name}`, }, ], isError: true, }; } catch (error) { return formatErrorResponse(error, `document_operations.${name}`); } } export function setupDocumentTools(server) { // Register create_document tool server.tool("create_document", "Create a new document in Frappe", { doctype: z.string().describe("DocType name"), values: z.object({}).passthrough().describe("Document field values. Required fields must be included. For Link fields, provide the exact document name. For Table fields, provide an array of row objects.") }, async ({ doctype, values }) => { try { const result = await createDocument(doctype, values); return { content: [ { type: "text", text: `Document created successfully. Name: ${result.name}`, }, ], }; } catch (error) { return formatErrorResponse(error, "create_document"); } }); // Register get_document tool server.tool("get_document", "Retrieve a document from Frappe", { doctype: z.string().describe("DocType name"), name: z.string().describe("Document name (case-sensitive)"), fields: z.array(z.string()).optional().describe("Fields to retrieve (optional). If not specified, all fields will be returned.") }, async ({ doctype, name, fields }) => { try { const result = await getDocument(doctype, name, fields); return { content: [ { type: "text", text: JSON.stringify(result, null, 2), }, ], }; } catch (error) { return formatErrorResponse(error, "get_document"); } }); // Register update_document tool server.tool("update_document", "Update an existing document in Frappe", { doctype: z.string().describe("DocType name"), name: z.string().describe("Document name (case-sensitive)"), values: z.object({}).passthrough().describe("Document field values to update. Only include fields that need to be updated. For Table fields, provide the entire table data including row IDs for existing rows.") }, async ({ doctype, name, values }) => { try { const result = await updateDocument(doctype, name, values); return { content: [ { type: "text", text: `Document updated successfully. Name: ${result.name}`, }, ], }; } catch (error) { return formatErrorResponse(error, "update_document"); } }); // Register delete_document tool server.tool("delete_document", "Delete a document from Frappe", { doctype: z.string().describe("DocType name"), name: z.string().describe("Document name (case-sensitive)") }, async ({ doctype, name }) => { try { await deleteDocument(doctype, name); return { content: [ { type: "text", text: `Document deleted successfully. DocType: ${doctype}, Name: ${name}`, }, ], }; } catch (error) { return formatErrorResponse(error, "delete_document"); } }); // Register list_documents tool server.tool("list_documents", "List documents from Frappe with filters", { doctype: z.string().describe("DocType name"), filters: z.object({}).optional().describe("Filters to apply (optional). Simple format: {\"field\": \"value\"} or with operators: {\"field\": [\">\", \"value\"]}. Available operators: =, !=, <, >, <=, >=, like, not like, in, not in, is, is not, between."), fields: z.array(z.string()).optional().describe("Fields to retrieve (optional). For better performance, specify only the fields you need."), limit: z.number().optional().describe("Maximum number of documents to retrieve (optional). Use with limit_start for pagination."), limit_start: z.number().optional().describe("Starting offset for pagination (optional). Use with limit for pagination."), order_by: z.string().optional().describe("Field to order by (optional). Format: \"field_name asc\" or \"field_name desc\".") }, async ({ doctype, filters, fields, limit, limit_start, order_by }) => { try { // Format filters if provided const formattedFilters = filters ? formatFilters(filters) : undefined; const result = await listDocuments(doctype, formattedFilters, fields, limit, order_by, limit_start); return { content: [ { type: "text", text: JSON.stringify(result, null, 2), }, ], }; } catch (error) { return formatErrorResponse(error, "list_documents"); } }); // Register reconcile_bank_transaction_with_vouchers tool server.tool("reconcile_bank_transaction_with_vouchers", "Reconciles a Bank Transaction document with specified vouchers by calling a specific Frappe method.", { bank_transaction_name: z.string().describe("The ID (name) of the Bank Transaction document to reconcile."), vouchers: z.array(z.object({ payment_doctype: z.string().describe("The DocType of the payment voucher (e.g., Payment Entry, Journal Entry)."), payment_name: z.string().describe("The ID (name) of the payment voucher document."), amount: z.number().describe("The amount from the voucher to reconcile.") })).describe("An array of voucher objects to reconcile against the bank transaction.") }, async ({ bank_transaction_name, vouchers }) => { try { const result = await callMethod("erpnext.accounts.doctype.bank_transaction.bank_transaction.reconcile_bank_transaction_with_vouchers", { bank_transaction_name, vouchers }); return { content: [ { type: "text", text: `Bank transaction reconciled successfully: ${JSON.stringify(result, null, 2)}`, }, ], }; } catch (error) { return formatErrorResponse(error, "reconcile_bank_transaction_with_vouchers"); } }); } /** * Handle call_method tool call */ export async function handleCallMethodToolCall(request) { const { name, arguments: args } = request.params; if (!args) { return { content: [ { type: "text", text: "Missing arguments for tool call", }, ], isError: true, }; } try { console.error(`Handling call_method tool with args:`, args); const method = args.method; const params = args.params; if (!method) { return { content: [ { type: "text", text: "Missing required parameter: method", }, ], isError: true, }; } console.error(`About to call method: ${method} with params:`, JSON.stringify(params, null, 2)); const result = await callMethod(method, params); console.error(`Method call successful, result:`, JSON.stringify(result, null, 2)); return { content: [ { type: "text", text: `Method ${method} called successfully:\n\n${JSON.stringify(result, null, 2)}`, }, ], }; } catch (error) { console.error(`Error in handleCallMethodToolCall:`, error); console.error(`Error details - type:`, typeof error); console.error(`Error details - constructor:`, error.constructor?.name); console.error(`Error details - message:`, error.message); console.error(`Error details - stack:`, error.stack); if (error instanceof Error) { console.error(`Error properties:`, Object.keys(error)); console.error(`Error toString:`, error.toString()); } return formatErrorResponse(error, `call_method(${args.method || 'unknown'})`); } } //# sourceMappingURL=document-operations.js.map