diff --git a/mcp-gateway/erpnext-mcp/src/tools/core.ts b/mcp-gateway/erpnext-mcp/src/tools/core.ts new file mode 100644 index 0000000..b762255 --- /dev/null +++ b/mcp-gateway/erpnext-mcp/src/tools/core.ts @@ -0,0 +1,454 @@ +import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import { ERPNextClient, truncateResponse, formatDocumentMarkdown, formatListMarkdown } from "../services/erpnext-client.js"; +import { + ListDocumentsSchema, + GetDocumentSchema, + CreateDocumentSchema, + UpdateDocumentSchema, + DeleteDocumentSchema, + SubmitDocumentSchema, + CancelDocumentSchema, + SearchLinkSchema, + GetCountSchema, + CallMethodSchema, + GetDocTypeMetaSchema, + RunReportSchema, + BulkUpdateSchema, + WorkflowActionSchema, +} from "../schemas/common.js"; + +export function registerCoreTools(server: McpServer, client: ERPNextClient): void { + + // ─── 1. List Documents ───────────────────────────────── + server.registerTool( + "erpnext_list_documents", + { + title: "List ERPNext Documents", + description: `List documents of any DocType with filtering, sorting, pagination and field selection. + +Args: + - doctype: DocType name (e.g. "Sales Order", "Customer", "Item") + - fields: Array of field names to return (default: ["name"]) + - filters: Array of [field, operator, value] tuples (e.g. [["status","=","Open"]]) + - order_by: Sort string (e.g. "modified desc") + - group_by: Group results by field + - limit: Max results (1-100, default 20) + - offset: Skip N results for pagination + +Returns: Paginated list with total count, items array, and pagination metadata. + +Examples: + - List open sales orders: doctype="Sales Order", filters=[["status","=","To Deliver and Bill"]] + - List customers: doctype="Customer", fields=["name","customer_name","territory"] + - List items by group: doctype="Item", filters=[["item_group","=","Products"]]`, + inputSchema: ListDocumentsSchema, + annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: true }, + }, + async (params) => { + try { + const result = await client.listDocuments(params.doctype, { + fields: params.fields, + filters: params.filters?.map(([f, op, val]) => [params.doctype, f, op, val as string]), + order_by: params.order_by, + group_by: params.group_by, + limit_start: params.offset, + limit_page_length: params.limit, + }); + + const text = formatListMarkdown(result.items, params.doctype, result); + return { content: [{ type: "text", text: truncateResponse(text) }] }; + } catch (error) { + return { isError: true, content: [{ type: "text", text: `Error: ${(error as Error).message}` }] }; + } + } + ); + + // ─── 2. Get Document ─────────────────────────────────── + server.registerTool( + "erpnext_get_document", + { + title: "Get ERPNext Document", + description: `Retrieve a single document by DocType and name with all or selected fields. + +Args: + - doctype: DocType name + - name: Document name/ID + - fields: Optional array of specific fields to return + +Returns: Full document data including child tables.`, + inputSchema: GetDocumentSchema, + annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: true }, + }, + async (params) => { + try { + const doc = await client.getDocument(params.doctype, params.name, params.fields); + const text = formatDocumentMarkdown(doc, params.doctype); + return { content: [{ type: "text", text: truncateResponse(text) }] }; + } catch (error) { + return { isError: true, content: [{ type: "text", text: `Error: ${(error as Error).message}` }] }; + } + } + ); + + // ─── 3. Create Document ──────────────────────────────── + server.registerTool( + "erpnext_create_document", + { + title: "Create ERPNext Document", + description: `Create a new document of any DocType. + +Args: + - doctype: DocType name + - data: Object with field values. For child tables, pass array of objects under the table fieldname. + +Returns: Created document with assigned name. + +Examples: + - Create customer: doctype="Customer", data={"customer_name":"Acme Corp","customer_type":"Company","customer_group":"Commercial","territory":"All Territories"} + - Create item: doctype="Item", data={"item_code":"ITEM-001","item_name":"Widget","item_group":"Products","stock_uom":"Nos"}`, + inputSchema: CreateDocumentSchema, + annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: true }, + }, + async (params) => { + try { + const doc = await client.createDocument(params.doctype, params.data); + return { content: [{ type: "text", text: `Created ${params.doctype}: ${doc.name}\n\n${formatDocumentMarkdown(doc, params.doctype)}` }] }; + } catch (error) { + return { isError: true, content: [{ type: "text", text: `Error: ${(error as Error).message}` }] }; + } + } + ); + + // ─── 4. Update Document ──────────────────────────────── + server.registerTool( + "erpnext_update_document", + { + title: "Update ERPNext Document", + description: `Update an existing document. Only pass the fields you want to change (PATCH semantics). + +Args: + - doctype: DocType name + - name: Document name/ID + - data: Object with fields to update + +Returns: Updated document.`, + inputSchema: UpdateDocumentSchema, + annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: true }, + }, + async (params) => { + try { + const doc = await client.updateDocument(params.doctype, params.name, params.data); + return { content: [{ type: "text", text: `Updated ${params.doctype}: ${params.name}\n\n${formatDocumentMarkdown(doc, params.doctype)}` }] }; + } catch (error) { + return { isError: true, content: [{ type: "text", text: `Error: ${(error as Error).message}` }] }; + } + } + ); + + // ─── 5. Delete Document ──────────────────────────────── + server.registerTool( + "erpnext_delete_document", + { + title: "Delete ERPNext Document", + description: `Permanently delete a document. This cannot be undone. + +Args: + - doctype: DocType name + - name: Document name/ID`, + inputSchema: DeleteDocumentSchema, + annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: false, openWorldHint: true }, + }, + async (params) => { + try { + await client.deleteDocument(params.doctype, params.name); + return { content: [{ type: "text", text: `Deleted ${params.doctype}: ${params.name}` }] }; + } catch (error) { + return { isError: true, content: [{ type: "text", text: `Error: ${(error as Error).message}` }] }; + } + } + ); + + // ─── 6. Submit Document ──────────────────────────────── + server.registerTool( + "erpnext_submit_document", + { + title: "Submit ERPNext Document", + description: `Submit a submittable document (sets docstatus=1). Used for Sales Orders, Purchase Orders, Invoices, Journal Entries, etc. Submitted documents are locked from editing. + +Args: + - doctype: DocType name + - name: Document name/ID`, + inputSchema: SubmitDocumentSchema, + annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: true }, + }, + async (params) => { + try { + const doc = await client.submitDocument(params.doctype, params.name); + return { content: [{ type: "text", text: `Submitted ${params.doctype}: ${params.name}\n\n${formatDocumentMarkdown(doc, params.doctype)}` }] }; + } catch (error) { + return { isError: true, content: [{ type: "text", text: `Error: ${(error as Error).message}` }] }; + } + } + ); + + // ─── 7. Cancel Document ──────────────────────────────── + server.registerTool( + "erpnext_cancel_document", + { + title: "Cancel ERPNext Document", + description: `Cancel a submitted document (sets docstatus=2). Creates reversal entries where applicable (e.g. GL entries for invoices). + +Args: + - doctype: DocType name + - name: Document name/ID`, + inputSchema: CancelDocumentSchema, + annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: true, openWorldHint: true }, + }, + async (params) => { + try { + const doc = await client.cancelDocument(params.doctype, params.name); + return { content: [{ type: "text", text: `Cancelled ${params.doctype}: ${params.name}` }] }; + } catch (error) { + return { isError: true, content: [{ type: "text", text: `Error: ${(error as Error).message}` }] }; + } + } + ); + + // ─── 8. Search Link ──────────────────────────────────── + server.registerTool( + "erpnext_search_link", + { + title: "Search ERPNext Link", + description: `Search for documents by text — the same search used in Link fields across ERPNext. Returns name + description pairs. + +Args: + - doctype: DocType to search in + - txt: Search text + - filters: Optional additional filters + - page_length: Max results (default 20) + +Examples: + - Find customers matching "acme": doctype="Customer", txt="acme" + - Find items matching "widget": doctype="Item", txt="widget"`, + inputSchema: SearchLinkSchema, + annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: true }, + }, + async (params) => { + try { + const results = await client.searchLink( + params.doctype, params.txt, params.filters, params.page_length + ); + const text = results.length + ? results.map((r) => `- **${r.value}** — ${r.description || ""}`).join("\n") + : `No ${params.doctype} found matching "${params.txt}"`; + return { content: [{ type: "text", text }] }; + } catch (error) { + return { isError: true, content: [{ type: "text", text: `Error: ${(error as Error).message}` }] }; + } + } + ); + + // ─── 9. Get Count ────────────────────────────────────── + server.registerTool( + "erpnext_get_count", + { + title: "Count ERPNext Documents", + description: `Get the total count of documents matching optional filters. + +Args: + - doctype: DocType name + - filters: Optional [field, operator, value] tuples + +Examples: + - Count open sales orders: doctype="Sales Order", filters=[["status","=","To Deliver and Bill"]] + - Count all customers: doctype="Customer"`, + inputSchema: GetCountSchema, + annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: true }, + }, + async (params) => { + try { + const count = await client.getCount( + params.doctype, + params.filters?.map(([f, op, val]) => [params.doctype, f, op, val as string]) + ); + return { content: [{ type: "text", text: `Count of ${params.doctype}: ${count}` }] }; + } catch (error) { + return { isError: true, content: [{ type: "text", text: `Error: ${(error as Error).message}` }] }; + } + } + ); + + // ─── 10. Call Method ─────────────────────────────────── + server.registerTool( + "erpnext_call_method", + { + title: "Call ERPNext Method", + description: `Call any whitelisted ERPNext/Frappe method via the /api/method/ endpoint. This is the escape hatch for any operation not covered by the other tools. + +Args: + - method: Dotted method path (e.g. "erpnext.stock.utils.get_stock_balance") + - args: Method arguments as key-value object + +Common methods: + - frappe.client.get_value: Get a field value from a document + - frappe.client.set_value: Set a field value on a document + - frappe.client.get_list: Alternative list with more options + - erpnext.stock.utils.get_stock_balance: Get stock balance for item/warehouse + - erpnext.setup.utils.get_exchange_rate: Get currency exchange rate + - erpnext.selling.page.point_of_sale.point_of_sale.get_items: POS items + - erpnext.stock.get_item_details.get_item_details: Full item details for transactions`, + inputSchema: CallMethodSchema, + annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: true }, + }, + async (params) => { + try { + const result = await client.callMethod(params.method, params.args); + const text = typeof result === "string" ? result : JSON.stringify(result, null, 2); + return { content: [{ type: "text", text: truncateResponse(text) }] }; + } catch (error) { + return { isError: true, content: [{ type: "text", text: `Error: ${(error as Error).message}` }] }; + } + } + ); + + // ─── 11. Get DocType Schema ──────────────────────────── + server.registerTool( + "erpnext_get_doctype_meta", + { + title: "Get DocType Schema/Meta", + description: `Get the field definitions and schema for any DocType. Useful to discover available fields before listing or creating documents. + +Args: + - doctype: DocType name + +Returns: List of fields with fieldname, fieldtype, label, options, reqd (required), read_only, hidden.`, + inputSchema: GetDocTypeMetaSchema, + annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: true }, + }, + async (params) => { + try { + const meta = await client.getDocTypeMeta(params.doctype); + const text = JSON.stringify(meta, null, 2); + return { content: [{ type: "text", text: truncateResponse(text) }] }; + } catch (error) { + return { isError: true, content: [{ type: "text", text: `Error: ${(error as Error).message}` }] }; + } + } + ); + + // ─── 12. Run Report ──────────────────────────────────── + server.registerTool( + "erpnext_run_report", + { + title: "Run ERPNext Report", + description: `Run a Script Report or Query Report and return the data. + +Args: + - report_name: Name of the report (e.g. "General Ledger", "Stock Balance", "Accounts Receivable") + - filters: Report filter parameters as key-value object + +Examples: + - General Ledger: report_name="General Ledger", filters={"company":"My Company","from_date":"2024-01-01","to_date":"2024-12-31"} + - Stock Balance: report_name="Stock Balance", filters={"company":"My Company"} + - Accounts Receivable: report_name="Accounts Receivable", filters={"company":"My Company"}`, + inputSchema: RunReportSchema, + annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: true }, + }, + async (params) => { + try { + const result = await client.runReport(params.report_name, params.filters); + const text = JSON.stringify(result, null, 2); + return { content: [{ type: "text", text: truncateResponse(text) }] }; + } catch (error) { + return { isError: true, content: [{ type: "text", text: `Error: ${(error as Error).message}` }] }; + } + } + ); + + // ─── 13. List DocTypes ───────────────────────────────── + server.registerTool( + "erpnext_list_doctypes", + { + title: "List Available DocTypes", + description: `List all available DocTypes in the ERPNext instance. Useful to discover what entities exist. + +Returns: Array of DocType names sorted alphabetically.`, + inputSchema: {}, + annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: true }, + }, + async () => { + try { + const doctypes = await client.getDocTypeList(); + return { content: [{ type: "text", text: `Available DocTypes (${doctypes.length}):\n\n${doctypes.join("\n")}` }] }; + } catch (error) { + return { isError: true, content: [{ type: "text", text: `Error: ${(error as Error).message}` }] }; + } + } + ); + + // ─── 14. Bulk Update ─────────────────────────────────── + server.registerTool( + "erpnext_bulk_update", + { + title: "Bulk Update ERPNext Documents", + description: `Update multiple documents of the same DocType with the same field values. + +Args: + - doctype: DocType name + - names: Array of document names to update + - data: Field values to set on all documents + +Example: Set status on multiple issues: doctype="Issue", names=["ISS-001","ISS-002"], data={"status":"Closed"}`, + inputSchema: BulkUpdateSchema, + annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: true }, + }, + async (params) => { + try { + const results: string[] = []; + const errors: string[] = []; + for (const name of params.names) { + try { + await client.updateDocument(params.doctype, name, params.data); + results.push(name); + } catch (error) { + errors.push(`${name}: ${(error as Error).message}`); + } + } + let text = `Updated ${results.length}/${params.names.length} ${params.doctype} documents.`; + if (errors.length) { + text += `\n\nErrors:\n${errors.join("\n")}`; + } + return { content: [{ type: "text", text }] }; + } catch (error) { + return { isError: true, content: [{ type: "text", text: `Error: ${(error as Error).message}` }] }; + } + } + ); + + // ─── 15. Workflow Action ─────────────────────────────── + server.registerTool( + "erpnext_workflow_action", + { + title: "Apply Workflow Action", + description: `Apply a workflow action to a document that has a workflow configured. + +Args: + - doctype: DocType name + - name: Document name + - action: Workflow action name (e.g. "Approve", "Reject", "Submit")`, + inputSchema: WorkflowActionSchema, + annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: true }, + }, + async (params) => { + try { + const result = await client.callMethod("frappe.model.workflow.apply_workflow", { + doc: { doctype: params.doctype, name: params.name }, + action: params.action, + }); + return { content: [{ type: "text", text: `Applied workflow action "${params.action}" on ${params.doctype}: ${params.name}` }] }; + } catch (error) { + return { isError: true, content: [{ type: "text", text: `Error: ${(error as Error).message}` }] }; + } + } + ); +}