Add erpnext-mcp/src/services/erpnext-client.ts
This commit is contained in:
parent
ba39e4d162
commit
ea23d7146e
1 changed files with 434 additions and 0 deletions
434
erpnext-mcp/src/services/erpnext-client.ts
Normal file
434
erpnext-mcp/src/services/erpnext-client.ts
Normal 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");
|
||||||
|
}
|
||||||
Loading…
Reference in a new issue