454 lines
19 KiB
TypeScript
454 lines
19 KiB
TypeScript
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}` }] };
|
|
}
|
|
}
|
|
);
|
|
}
|