Add mcp-gateway/erpnext-mcp/src/services/erpnext-client.ts

This commit is contained in:
Zac Gaetano 2026-03-31 15:29:33 -04:00
parent af28ff9809
commit f4e891f0d3

View file

@ -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<T>(
path: string,
method: string = "GET",
body?: Record<string, unknown>,
params?: Record<string, string>
): Promise<T> {
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<string, string> = {
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<PaginatedResult<Record<string, unknown>>> {
const params: Record<string, string> = {};
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<string, unknown>[] }>(
`${RESOURCE_PATH}/${encodeURIComponent(doctype)}`,
"GET",
undefined,
params
);
// Get total count
const countParams: Record<string, string> = {};
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<Record<string, unknown>> {
const params: Record<string, string> = {};
if (fields?.length) {
params.fields = JSON.stringify(fields);
}
const result = await this.request<{ data: Record<string, unknown> }>(
`${RESOURCE_PATH}/${encodeURIComponent(doctype)}/${encodeURIComponent(name)}`,
"GET",
undefined,
params
);
return result.data;
}
async createDocument(
doctype: string,
data: Record<string, unknown>
): Promise<Record<string, unknown>> {
const result = await this.request<{ data: Record<string, unknown> }>(
`${RESOURCE_PATH}/${encodeURIComponent(doctype)}`,
"POST",
data
);
return result.data;
}
async updateDocument(
doctype: string,
name: string,
data: Record<string, unknown>
): Promise<Record<string, unknown>> {
const result = await this.request<{ data: Record<string, unknown> }>(
`${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<Record<string, unknown>> {
return this.updateDocument(doctype, name, { docstatus: 1 });
}
async cancelDocument(
doctype: string,
name: string
): Promise<Record<string, unknown>> {
return this.updateDocument(doctype, name, { docstatus: 2 });
}
async amendDocument(
doctype: string,
name: string
): Promise<Record<string, unknown>> {
const result = await this.callMethod("frappe.client.amend", {
doctype,
name,
});
return result as Record<string, unknown>;
}
// ── Method Calls ──────────────────────────────────────────
async callMethod(
method: string,
args?: Record<string, unknown>
): Promise<unknown> {
const result = await this.request<{ message: unknown }>(
`${METHOD_PATH}/${method}`,
"POST",
args
);
return result.message ?? result;
}
async callGetMethod(
method: string,
params?: Record<string, string>
): Promise<unknown> {
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<string, unknown>,
pageLength?: number
): Promise<Array<{ value: string; description: string }>> {
const params: Record<string, string> = {
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<string, unknown>
): Promise<unknown> {
return this.callMethod("frappe.desk.query_report.run", {
report_name: reportName,
filters: filters ?? {},
});
}
// ── File upload ───────────────────────────────────────────
async getFileUrl(fileName: string): Promise<string> {
return `${this.baseUrl}/api/method/frappe.client.get_file?file_name=${encodeURIComponent(fileName)}`;
}
// ── Print format ──────────────────────────────────────────
async getPrintFormat(
doctype: string,
name: string,
printFormat?: string
): Promise<string> {
const params: Record<string, string> = {
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<number> {
const params: Record<string, string> = { 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<string[]> {
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<Record<string, unknown>> {
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<string, unknown>,
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<string, unknown>[],
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");
}