Add mcp-gateway/erpnext-mcp/src/tools/core.ts

This commit is contained in:
Zac Gaetano 2026-03-31 15:29:33 -04:00
parent 0593eb997c
commit 91f11a0911

View file

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