diff --git a/erpnext-mcp/src/services/erpnext-client.ts b/erpnext-mcp/src/services/erpnext-client.ts new file mode 100644 index 0000000..3b68ac5 --- /dev/null +++ b/erpnext-mcp/src/services/erpnext-client.ts @@ -0,0 +1,434 @@ +import { ERPNextConfig, ERPNextListParams, PaginatedResult } from "../types.js"; +import { RESOURCE_PATH, METHOD_PATH, CHARACTER_LIMIT, DEFAULT_PAGE_SIZE } from "../constants.js"; + +export class ERPNextClient { + private baseUrl: string; + private authHeader: string; + + constructor(config: ERPNextConfig) { + this.baseUrl = config.baseUrl.replace(/\/$/, ""); + this.authHeader = `token ${config.apiKey}:${config.apiSecret}`; + } + + private async request( + path: string, + method: string = "GET", + body?: Record, + params?: Record + ): Promise { + let url = `${this.baseUrl}${path}`; + if (params) { + const searchParams = new URLSearchParams(); + for (const [key, value] of Object.entries(params)) { + if (value !== undefined && value !== null && value !== "") { + searchParams.append(key, value); + } + } + const qs = searchParams.toString(); + if (qs) url += `?${qs}`; + } + + const headers: Record = { + Authorization: this.authHeader, + "Content-Type": "application/json", + Accept: "application/json", + }; + + const fetchOptions: RequestInit = { method, headers }; + if (body && (method === "POST" || method === "PUT" || method === "DELETE")) { + fetchOptions.body = JSON.stringify(body); + } + + const response = await fetch(url, fetchOptions); + + if (!response.ok) { + const errorText = await response.text(); + let errorMessage: string; + try { + const errorJson = JSON.parse(errorText); + errorMessage = + errorJson.message || + errorJson.exc || + errorJson._server_messages || + errorText; + if (typeof errorMessage !== "string") { + errorMessage = JSON.stringify(errorMessage); + } + } catch { + errorMessage = errorText; + } + throw new Error( + `ERPNext API error (${response.status} ${response.statusText}): ${errorMessage.slice(0, 500)}` + ); + } + + const json = await response.json(); + return json as T; + } + + // ── Generic CRUD ────────────────────────────────────────── + + async listDocuments( + doctype: string, + listParams?: ERPNextListParams + ): Promise>> { + const params: Record = {}; + + if (listParams?.fields?.length) { + params.fields = JSON.stringify(listParams.fields); + } + if (listParams?.filters?.length) { + params.filters = JSON.stringify(listParams.filters); + } + if (listParams?.or_filters?.length) { + params.or_filters = JSON.stringify(listParams.or_filters); + } + if (listParams?.order_by) { + params.order_by = listParams.order_by; + } + if (listParams?.group_by) { + params.group_by = listParams.group_by; + } + + const offset = listParams?.limit_start ?? 0; + const limit = listParams?.limit_page_length ?? DEFAULT_PAGE_SIZE; + params.limit_start = String(offset); + params.limit_page_length = String(limit); + + const result = await this.request<{ data: Record[] }>( + `${RESOURCE_PATH}/${encodeURIComponent(doctype)}`, + "GET", + undefined, + params + ); + + // Get total count + const countParams: Record = {}; + if (listParams?.filters?.length) { + countParams.filters = JSON.stringify(listParams.filters); + } + if (listParams?.or_filters?.length) { + countParams.or_filters = JSON.stringify(listParams.or_filters); + } + countParams.limit_page_length = "0"; + + let total = result.data.length + offset; + try { + const countResult = await this.request<{ data: number }>( + `${METHOD_PATH}/frappe.client.get_count`, + "GET", + undefined, + { + doctype, + ...(listParams?.filters?.length + ? { filters: JSON.stringify(listParams.filters) } + : {}), + } + ); + total = countResult.data ?? (countResult as unknown as { message: number }).message ?? total; + } catch { + // If count fails, estimate based on returned data + if (result.data.length === limit) { + total = offset + limit + 1; // Signal there might be more + } + } + + return { + total, + count: result.data.length, + offset, + items: result.data, + has_more: offset + result.data.length < total, + ...(offset + result.data.length < total + ? { next_offset: offset + result.data.length } + : {}), + }; + } + + async getDocument( + doctype: string, + name: string, + fields?: string[] + ): Promise> { + const params: Record = {}; + if (fields?.length) { + params.fields = JSON.stringify(fields); + } + const result = await this.request<{ data: Record }>( + `${RESOURCE_PATH}/${encodeURIComponent(doctype)}/${encodeURIComponent(name)}`, + "GET", + undefined, + params + ); + return result.data; + } + + async createDocument( + doctype: string, + data: Record + ): Promise> { + const result = await this.request<{ data: Record }>( + `${RESOURCE_PATH}/${encodeURIComponent(doctype)}`, + "POST", + data + ); + return result.data; + } + + async updateDocument( + doctype: string, + name: string, + data: Record + ): Promise> { + const result = await this.request<{ data: Record }>( + `${RESOURCE_PATH}/${encodeURIComponent(doctype)}/${encodeURIComponent(name)}`, + "PUT", + data + ); + return result.data; + } + + async deleteDocument( + doctype: string, + name: string + ): Promise<{ message: string }> { + const result = await this.request<{ message: string }>( + `${RESOURCE_PATH}/${encodeURIComponent(doctype)}/${encodeURIComponent(name)}`, + "DELETE" + ); + return result; + } + + // ── Workflow / Submit / Cancel / Amend ──────────────────── + + async submitDocument( + doctype: string, + name: string + ): Promise> { + return this.updateDocument(doctype, name, { docstatus: 1 }); + } + + async cancelDocument( + doctype: string, + name: string + ): Promise> { + return this.updateDocument(doctype, name, { docstatus: 2 }); + } + + async amendDocument( + doctype: string, + name: string + ): Promise> { + const result = await this.callMethod("frappe.client.amend", { + doctype, + name, + }); + return result as Record; + } + + // ── Method Calls ────────────────────────────────────────── + + async callMethod( + method: string, + args?: Record + ): Promise { + const result = await this.request<{ message: unknown }>( + `${METHOD_PATH}/${method}`, + "POST", + args + ); + return result.message ?? result; + } + + async callGetMethod( + method: string, + params?: Record + ): Promise { + const result = await this.request<{ message: unknown }>( + `${METHOD_PATH}/${method}`, + "GET", + undefined, + params + ); + return result.message ?? result; + } + + // ── Search / Link ───────────────────────────────────────── + + async searchLink( + doctype: string, + txt: string, + filters?: Record, + pageLength?: number + ): Promise> { + const params: Record = { + doctype, + txt, + page_length: String(pageLength ?? 20), + }; + if (filters) { + params.filters = JSON.stringify(filters); + } + const result = await this.callGetMethod( + "frappe.desk.search.search_link", + params + ); + return result as Array<{ value: string; description: string }>; + } + + // ── Report ──────────────────────────────────────────────── + + async runReport( + reportName: string, + filters?: Record + ): Promise { + return this.callMethod("frappe.desk.query_report.run", { + report_name: reportName, + filters: filters ?? {}, + }); + } + + // ── File upload ─────────────────────────────────────────── + + async getFileUrl(fileName: string): Promise { + return `${this.baseUrl}/api/method/frappe.client.get_file?file_name=${encodeURIComponent(fileName)}`; + } + + // ── Print format ────────────────────────────────────────── + + async getPrintFormat( + doctype: string, + name: string, + printFormat?: string + ): Promise { + const params: Record = { + doctype, + name, + format: printFormat ?? "Standard", + }; + const result = await this.callGetMethod( + "frappe.utils.print_format.download_pdf", + params + ); + return result as string; + } + + // ── Get count ───────────────────────────────────────────── + + async getCount( + doctype: string, + filters?: Array<[string, string, string, string | number | boolean]> + ): Promise { + const params: Record = { doctype }; + if (filters?.length) { + params.filters = JSON.stringify(filters); + } + const result = await this.callGetMethod("frappe.client.get_count", params); + return result as number; + } + + // ── Get list of DocTypes ────────────────────────────────── + + async getDocTypeList(): Promise { + const result = await this.listDocuments("DocType", { + fields: ["name"], + limit_page_length: 500, + order_by: "name asc", + }); + return result.items.map((d) => d.name as string); + } + + // ── Get DocType schema/meta ─────────────────────────────── + + async getDocTypeMeta( + doctype: string + ): Promise> { + const result = await this.callGetMethod("frappe.client.get_list", { + doctype: "DocField", + filters: JSON.stringify([["parent", "=", doctype]]), + fields: JSON.stringify([ + "fieldname", + "fieldtype", + "label", + "options", + "reqd", + "read_only", + "hidden", + ]), + limit_page_length: "500", + order_by: "idx asc", + }); + return { doctype, fields: result }; + } +} + +// ── Response Formatting Helpers ─────────────────────────── + +export function truncateResponse(text: string): string { + if (text.length <= CHARACTER_LIMIT) return text; + return ( + text.slice(0, CHARACTER_LIMIT) + + `\n\n--- Response truncated (${text.length} chars). Use filters, pagination, or narrower field selection to get more targeted results. ---` + ); +} + +export function formatDocumentMarkdown( + doc: Record, + doctype?: string +): string { + const lines: string[] = []; + if (doctype) { + lines.push(`# ${doctype}: ${doc.name ?? "Unknown"}`); + } else { + lines.push(`# ${doc.name ?? "Document"}`); + } + lines.push(""); + + for (const [key, value] of Object.entries(doc)) { + if ( + value === null || + value === undefined || + value === "" || + key.startsWith("_") + ) + continue; + if (Array.isArray(value)) { + lines.push(`**${key}**: [${value.length} items]`); + } else if (typeof value === "object") { + lines.push(`**${key}**: ${JSON.stringify(value)}`); + } else { + lines.push(`**${key}**: ${value}`); + } + } + return lines.join("\n"); +} + +export function formatListMarkdown( + items: Record[], + doctype: string, + pagination: { total: number; offset: number; has_more: boolean } +): string { + const lines: string[] = [ + `# ${doctype} List`, + `Showing ${items.length} of ${pagination.total} (offset: ${pagination.offset})`, + "", + ]; + + for (const item of items) { + const name = item.name ?? "unnamed"; + const summary = Object.entries(item) + .filter(([k]) => k !== "name") + .map(([k, v]) => `${k}: ${v}`) + .join(" | "); + lines.push(`- **${name}**${summary ? ` — ${summary}` : ""}`); + } + + if (pagination.has_more) { + lines.push( + "", + `_More results available. Use offset=${pagination.offset + items.length} to see next page._` + ); + } + + return lines.join("\n"); +}