Remove mcp-gateway/erpnext-mcp/src/services/erpnext-client.ts
This commit is contained in:
parent
21f137e6ac
commit
c4948ab36d
1 changed files with 0 additions and 434 deletions
|
|
@ -1,434 +0,0 @@
|
||||||
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