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}` }] }; } } ); }