@casys/mcp-erpnext 2.4.2 → 2.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (3) hide show
  1. package/README.md +53 -37
  2. package/mcp-erpnext.mjs +1604 -646
  3. package/package.json +1 -1
package/mcp-erpnext.mjs CHANGED
@@ -34542,7 +34542,7 @@ function isCloudflareWorkers() {
34542
34542
  var USER_AGENT;
34543
34543
  if (typeof navigator === "undefined" || !navigator.userAgent?.startsWith?.("Mozilla/5.0 ")) {
34544
34544
  const NAME = "jose";
34545
- const VERSION2 = "v6.2.3";
34545
+ const VERSION2 = "v6.2.4";
34546
34546
  USER_AGENT = `${NAME}/${VERSION2}`;
34547
34547
  }
34548
34548
  var customFetch = Symbol();
@@ -37314,6 +37314,65 @@ function defaultHumanName(name) {
37314
37314
  return name.split("-").map((w) => w.charAt(0).toUpperCase() + w.slice(1)).join(" ");
37315
37315
  }
37316
37316
 
37317
+ // node_modules/@casys/mcp-server/src/auth/static-token-provider.ts
37318
+ var StaticTokenAuthProvider = class extends AuthProvider {
37319
+ tokens;
37320
+ authInfo;
37321
+ resource;
37322
+ resourceMetadataUrl;
37323
+ scopesSupported;
37324
+ constructor(tokens, options) {
37325
+ super();
37326
+ if (tokens.length === 0) {
37327
+ throw new Error(
37328
+ "[StaticTokenAuthProvider] `tokens` must contain at least one token"
37329
+ );
37330
+ }
37331
+ if (tokens.some((t) => t.trim().length === 0)) {
37332
+ throw new Error(
37333
+ "[StaticTokenAuthProvider] `tokens` must not contain empty entries"
37334
+ );
37335
+ }
37336
+ if (!options.resource?.trim()) {
37337
+ throw new Error("[StaticTokenAuthProvider] `resource` is required");
37338
+ }
37339
+ const resourceUrl = httpsUrl(options.resource);
37340
+ this.tokens = new Set(tokens.map((t) => t.trim()));
37341
+ const scopes = Object.freeze([...options.scopes ?? []]);
37342
+ this.authInfo = Object.freeze({
37343
+ subject: options.subject ?? "static-token-user",
37344
+ scopes
37345
+ });
37346
+ this.resource = options.resource;
37347
+ this.scopesSupported = options.scopesSupported ? Object.freeze([...options.scopesSupported]) : scopes.length > 0 ? scopes : void 0;
37348
+ if (options.resourceMetadataUrl?.trim()) {
37349
+ this.resourceMetadataUrl = httpsUrl(options.resourceMetadataUrl);
37350
+ } else {
37351
+ const parsed = new URL(resourceUrl);
37352
+ const pathPart = parsed.pathname === "/" ? "" : parsed.pathname;
37353
+ this.resourceMetadataUrl = httpsUrl(
37354
+ `${parsed.origin}/.well-known/oauth-protected-resource${pathPart}${parsed.search}`
37355
+ );
37356
+ }
37357
+ }
37358
+ async verifyToken(token) {
37359
+ return this.tokens.has(token.trim()) ? this.authInfo : null;
37360
+ }
37361
+ getResourceMetadata() {
37362
+ return {
37363
+ resource: this.resource,
37364
+ resource_metadata_url: this.resourceMetadataUrl,
37365
+ // No authorization server: static tokens are provisioned out of band.
37366
+ authorization_servers: [],
37367
+ scopes_supported: this.scopesSupported,
37368
+ bearer_methods_supported: ["header"]
37369
+ };
37370
+ }
37371
+ };
37372
+ function createStaticTokenAuthProvider(tokens, options) {
37373
+ return new StaticTokenAuthProvider(tokens, options);
37374
+ }
37375
+
37317
37376
  // node_modules/@casys/mcp-server/src/inspector/launcher.ts
37318
37377
  async function launchInspector(serverCommand, serverArgs, options) {
37319
37378
  const { spawn } = await import("node:child_process");
@@ -37389,97 +37448,714 @@ var KANBAN_META = viewer("kanban-viewer");
37389
37448
  var KPI_META = viewer("kpi-viewer");
37390
37449
  var FUNNEL_META = viewer("funnel-viewer");
37391
37450
 
37392
- // src/tools/sales.ts
37393
- function mapLineItems(items, options) {
37394
- if (!Array.isArray(items) || items.length === 0) {
37451
+ // src/runtime.ts
37452
+ var isDeno2 = typeof globalThis.Deno?.version?.deno === "string";
37453
+ var impl2 = isDeno2 ? await Promise.resolve().then(() => (init_runtime_deno2(), runtime_deno_exports2)) : await Promise.resolve().then(() => (init_runtime_node2(), runtime_node_exports2));
37454
+ var env6 = impl2.env;
37455
+ var readTextFile6 = impl2.readTextFile;
37456
+ var statSync3 = impl2.statSync;
37457
+ var readDirSync3 = impl2.readDirSync;
37458
+ var getArgs3 = impl2.getArgs;
37459
+ var exit3 = impl2.exit;
37460
+ var onSignal3 = impl2.onSignal;
37461
+
37462
+ // src/cache/memory.ts
37463
+ var MemoryCache = class {
37464
+ store = /* @__PURE__ */ new Map();
37465
+ get(key) {
37466
+ const entry = this.store.get(key);
37467
+ if (!entry) return void 0;
37468
+ if (Date.now() >= entry.expiresAt) {
37469
+ this.store.delete(key);
37470
+ return void 0;
37471
+ }
37472
+ return structuredClone(entry.value);
37473
+ }
37474
+ set(key, value, ttlMs) {
37475
+ this.store.set(key, { value, expiresAt: Date.now() + ttlMs });
37476
+ }
37477
+ delete(key) {
37478
+ this.store.delete(key);
37479
+ }
37480
+ deleteByPrefix(prefix) {
37481
+ for (const key of this.store.keys()) {
37482
+ if (key.startsWith(prefix)) this.store.delete(key);
37483
+ }
37484
+ }
37485
+ clear() {
37486
+ this.store.clear();
37487
+ }
37488
+ };
37489
+
37490
+ // src/cache/noop.ts
37491
+ var NoopCache = class {
37492
+ get(_key) {
37493
+ return void 0;
37494
+ }
37495
+ set(_key, _value, _ttlMs) {
37496
+ }
37497
+ delete(_key) {
37498
+ }
37499
+ deleteByPrefix(_prefix) {
37500
+ }
37501
+ clear() {
37502
+ }
37503
+ };
37504
+
37505
+ // src/cache/cache.ts
37506
+ var DEFAULT_CACHE_TTL_MS = 15e3;
37507
+ var _cache = null;
37508
+ function getCacheTtlMs() {
37509
+ const raw2 = env6("MCP_CACHE_TTL_MS");
37510
+ if (raw2 === void 0) return DEFAULT_CACHE_TTL_MS;
37511
+ const parsed = Number(raw2);
37512
+ if (!Number.isFinite(parsed) || parsed < 0) {
37513
+ console.error(
37514
+ `[mcp-erpnext] Invalid MCP_CACHE_TTL_MS=${JSON.stringify(raw2)} \u2014 must be a non-negative number. Falling back to default (${DEFAULT_CACHE_TTL_MS}ms).`
37515
+ );
37516
+ return DEFAULT_CACHE_TTL_MS;
37517
+ }
37518
+ return parsed;
37519
+ }
37520
+ function getCache() {
37521
+ if (_cache) return _cache;
37522
+ const enabled = env6("MCP_CACHE_ENABLED") !== "false";
37523
+ _cache = enabled ? new MemoryCache() : new NoopCache();
37524
+ return _cache;
37525
+ }
37526
+
37527
+ // src/api/frappe-client.ts
37528
+ function stableStringify(value) {
37529
+ if (Array.isArray(value)) {
37530
+ return `[${value.map(stableStringify).join(",")}]`;
37531
+ }
37532
+ if (value !== null && typeof value === "object") {
37533
+ const keys = Object.keys(value).sort();
37534
+ return `{${keys.map(
37535
+ (k) => `${JSON.stringify(k)}:${stableStringify(value[k])}`
37536
+ ).join(",")}}`;
37537
+ }
37538
+ return JSON.stringify(value);
37539
+ }
37540
+ var DEFAULT_RETRY_STATUSES = [408, 429, 502, 503, 504];
37541
+ var DEFAULT_RETRY_METHODS = ["GET"];
37542
+ var DEFAULT_MAX_UPLOAD_BYTES = 10 * 1024 * 1024;
37543
+ function decodeBase64File(contentBase64, maxBytes) {
37544
+ if (contentBase64.length === 0) {
37545
+ throw new Error("[FrappeClient] File content must not be empty");
37546
+ }
37547
+ const unpadded = contentBase64.replace(/=+$/, "");
37548
+ const padding = contentBase64.slice(unpadded.length);
37549
+ if (!/^[A-Za-z0-9+/]+$/.test(unpadded) || !/^={0,2}$/.test(padding) || unpadded.length % 4 === 1) {
37550
+ throw new Error("[FrappeClient] File content must be valid base64");
37551
+ }
37552
+ const decodedSize = Math.floor(unpadded.length * 6 / 8);
37553
+ if (decodedSize > maxBytes) {
37395
37554
  throw new Error(
37396
- `[${options.toolName}] 'items' must be a non-empty array`
37555
+ `[FrappeClient] Decoded file size ${decodedSize} bytes exceeds the ${maxBytes}-byte upload limit`
37397
37556
  );
37398
37557
  }
37399
- return items.map((item) => {
37400
- if (!item.item_code || item.qty == null || item.rate == null) {
37558
+ const normalized = unpadded + "=".repeat((4 - unpadded.length % 4) % 4);
37559
+ let binary;
37560
+ try {
37561
+ binary = atob(normalized);
37562
+ } catch {
37563
+ throw new Error("[FrappeClient] File content must be valid base64");
37564
+ }
37565
+ if (binary.length === 0) {
37566
+ throw new Error("[FrappeClient] File content must not be empty");
37567
+ }
37568
+ return Uint8Array.from(binary, (character) => character.charCodeAt(0));
37569
+ }
37570
+ var FrappeAPIError = class extends Error {
37571
+ /**
37572
+ * @param message - Human-readable error description
37573
+ * @param status - HTTP status code (0 for network errors, 408 for timeouts)
37574
+ * @param body - Raw response body (parsed JSON object or plain text string)
37575
+ * @param retryAfterMs - When the server sent a `Retry-After` header on a
37576
+ * retryable status (typically 429), the parsed delay
37577
+ * in ms. Used by the retry loop; absent otherwise.
37578
+ */
37579
+ constructor(message2, status, body, retryAfterMs) {
37580
+ super(`[FrappeClient] ${message2} (HTTP ${status})`);
37581
+ this.status = status;
37582
+ this.body = body;
37583
+ this.retryAfterMs = retryAfterMs;
37584
+ this.name = "FrappeAPIError";
37585
+ }
37586
+ };
37587
+ function parseRetryAfter(raw2) {
37588
+ if (!raw2) return void 0;
37589
+ const trimmed = raw2.trim();
37590
+ const seconds = Number(trimmed);
37591
+ if (Number.isFinite(seconds) && seconds >= 0) {
37592
+ return Math.round(seconds * 1e3);
37593
+ }
37594
+ const date3 = Date.parse(trimmed);
37595
+ if (Number.isFinite(date3)) {
37596
+ return Math.max(0, date3 - Date.now());
37597
+ }
37598
+ return void 0;
37599
+ }
37600
+ function extractServerMessages(raw2) {
37601
+ if (!raw2 || typeof raw2 !== "string") return void 0;
37602
+ try {
37603
+ const outer = JSON.parse(raw2);
37604
+ if (!Array.isArray(outer)) return void 0;
37605
+ const msgs = [];
37606
+ for (const item of outer) {
37607
+ try {
37608
+ const inner = JSON.parse(item);
37609
+ if (typeof inner === "object" && inner?.message) {
37610
+ msgs.push(inner.message);
37611
+ } else if (typeof inner === "string") {
37612
+ msgs.push(inner);
37613
+ }
37614
+ } catch {
37615
+ if (typeof item === "string") msgs.push(item);
37616
+ }
37617
+ }
37618
+ return msgs.length > 0 ? msgs.join("; ") : void 0;
37619
+ } catch {
37620
+ return void 0;
37621
+ }
37622
+ }
37623
+ var FrappeClient = class {
37624
+ baseUrl;
37625
+ authHeader;
37626
+ timeoutMs;
37627
+ maxUploadBytes;
37628
+ retries;
37629
+ retryStatuses;
37630
+ retryBackoffMs;
37631
+ retryMethods;
37632
+ cache;
37633
+ constructor(config2) {
37634
+ this.baseUrl = config2.baseUrl.replace(/\/$/, "");
37635
+ this.authHeader = `token ${config2.apiKey}:${config2.apiSecret}`;
37636
+ this.timeoutMs = config2.timeoutMs ?? 3e4;
37637
+ this.maxUploadBytes = config2.maxUploadBytes ?? DEFAULT_MAX_UPLOAD_BYTES;
37638
+ if (!Number.isInteger(this.maxUploadBytes) || this.maxUploadBytes <= 0) {
37401
37639
  throw new Error(
37402
- `[${options.toolName}] Each item must have item_code, qty, and rate`
37640
+ "[FrappeClient] maxUploadBytes must be a positive integer"
37403
37641
  );
37404
37642
  }
37405
- const mapped = {
37406
- item_code: item.item_code,
37407
- qty: item.qty,
37408
- rate: item.rate
37643
+ this.retries = config2.retries ?? 3;
37644
+ this.retryStatuses = config2.retryStatuses ?? DEFAULT_RETRY_STATUSES;
37645
+ this.retryBackoffMs = config2.retryBackoffMs ?? 200;
37646
+ this.retryMethods = config2.retryMethods ?? DEFAULT_RETRY_METHODS;
37647
+ this.cache = config2.cache ?? new MemoryCache();
37648
+ }
37649
+ // ── Private HTTP helpers ────────────────────────────────────────────────────
37650
+ buildHeaders(includeJsonContentType = true) {
37651
+ const headers = {
37652
+ "Authorization": this.authHeader,
37653
+ "Accept": "application/json"
37409
37654
  };
37410
- if (options.defaultDeliveryDate !== void 0) {
37411
- mapped.delivery_date = options.defaultDeliveryDate;
37655
+ if (includeJsonContentType) {
37656
+ headers["Content-Type"] = "application/json";
37412
37657
  }
37413
- if (options.includeWarehouse !== false && item.warehouse) {
37414
- mapped.warehouse = item.warehouse;
37658
+ return headers;
37659
+ }
37660
+ /**
37661
+ * Decide whether an error is worth retrying.
37662
+ * Network errors (status 0) and timeouts (status 408) are always retryable;
37663
+ * other statuses are checked against `retryStatuses`.
37664
+ */
37665
+ isRetryable(err, method) {
37666
+ if (!this.retryMethods.includes(method)) return false;
37667
+ if (!(err instanceof FrappeAPIError)) return false;
37668
+ if (err.status === 0) return true;
37669
+ return this.retryStatuses.includes(err.status);
37670
+ }
37671
+ /** Compute the backoff delay for a given attempt (0-indexed). */
37672
+ computeBackoff(attempt, err) {
37673
+ if (err instanceof FrappeAPIError && err.retryAfterMs !== void 0) {
37674
+ return err.retryAfterMs;
37415
37675
  }
37416
- return mapped;
37417
- });
37418
- }
37419
- var salesTools = [
37420
- // ── Customers ─────────────────────────────────────────────────────────────
37421
- {
37422
- name: "erpnext_customer_list",
37423
- annotations: { readOnlyHint: true },
37424
- _meta: DOCLIST_META,
37425
- description: "List ERPNext customers. Returns active customers by default. Fields: name, customer_name, customer_group, territory, email_id, disabled.",
37426
- category: "sales",
37427
- inputSchema: {
37428
- type: "object",
37429
- properties: {
37430
- limit: { type: "number", description: "Max results (default 20)" },
37431
- customer_group: {
37432
- type: "string",
37433
- description: "Filter by customer group"
37434
- },
37435
- territory: { type: "string", description: "Filter by territory" },
37436
- include_disabled: {
37437
- type: "boolean",
37438
- description: "Include disabled customers (default false)"
37676
+ return this.retryBackoffMs * 2 ** attempt;
37677
+ }
37678
+ async request(method, path, body, multipart = false) {
37679
+ let lastError;
37680
+ for (let attempt = 0; attempt <= this.retries; attempt++) {
37681
+ try {
37682
+ return await this.requestOnce(method, path, body, multipart);
37683
+ } catch (err) {
37684
+ lastError = err;
37685
+ if (attempt === this.retries || !this.isRetryable(err, method)) {
37686
+ throw err;
37687
+ }
37688
+ const delay = this.computeBackoff(attempt, err);
37689
+ if (delay > 0) {
37690
+ await new Promise((resolve) => setTimeout(resolve, delay));
37439
37691
  }
37440
37692
  }
37441
- },
37442
- handler: async (input, ctx) => {
37443
- const limit = input.limit ?? 20;
37444
- const filters = [];
37445
- if (!input.include_disabled) {
37446
- filters.push(["disabled", "=", 0]);
37447
- }
37448
- if (input.customer_group) {
37449
- filters.push(["customer_group", "=", input.customer_group]);
37450
- }
37451
- if (input.territory) {
37452
- filters.push(["territory", "=", input.territory]);
37453
- }
37454
- const docs = await ctx.client.list("Customer", {
37455
- fields: [
37456
- "name",
37457
- "customer_name",
37458
- "customer_group",
37459
- "territory",
37460
- "email_id",
37461
- "disabled"
37462
- ],
37463
- filters,
37464
- limit,
37465
- order_by: "modified desc"
37466
- });
37467
- return {
37468
- doctype: "Customer",
37469
- count: docs.length,
37470
- data: docs,
37471
- _meta: DOCLIST_META
37472
- };
37473
37693
  }
37474
- },
37475
- {
37476
- name: "erpnext_customer_get",
37477
- annotations: { readOnlyHint: true },
37478
- description: "Get a single ERPNext customer by name (ID). Returns all fields including contact details.",
37479
- category: "sales",
37480
- inputSchema: {
37481
- type: "object",
37482
- properties: {
37694
+ throw lastError;
37695
+ }
37696
+ async requestOnce(method, path, body, multipart = false) {
37697
+ const url = `${this.baseUrl}${path}`;
37698
+ const controller = new AbortController();
37699
+ const timer = setTimeout(() => controller.abort(), this.timeoutMs);
37700
+ let response;
37701
+ try {
37702
+ response = await fetch(url, {
37703
+ method,
37704
+ headers: this.buildHeaders(!multipart),
37705
+ body: body === void 0 ? void 0 : multipart ? body : JSON.stringify(body),
37706
+ signal: controller.signal
37707
+ });
37708
+ } catch (err) {
37709
+ clearTimeout(timer);
37710
+ if (err instanceof Error && err.name === "AbortError") {
37711
+ throw new FrappeAPIError(
37712
+ `Request timed out after ${this.timeoutMs}ms: ${method} ${path}`,
37713
+ 408,
37714
+ null
37715
+ );
37716
+ }
37717
+ throw new FrappeAPIError(
37718
+ `Network error on ${method} ${path}: ${err.message}`,
37719
+ 0,
37720
+ null
37721
+ );
37722
+ }
37723
+ clearTimeout(timer);
37724
+ const rawText = await response.text();
37725
+ const contentType = response.headers.get("content-type") ?? "";
37726
+ let responseBody = rawText;
37727
+ if (contentType.includes("application/json") && rawText.length > 0) {
37728
+ try {
37729
+ responseBody = JSON.parse(rawText);
37730
+ } catch {
37731
+ responseBody = rawText;
37732
+ }
37733
+ }
37734
+ if (!response.ok) {
37735
+ let msg = response.statusText;
37736
+ if (typeof responseBody === "object" && responseBody !== null) {
37737
+ const rb = responseBody;
37738
+ const excType = rb.exc_type;
37739
+ const baseMsg = rb.message ?? excType ?? response.statusText;
37740
+ const serverDetails = extractServerMessages(rb._server_messages);
37741
+ msg = serverDetails ? `${baseMsg}: ${serverDetails}` : baseMsg;
37742
+ } else if (typeof responseBody === "string" && responseBody.length > 0) {
37743
+ msg = responseBody.slice(0, 200);
37744
+ }
37745
+ const retryAfterMs = parseRetryAfter(
37746
+ response.headers.get("retry-after")
37747
+ );
37748
+ throw new FrappeAPIError(
37749
+ `${method} ${path} failed: ${msg}`,
37750
+ response.status,
37751
+ responseBody,
37752
+ retryAfterMs
37753
+ );
37754
+ }
37755
+ return responseBody;
37756
+ }
37757
+ // ── Resource CRUD ───────────────────────────────────────────────────────────
37758
+ /**
37759
+ * List documents of a DocType.
37760
+ * Frappe list API: GET /api/resource/{doctype}?fields=...&filters=...
37761
+ *
37762
+ * Pass `{ skipCache: true }` to force a fresh read — e.g. for aggregate/KPI
37763
+ * tools that read across doctypes other than the one a preceding mutation
37764
+ * invalidated (see `invalidate()` below for why that gap exists). The fresh
37765
+ * result still refreshes the cache for subsequent normal reads.
37766
+ */
37767
+ async list(doctype, options = {}, opts = {}) {
37768
+ const cacheKey = `list:${doctype}:${stableStringify(options)}`;
37769
+ if (!opts.skipCache) {
37770
+ const cached2 = this.cache.get(cacheKey);
37771
+ if (cached2 !== void 0) return cached2;
37772
+ }
37773
+ const params = new URLSearchParams();
37774
+ if (options.fields && options.fields.length > 0) {
37775
+ params.set("fields", JSON.stringify(options.fields));
37776
+ }
37777
+ if (options.filters && options.filters.length > 0) {
37778
+ params.set("filters", JSON.stringify(options.filters));
37779
+ }
37780
+ if (options.order_by) {
37781
+ params.set("order_by", options.order_by);
37782
+ }
37783
+ if (options.limit !== void 0) {
37784
+ params.set("limit", String(options.limit));
37785
+ }
37786
+ if (options.limit_start !== void 0) {
37787
+ params.set("limit_start", String(options.limit_start));
37788
+ }
37789
+ params.set("as_dict", "1");
37790
+ const query = params.toString() ? `?${params.toString()}` : "";
37791
+ const res = await this.request(
37792
+ "GET",
37793
+ `/api/resource/${encodeURIComponent(doctype)}${query}`
37794
+ );
37795
+ const docs = res.data ?? [];
37796
+ this.cache.set(cacheKey, docs, getCacheTtlMs());
37797
+ return docs;
37798
+ }
37799
+ /**
37800
+ * Get a single document by name.
37801
+ * GET /api/resource/{doctype}/{name}
37802
+ *
37803
+ * Pass `{ skipCache: true }` to force a fresh read — required before any
37804
+ * operation relying on the doc's `modified` timestamp for optimistic
37805
+ * locking (see erpnext_doc_submit/erpnext_doc_cancel in operations.ts).
37806
+ * The fresh result still refreshes the cache for subsequent normal reads.
37807
+ */
37808
+ async get(doctype, name, opts = {}) {
37809
+ const cacheKey = `get:${doctype}:${name}`;
37810
+ if (!opts.skipCache) {
37811
+ const cached2 = this.cache.get(cacheKey);
37812
+ if (cached2 !== void 0) return cached2;
37813
+ }
37814
+ const res = await this.request(
37815
+ "GET",
37816
+ `/api/resource/${encodeURIComponent(doctype)}/${encodeURIComponent(name)}`
37817
+ );
37818
+ this.cache.set(cacheKey, res.data, getCacheTtlMs());
37819
+ return res.data;
37820
+ }
37821
+ /**
37822
+ * Clear cached entries for a doctype (list results, resolveLink's
37823
+ * negative-match cache) and, if `name` is given, the cached single-document
37824
+ * read too. Called automatically after create/update/delete; call explicitly
37825
+ * after any mutation that bypasses those methods (e.g.
37826
+ * frappe.client.submit/cancel via callMethod).
37827
+ *
37828
+ * Known limitation: this only clears the mutated doctype. Frappe mutations
37829
+ * commonly cascade — submitting a Sales Order also writes Bin/GL
37830
+ * Entry/Sales Invoice rows — and those doctypes' cached `list()` results
37831
+ * aren't invalidated here. Aggregate/KPI tools that read across doctypes
37832
+ * can therefore serve up-to-TTL-stale numbers right after a mutation; pass
37833
+ * `{ skipCache: true }` to `list()` in those tools if that staleness isn't
37834
+ * acceptable for a given call site.
37835
+ */
37836
+ invalidate(doctype, name) {
37837
+ this.cache.deleteByPrefix(`list:${doctype}:`);
37838
+ this.cache.deleteByPrefix(`resolve:miss:${doctype}:`);
37839
+ if (name) this.cache.delete(`get:${doctype}:${name}`);
37840
+ }
37841
+ /**
37842
+ * Create a new document.
37843
+ * POST /api/resource/{doctype}
37844
+ */
37845
+ async create(doctype, data) {
37846
+ const res = await this.request(
37847
+ "POST",
37848
+ `/api/resource/${encodeURIComponent(doctype)}`,
37849
+ { data: { ...data, doctype } }
37850
+ );
37851
+ this.invalidate(doctype, res.data.name);
37852
+ return res.data;
37853
+ }
37854
+ /**
37855
+ * Update an existing document (partial update).
37856
+ * PUT /api/resource/{doctype}/{name}
37857
+ */
37858
+ async update(doctype, name, data) {
37859
+ const res = await this.request(
37860
+ "PUT",
37861
+ `/api/resource/${encodeURIComponent(doctype)}/${encodeURIComponent(name)}`,
37862
+ { data }
37863
+ );
37864
+ this.invalidate(doctype, name);
37865
+ return res.data;
37866
+ }
37867
+ /**
37868
+ * Delete a document.
37869
+ * DELETE /api/resource/{doctype}/{name}
37870
+ */
37871
+ async delete(doctype, name) {
37872
+ await this.request(
37873
+ "DELETE",
37874
+ `/api/resource/${encodeURIComponent(doctype)}/${encodeURIComponent(name)}`
37875
+ );
37876
+ this.invalidate(doctype, name);
37877
+ }
37878
+ /**
37879
+ * Upload file bytes and attach the native File document to another document.
37880
+ * POST /api/method/upload_file
37881
+ */
37882
+ async uploadFile(input) {
37883
+ const fileName = input.fileName.trim();
37884
+ const attachedToDoctype = input.attachedToDoctype.trim();
37885
+ const attachedToName = input.attachedToName.trim();
37886
+ const attachedToField = input.attachedToField?.trim();
37887
+ if (!fileName || /[\\/\0]/.test(fileName)) {
37888
+ throw new Error(
37889
+ "[FrappeClient] fileName must be a filename without path separators"
37890
+ );
37891
+ }
37892
+ if (!attachedToDoctype) {
37893
+ throw new Error("[FrappeClient] attachedToDoctype must not be empty");
37894
+ }
37895
+ if (!attachedToName) {
37896
+ throw new Error("[FrappeClient] attachedToName must not be empty");
37897
+ }
37898
+ const bytes = decodeBase64File(
37899
+ input.contentBase64,
37900
+ this.maxUploadBytes
37901
+ );
37902
+ const fileBuffer = new ArrayBuffer(bytes.byteLength);
37903
+ new Uint8Array(fileBuffer).set(bytes);
37904
+ const form = new FormData();
37905
+ form.append("file", new Blob([fileBuffer]), fileName);
37906
+ form.append("doctype", attachedToDoctype);
37907
+ form.append("docname", attachedToName);
37908
+ if (attachedToField) {
37909
+ form.append("fieldname", attachedToField);
37910
+ }
37911
+ form.append("is_private", input.isPrivate === false ? "0" : "1");
37912
+ const res = await this.request(
37913
+ "POST",
37914
+ "/api/method/upload_file",
37915
+ form,
37916
+ true
37917
+ );
37918
+ const file = res.message;
37919
+ this.invalidate("File", file.name);
37920
+ this.invalidate(attachedToDoctype, attachedToName);
37921
+ if (attachedToField) {
37922
+ await this.update(attachedToDoctype, attachedToName, {
37923
+ [attachedToField]: file.file_url
37924
+ });
37925
+ }
37926
+ return file;
37927
+ }
37928
+ /**
37929
+ * Call a whitelisted Frappe method.
37930
+ * POST /api/method/{method}
37931
+ */
37932
+ async callMethod(method, args = {}) {
37933
+ const res = await this.request(
37934
+ "POST",
37935
+ `/api/method/${method}`,
37936
+ args
37937
+ );
37938
+ return res.message;
37939
+ }
37940
+ };
37941
+ var _client = null;
37942
+ function getFrappeClient() {
37943
+ if (_client) return _client;
37944
+ const url = env6("ERPNEXT_URL");
37945
+ const apiKey = env6("ERPNEXT_API_KEY");
37946
+ const apiSecret = env6("ERPNEXT_API_SECRET");
37947
+ const maxUploadBytesRaw = env6("ERPNEXT_MAX_UPLOAD_BYTES");
37948
+ if (!url) {
37949
+ throw new Error(
37950
+ "[lib/erpnext] ERPNEXT_URL is required. Set it to your ERPNext instance URL, e.g. http://localhost:8000"
37951
+ );
37952
+ }
37953
+ if (!apiKey || !apiSecret) {
37954
+ throw new Error(
37955
+ "[lib/erpnext] ERPNEXT_API_KEY and ERPNEXT_API_SECRET are required. Generate them in ERPNext: User Settings \u2192 API Access."
37956
+ );
37957
+ }
37958
+ _client = new FrappeClient({
37959
+ baseUrl: url,
37960
+ apiKey,
37961
+ apiSecret,
37962
+ cache: getCache(),
37963
+ maxUploadBytes: maxUploadBytesRaw?.trim() ? Number(maxUploadBytesRaw) : void 0
37964
+ });
37965
+ return _client;
37966
+ }
37967
+
37968
+ // src/api/resolve.ts
37969
+ var NEGATIVE_CACHE_TTL_MS = 15e3;
37970
+ var EXACT_MATCH_PROBE_LIMIT = 2;
37971
+ var PARTIAL_MATCH_PROBE_LIMIT = 5;
37972
+ async function resolveUnique(client, doctype, identifier, searchField, op, value, probeLimit, ambiguityHint) {
37973
+ const rows = await client.list(doctype, {
37974
+ filters: [[searchField, op, value]],
37975
+ fields: ["name", searchField],
37976
+ limit: probeLimit
37977
+ });
37978
+ if (rows.length === 0) return void 0;
37979
+ if (rows.length === 1) return rows[0].name;
37980
+ const candidates = rows.map((r) => `${r.name} (${r[searchField]})`).join(
37981
+ ", "
37982
+ );
37983
+ const suffix = rows.length === probeLimit ? ", and possibly more" : "";
37984
+ throw new Error(
37985
+ `[resolveLink] Ambiguous ${doctype} identifier "${identifier}": did you mean ${candidates}${suffix}? ${ambiguityHint}`
37986
+ );
37987
+ }
37988
+ async function resolveLink(client, doctype, identifier, searchField, options = {}) {
37989
+ const { allowPartialMatch = true } = options;
37990
+ const cache2 = getCache();
37991
+ const missKey = `resolve:miss:${doctype}:${identifier}`;
37992
+ if (cache2.get(missKey) === void 0) {
37993
+ try {
37994
+ await client.get(doctype, identifier);
37995
+ return identifier;
37996
+ } catch (e) {
37997
+ if (!(e instanceof FrappeAPIError) || e.status !== 404) throw e;
37998
+ cache2.set(missKey, true, NEGATIVE_CACHE_TTL_MS);
37999
+ }
38000
+ }
38001
+ const exact = await resolveUnique(
38002
+ client,
38003
+ doctype,
38004
+ identifier,
38005
+ searchField,
38006
+ "=",
38007
+ identifier,
38008
+ EXACT_MATCH_PROBE_LIMIT,
38009
+ "Please pass the record's ID directly."
38010
+ );
38011
+ if (exact !== void 0) return exact;
38012
+ if (allowPartialMatch) {
38013
+ const partial2 = await resolveUnique(
38014
+ client,
38015
+ doctype,
38016
+ identifier,
38017
+ searchField,
38018
+ "like",
38019
+ `%${identifier}%`,
38020
+ PARTIAL_MATCH_PROBE_LIMIT,
38021
+ "Please pass an exact value."
38022
+ );
38023
+ if (partial2 !== void 0) return partial2;
38024
+ }
38025
+ throw new Error(`[resolveLink] No ${doctype} found matching "${identifier}"`);
38026
+ }
38027
+ function resolveEmployee(client, identifier) {
38028
+ return resolveLink(client, "Employee", identifier, "employee_name");
38029
+ }
38030
+ function resolveCustomer(client, identifier) {
38031
+ return resolveLink(client, "Customer", identifier, "customer_name");
38032
+ }
38033
+ function resolveSupplier(client, identifier) {
38034
+ return resolveLink(client, "Supplier", identifier, "supplier_name");
38035
+ }
38036
+ function resolveItem(client, identifier) {
38037
+ return resolveLink(client, "Item", identifier, "item_name");
38038
+ }
38039
+ var DYNAMIC_LINK_SEARCH_FIELDS = {
38040
+ Customer: "customer_name",
38041
+ Supplier: "supplier_name",
38042
+ Employee: "employee_name",
38043
+ Lead: "lead_name"
38044
+ };
38045
+ async function resolveDynamicLink(client, targetDoctype, identifier, options = {}) {
38046
+ const searchField = DYNAMIC_LINK_SEARCH_FIELDS[targetDoctype];
38047
+ if (!searchField) return identifier;
38048
+ return resolveLink(client, targetDoctype, identifier, searchField, options);
38049
+ }
38050
+
38051
+ // src/tools/submit-helpers.ts
38052
+ function withRoundedTotalFallback(doc) {
38053
+ const hasNullRoundedTotal = "base_rounded_total" in doc && doc.base_rounded_total == null || "rounded_total" in doc && doc.rounded_total == null;
38054
+ if (!hasNullRoundedTotal || doc.disable_rounded_total) {
38055
+ return doc;
38056
+ }
38057
+ return { ...doc, disable_rounded_total: 1 };
38058
+ }
38059
+ function roundedTotalFallbackWarning(original, patched) {
38060
+ if (patched.disable_rounded_total === 1 && original.disable_rounded_total !== 1) {
38061
+ return [
38062
+ "disable_rounded_total auto-set \u2014 rounded totals were null (rounding not configured on this instance)"
38063
+ ];
38064
+ }
38065
+ return [];
38066
+ }
38067
+
38068
+ // src/tools/sales.ts
38069
+ function mapLineItems(items, options) {
38070
+ if (!Array.isArray(items) || items.length === 0) {
38071
+ throw new Error(
38072
+ `[${options.toolName}] 'items' must be a non-empty array`
38073
+ );
38074
+ }
38075
+ return items.map((item) => {
38076
+ if (!item.item_code || item.qty == null || item.rate == null) {
38077
+ throw new Error(
38078
+ `[${options.toolName}] Each item must have item_code, qty, and rate`
38079
+ );
38080
+ }
38081
+ const mapped = {
38082
+ item_code: item.item_code,
38083
+ qty: item.qty,
38084
+ rate: item.rate
38085
+ };
38086
+ if (options.defaultDeliveryDate !== void 0) {
38087
+ mapped.delivery_date = options.defaultDeliveryDate;
38088
+ }
38089
+ if (options.includeWarehouse !== false && item.warehouse) {
38090
+ mapped.warehouse = item.warehouse;
38091
+ }
38092
+ return mapped;
38093
+ });
38094
+ }
38095
+ var salesTools = [
38096
+ // ── Customers ─────────────────────────────────────────────────────────────
38097
+ {
38098
+ name: "erpnext_customer_list",
38099
+ annotations: { readOnlyHint: true },
38100
+ _meta: DOCLIST_META,
38101
+ description: "List ERPNext customers. Returns active customers by default. Fields: name, customer_name, customer_group, territory, email_id, disabled.",
38102
+ category: "sales",
38103
+ inputSchema: {
38104
+ type: "object",
38105
+ properties: {
38106
+ limit: { type: "number", description: "Max results (default 20)" },
38107
+ customer_group: {
38108
+ type: "string",
38109
+ description: "Filter by customer group"
38110
+ },
38111
+ territory: { type: "string", description: "Filter by territory" },
38112
+ include_disabled: {
38113
+ type: "boolean",
38114
+ description: "Include disabled customers (default false)"
38115
+ }
38116
+ }
38117
+ },
38118
+ handler: async (input, ctx) => {
38119
+ const limit = input.limit ?? 20;
38120
+ const filters = [];
38121
+ if (!input.include_disabled) {
38122
+ filters.push(["disabled", "=", 0]);
38123
+ }
38124
+ if (input.customer_group) {
38125
+ filters.push(["customer_group", "=", input.customer_group]);
38126
+ }
38127
+ if (input.territory) {
38128
+ filters.push(["territory", "=", input.territory]);
38129
+ }
38130
+ const docs = await ctx.client.list("Customer", {
38131
+ fields: [
38132
+ "name",
38133
+ "customer_name",
38134
+ "customer_group",
38135
+ "territory",
38136
+ "email_id",
38137
+ "disabled"
38138
+ ],
38139
+ filters,
38140
+ limit,
38141
+ order_by: "modified desc"
38142
+ });
38143
+ return {
38144
+ doctype: "Customer",
38145
+ count: docs.length,
38146
+ data: docs,
38147
+ _meta: DOCLIST_META
38148
+ };
38149
+ }
38150
+ },
38151
+ {
38152
+ name: "erpnext_customer_get",
38153
+ annotations: { readOnlyHint: true },
38154
+ description: "Get a single ERPNext customer by name (ID). Returns all fields including contact details.",
38155
+ category: "sales",
38156
+ inputSchema: {
38157
+ type: "object",
38158
+ properties: {
37483
38159
  name: { type: "string", description: "Customer name (ID)" }
37484
38160
  },
37485
38161
  required: ["name"]
@@ -37592,7 +38268,10 @@ var salesTools = [
37592
38268
  type: "object",
37593
38269
  properties: {
37594
38270
  limit: { type: "number", description: "Max results (default 20)" },
37595
- customer: { type: "string", description: "Filter by customer" },
38271
+ customer: {
38272
+ type: "string",
38273
+ description: "Filter by customer ID or name (e.g. 'CUST-00001' or 'Acme Corp')"
38274
+ },
37596
38275
  status: {
37597
38276
  type: "string",
37598
38277
  description: "Filter by status (Draft, To Deliver and Bill, Completed, Cancelled, etc.)"
@@ -37608,7 +38287,11 @@ var salesTools = [
37608
38287
  const limit = input.limit ?? 20;
37609
38288
  const filters = [];
37610
38289
  if (input.customer) {
37611
- filters.push(["customer", "=", input.customer]);
38290
+ filters.push([
38291
+ "customer",
38292
+ "=",
38293
+ await resolveCustomer(ctx.client, input.customer)
38294
+ ]);
37612
38295
  }
37613
38296
  if (input.status) {
37614
38297
  filters.push(["status", "=", input.status]);
@@ -37822,13 +38505,20 @@ var salesTools = [
37822
38505
  if (!input.name) {
37823
38506
  throw new Error("[erpnext_sales_order_submit] 'name' is required");
37824
38507
  }
37825
- const doc = await ctx.client.get("Sales Order", input.name);
38508
+ const doc = await ctx.client.get("Sales Order", input.name, {
38509
+ skipCache: true
38510
+ });
38511
+ const docWithDoctype = { ...doc, doctype: "Sales Order" };
38512
+ const patchedDoc = withRoundedTotalFallback(docWithDoctype);
37826
38513
  const result = await ctx.client.callMethod("frappe.client.submit", {
37827
- doc: { ...doc, doctype: "Sales Order" }
38514
+ doc: patchedDoc
37828
38515
  });
38516
+ ctx.client.invalidate("Sales Order", input.name);
38517
+ const warnings = roundedTotalFallbackWarning(docWithDoctype, patchedDoc);
37829
38518
  return {
37830
38519
  data: result,
37831
- message: `Sales Order ${input.name} submitted successfully`
38520
+ message: `Sales Order ${input.name} submitted successfully`,
38521
+ ...warnings.length > 0 ? { warnings } : {}
37832
38522
  };
37833
38523
  }
37834
38524
  },
@@ -37855,6 +38545,7 @@ var salesTools = [
37855
38545
  doctype: "Sales Order",
37856
38546
  name: input.name
37857
38547
  });
38548
+ ctx.client.invalidate("Sales Order", input.name);
37858
38549
  return {
37859
38550
  data: result,
37860
38551
  message: `Sales Order ${input.name} cancelled successfully`
@@ -37872,7 +38563,10 @@ var salesTools = [
37872
38563
  type: "object",
37873
38564
  properties: {
37874
38565
  limit: { type: "number", description: "Max results (default 20)" },
37875
- customer: { type: "string", description: "Filter by customer" },
38566
+ customer: {
38567
+ type: "string",
38568
+ description: "Filter by customer ID or name (e.g. 'CUST-00001' or 'Acme Corp')"
38569
+ },
37876
38570
  status: {
37877
38571
  type: "string",
37878
38572
  description: "Filter by status (Draft, Unpaid, Paid, Overdue, Cancelled, etc.)"
@@ -37888,7 +38582,11 @@ var salesTools = [
37888
38582
  const limit = input.limit ?? 20;
37889
38583
  const filters = [];
37890
38584
  if (input.customer) {
37891
- filters.push(["customer", "=", input.customer]);
38585
+ filters.push([
38586
+ "customer",
38587
+ "=",
38588
+ await resolveCustomer(ctx.client, input.customer)
38589
+ ]);
37892
38590
  }
37893
38591
  if (input.status) {
37894
38592
  filters.push(["status", "=", input.status]);
@@ -38054,14 +38752,21 @@ var salesTools = [
38054
38752
  if (!input.name) {
38055
38753
  throw new Error("[erpnext_sales_invoice_submit] 'name' is required");
38056
38754
  }
38057
- const doc = await ctx.client.get("Sales Invoice", input.name);
38755
+ const doc = await ctx.client.get("Sales Invoice", input.name, {
38756
+ skipCache: true
38757
+ });
38758
+ const docWithDoctype = { ...doc, doctype: "Sales Invoice" };
38759
+ const patchedDoc = withRoundedTotalFallback(docWithDoctype);
38058
38760
  const result = await ctx.client.callMethod("frappe.client.submit", {
38059
- doc: { ...doc, doctype: "Sales Invoice" }
38761
+ doc: patchedDoc
38060
38762
  });
38763
+ ctx.client.invalidate("Sales Invoice", input.name);
38764
+ const warnings = roundedTotalFallbackWarning(docWithDoctype, patchedDoc);
38061
38765
  return {
38062
38766
  data: result,
38063
38767
  message: `Sales Invoice ${input.name} submitted successfully`,
38064
- _meta: INVOICE_META
38768
+ _meta: INVOICE_META,
38769
+ ...warnings.length > 0 ? { warnings } : {}
38065
38770
  };
38066
38771
  }
38067
38772
  },
@@ -38076,22 +38781,54 @@ var salesTools = [
38076
38781
  type: "object",
38077
38782
  properties: {
38078
38783
  limit: { type: "number", description: "Max results (default 20)" },
38079
- party_name: { type: "string", description: "Filter by party name" },
38784
+ quotation_to: {
38785
+ type: "string",
38786
+ description: "Party type: Customer or Lead. Required when 'party_name' is set, so the party name/ID can be resolved against the right doctype.",
38787
+ enum: ["Customer", "Lead"]
38788
+ },
38789
+ party_name: {
38790
+ type: "string",
38791
+ description: "Filter by party \u2014 ID or name (e.g. customer/lead name). Requires 'quotation_to'."
38792
+ },
38080
38793
  status: {
38081
38794
  type: "string",
38082
38795
  description: "Filter by status (Draft, Open, Replied, Ordered, Lost, Cancelled)"
38083
- }
38796
+ },
38797
+ date_from: {
38798
+ type: "string",
38799
+ description: "Start date filter YYYY-MM-DD"
38800
+ },
38801
+ date_to: { type: "string", description: "End date filter YYYY-MM-DD" }
38084
38802
  }
38085
38803
  },
38086
38804
  handler: async (input, ctx) => {
38087
38805
  const limit = input.limit ?? 20;
38088
38806
  const filters = [];
38089
38807
  if (input.party_name) {
38090
- filters.push(["party_name", "=", input.party_name]);
38808
+ if (!input.quotation_to) {
38809
+ throw new Error(
38810
+ "[erpnext_quotation_list] 'quotation_to' is required when filtering by 'party_name'"
38811
+ );
38812
+ }
38813
+ filters.push([
38814
+ "party_name",
38815
+ "=",
38816
+ await resolveDynamicLink(
38817
+ ctx.client,
38818
+ input.quotation_to,
38819
+ input.party_name
38820
+ )
38821
+ ]);
38091
38822
  }
38092
38823
  if (input.status) {
38093
38824
  filters.push(["status", "=", input.status]);
38094
38825
  }
38826
+ if (input.date_from) {
38827
+ filters.push(["transaction_date", ">=", input.date_from]);
38828
+ }
38829
+ if (input.date_to) {
38830
+ filters.push(["transaction_date", "<=", input.date_to]);
38831
+ }
38095
38832
  const docs = await ctx.client.list("Quotation", {
38096
38833
  fields: [
38097
38834
  "name",
@@ -38151,7 +38888,7 @@ var salesTools = [
38151
38888
  },
38152
38889
  party_name: {
38153
38890
  type: "string",
38154
- description: "Customer or Lead name"
38891
+ description: "Customer or Lead \u2014 ID or name"
38155
38892
  },
38156
38893
  items: {
38157
38894
  type: "array",
@@ -38204,7 +38941,12 @@ var salesTools = [
38204
38941
  });
38205
38942
  const data = {
38206
38943
  quotation_to: input.quotation_to,
38207
- party_name: input.party_name,
38944
+ party_name: await resolveDynamicLink(
38945
+ ctx.client,
38946
+ input.quotation_to,
38947
+ input.party_name,
38948
+ { allowPartialMatch: false }
38949
+ ),
38208
38950
  items
38209
38951
  };
38210
38952
  if (input.transaction_date) {
@@ -38418,7 +39160,10 @@ var inventoryTools = [
38418
39160
  type: "object",
38419
39161
  properties: {
38420
39162
  limit: { type: "number", description: "Max results (default 50)" },
38421
- item_code: { type: "string", description: "Filter by item code" },
39163
+ item_code: {
39164
+ type: "string",
39165
+ description: "Filter by item code or name (e.g. 'ITEM-001' or 'Widget A')"
39166
+ },
38422
39167
  warehouse: { type: "string", description: "Filter by warehouse" }
38423
39168
  }
38424
39169
  },
@@ -38426,7 +39171,11 @@ var inventoryTools = [
38426
39171
  const limit = input.limit ?? 50;
38427
39172
  const filters = [];
38428
39173
  if (input.item_code) {
38429
- filters.push(["item_code", "=", input.item_code]);
39174
+ filters.push([
39175
+ "item_code",
39176
+ "=",
39177
+ await resolveItem(ctx.client, input.item_code)
39178
+ ]);
38430
39179
  }
38431
39180
  if (input.warehouse) {
38432
39181
  filters.push(["warehouse", "=", input.warehouse]);
@@ -38807,9 +39556,13 @@ var accountingTools = [
38807
39556
  },
38808
39557
  party_type: {
38809
39558
  type: "string",
38810
- description: "Filter by party type (Customer, Supplier, Employee)"
39559
+ description: "Filter by party type (Customer, Supplier, Employee). Required when 'party' is set, so the party name/ID can be resolved against the right doctype.",
39560
+ enum: ["Customer", "Supplier", "Employee"]
39561
+ },
39562
+ party: {
39563
+ type: "string",
39564
+ description: "Filter by party \u2014 ID or name (e.g. 'CUST-00001' or 'Acme Corp'). Requires 'party_type'."
38811
39565
  },
38812
- party: { type: "string", description: "Filter by party name" },
38813
39566
  date_from: {
38814
39567
  type: "string",
38815
39568
  description: "Start date filter YYYY-MM-DD"
@@ -38826,7 +39579,20 @@ var accountingTools = [
38826
39579
  filters.push(["party_type", "=", input.party_type]);
38827
39580
  }
38828
39581
  if (input.party) {
38829
- filters.push(["party", "=", input.party]);
39582
+ if (!input.party_type) {
39583
+ throw new Error(
39584
+ "[erpnext_payment_entry_list] 'party_type' is required when filtering by 'party'"
39585
+ );
39586
+ }
39587
+ filters.push([
39588
+ "party",
39589
+ "=",
39590
+ await resolveDynamicLink(
39591
+ ctx.client,
39592
+ input.party_type,
39593
+ input.party
39594
+ )
39595
+ ]);
38830
39596
  }
38831
39597
  if (input.date_from) {
38832
39598
  filters.push(["posting_date", ">=", input.date_from]);
@@ -39030,7 +39796,10 @@ var hrTools = [
39030
39796
  type: "object",
39031
39797
  properties: {
39032
39798
  limit: { type: "number", description: "Max results (default 20)" },
39033
- employee: { type: "string", description: "Filter by employee ID" },
39799
+ employee: {
39800
+ type: "string",
39801
+ description: "Filter by employee ID or name (e.g. 'HR-EMP-00001' or 'John Doe')"
39802
+ },
39034
39803
  status: {
39035
39804
  type: "string",
39036
39805
  description: "Filter by status (Present, Absent, Half Day, On Leave)",
@@ -39047,7 +39816,11 @@ var hrTools = [
39047
39816
  const limit = input.limit ?? 20;
39048
39817
  const filters = [];
39049
39818
  if (input.employee) {
39050
- filters.push(["employee", "=", input.employee]);
39819
+ filters.push([
39820
+ "employee",
39821
+ "=",
39822
+ await resolveEmployee(ctx.client, input.employee)
39823
+ ]);
39051
39824
  }
39052
39825
  if (input.status) {
39053
39826
  filters.push(["status", "=", input.status]);
@@ -39089,7 +39862,10 @@ var hrTools = [
39089
39862
  type: "object",
39090
39863
  properties: {
39091
39864
  limit: { type: "number", description: "Max results (default 20)" },
39092
- employee: { type: "string", description: "Filter by employee ID" },
39865
+ employee: {
39866
+ type: "string",
39867
+ description: "Filter by employee ID or name (e.g. 'HR-EMP-00001' or 'John Doe')"
39868
+ },
39093
39869
  status: {
39094
39870
  type: "string",
39095
39871
  description: "Filter by status (Open, Approved, Rejected, Cancelled)",
@@ -39098,14 +39874,23 @@ var hrTools = [
39098
39874
  leave_type: {
39099
39875
  type: "string",
39100
39876
  description: "Filter by leave type (e.g. Sick Leave)"
39101
- }
39877
+ },
39878
+ date_from: {
39879
+ type: "string",
39880
+ description: "Start date filter YYYY-MM-DD"
39881
+ },
39882
+ date_to: { type: "string", description: "End date filter YYYY-MM-DD" }
39102
39883
  }
39103
39884
  },
39104
39885
  handler: async (input, ctx) => {
39105
39886
  const limit = input.limit ?? 20;
39106
39887
  const filters = [];
39107
39888
  if (input.employee) {
39108
- filters.push(["employee", "=", input.employee]);
39889
+ filters.push([
39890
+ "employee",
39891
+ "=",
39892
+ await resolveEmployee(ctx.client, input.employee)
39893
+ ]);
39109
39894
  }
39110
39895
  if (input.status) {
39111
39896
  filters.push(["status", "=", input.status]);
@@ -39113,6 +39898,12 @@ var hrTools = [
39113
39898
  if (input.leave_type) {
39114
39899
  filters.push(["leave_type", "=", input.leave_type]);
39115
39900
  }
39901
+ if (input.date_from) {
39902
+ filters.push(["from_date", ">=", input.date_from]);
39903
+ }
39904
+ if (input.date_to) {
39905
+ filters.push(["to_date", "<=", input.date_to]);
39906
+ }
39116
39907
  const docs = await ctx.client.list("Leave Application", {
39117
39908
  fields: [
39118
39909
  "name",
@@ -39227,7 +40018,10 @@ var hrTools = [
39227
40018
  type: "object",
39228
40019
  properties: {
39229
40020
  limit: { type: "number", description: "Max results (default 20)" },
39230
- employee: { type: "string", description: "Filter by employee ID" },
40021
+ employee: {
40022
+ type: "string",
40023
+ description: "Filter by employee ID or name (e.g. 'HR-EMP-00001' or 'John Doe')"
40024
+ },
39231
40025
  status: {
39232
40026
  type: "string",
39233
40027
  description: "Filter by status (Draft, Submitted, Cancelled)",
@@ -39247,7 +40041,11 @@ var hrTools = [
39247
40041
  const limit = input.limit ?? 20;
39248
40042
  const filters = [];
39249
40043
  if (input.employee) {
39250
- filters.push(["employee", "=", input.employee]);
40044
+ filters.push([
40045
+ "employee",
40046
+ "=",
40047
+ await resolveEmployee(ctx.client, input.employee)
40048
+ ]);
39251
40049
  }
39252
40050
  if (input.status) {
39253
40051
  filters.push(["status", "=", input.status]);
@@ -39321,7 +40119,12 @@ var hrTools = [
39321
40119
  type: "string",
39322
40120
  description: "Filter by status (Draft, Submitted, Cancelled)",
39323
40121
  enum: ["Draft", "Submitted", "Cancelled"]
39324
- }
40122
+ },
40123
+ date_from: {
40124
+ type: "string",
40125
+ description: "Start date filter YYYY-MM-DD"
40126
+ },
40127
+ date_to: { type: "string", description: "End date filter YYYY-MM-DD" }
39325
40128
  }
39326
40129
  },
39327
40130
  handler: async (input, ctx) => {
@@ -39333,6 +40136,12 @@ var hrTools = [
39333
40136
  if (input.status) {
39334
40137
  filters.push(["status", "=", input.status]);
39335
40138
  }
40139
+ if (input.date_from) {
40140
+ filters.push(["posting_date", ">=", input.date_from]);
40141
+ }
40142
+ if (input.date_to) {
40143
+ filters.push(["posting_date", "<=", input.date_to]);
40144
+ }
39336
40145
  const docs = await ctx.client.list("Payroll Entry", {
39337
40146
  fields: [
39338
40147
  "name",
@@ -39364,7 +40173,10 @@ var hrTools = [
39364
40173
  type: "object",
39365
40174
  properties: {
39366
40175
  limit: { type: "number", description: "Max results (default 20)" },
39367
- employee: { type: "string", description: "Filter by employee ID" },
40176
+ employee: {
40177
+ type: "string",
40178
+ description: "Filter by employee ID or name (e.g. 'HR-EMP-00001' or 'John Doe')"
40179
+ },
39368
40180
  status: {
39369
40181
  type: "string",
39370
40182
  description: "Filter by status (Draft, Submitted, Cancelled)",
@@ -39374,14 +40186,23 @@ var hrTools = [
39374
40186
  type: "string",
39375
40187
  description: "Filter by approval status (Pending, Approved, Rejected)",
39376
40188
  enum: ["Pending", "Approved", "Rejected"]
39377
- }
40189
+ },
40190
+ date_from: {
40191
+ type: "string",
40192
+ description: "Start date filter YYYY-MM-DD"
40193
+ },
40194
+ date_to: { type: "string", description: "End date filter YYYY-MM-DD" }
39378
40195
  }
39379
40196
  },
39380
40197
  handler: async (input, ctx) => {
39381
40198
  const limit = input.limit ?? 20;
39382
40199
  const filters = [];
39383
40200
  if (input.employee) {
39384
- filters.push(["employee", "=", input.employee]);
40201
+ filters.push([
40202
+ "employee",
40203
+ "=",
40204
+ await resolveEmployee(ctx.client, input.employee)
40205
+ ]);
39385
40206
  }
39386
40207
  if (input.status) {
39387
40208
  filters.push(["status", "=", input.status]);
@@ -39389,6 +40210,12 @@ var hrTools = [
39389
40210
  if (input.approval_status) {
39390
40211
  filters.push(["approval_status", "=", input.approval_status]);
39391
40212
  }
40213
+ if (input.date_from) {
40214
+ filters.push(["posting_date", ">=", input.date_from]);
40215
+ }
40216
+ if (input.date_to) {
40217
+ filters.push(["posting_date", "<=", input.date_to]);
40218
+ }
39392
40219
  const docs = await ctx.client.list("Expense Claim", {
39393
40220
  fields: [
39394
40221
  "name",
@@ -39490,7 +40317,7 @@ var hrTools = [
39490
40317
  properties: {
39491
40318
  employee: {
39492
40319
  type: "string",
39493
- description: "Employee ID (e.g. HR-EMP-00001)"
40320
+ description: "Employee ID or name (e.g. 'HR-EMP-00001' or 'John Doe')"
39494
40321
  }
39495
40322
  },
39496
40323
  required: ["employee"]
@@ -39500,7 +40327,11 @@ var hrTools = [
39500
40327
  throw new Error("[erpnext_leave_balance] 'employee' is required");
39501
40328
  }
39502
40329
  const filters = [
39503
- ["employee", "=", input.employee],
40330
+ [
40331
+ "employee",
40332
+ "=",
40333
+ await resolveEmployee(ctx.client, input.employee)
40334
+ ],
39504
40335
  ["docstatus", "=", 1]
39505
40336
  ];
39506
40337
  const docs = await ctx.client.list("Leave Allocation", {
@@ -39701,7 +40532,12 @@ var projectTools = [
39701
40532
  description: "Filter by status (Open, Completed, Cancelled)",
39702
40533
  enum: ["Open", "Completed", "Cancelled"]
39703
40534
  },
39704
- company: { type: "string", description: "Filter by company" }
40535
+ company: { type: "string", description: "Filter by company" },
40536
+ date_from: {
40537
+ type: "string",
40538
+ description: "Start date filter YYYY-MM-DD"
40539
+ },
40540
+ date_to: { type: "string", description: "End date filter YYYY-MM-DD" }
39705
40541
  }
39706
40542
  },
39707
40543
  handler: async (input, ctx) => {
@@ -39713,6 +40549,12 @@ var projectTools = [
39713
40549
  if (input.company) {
39714
40550
  filters.push(["company", "=", input.company]);
39715
40551
  }
40552
+ if (input.date_from) {
40553
+ filters.push(["expected_start_date", ">=", input.date_from]);
40554
+ }
40555
+ if (input.date_to) {
40556
+ filters.push(["expected_end_date", "<=", input.date_to]);
40557
+ }
39716
40558
  const docs = await ctx.client.list("Project", {
39717
40559
  fields: [
39718
40560
  "name",
@@ -39775,7 +40617,12 @@ var projectTools = [
39775
40617
  type: "string",
39776
40618
  description: "Filter by priority (Low, Medium, High, Urgent)",
39777
40619
  enum: ["Low", "Medium", "High", "Urgent"]
39778
- }
40620
+ },
40621
+ date_from: {
40622
+ type: "string",
40623
+ description: "Start date filter YYYY-MM-DD"
40624
+ },
40625
+ date_to: { type: "string", description: "End date filter YYYY-MM-DD" }
39779
40626
  }
39780
40627
  },
39781
40628
  handler: async (input, ctx) => {
@@ -39790,6 +40637,12 @@ var projectTools = [
39790
40637
  if (input.priority) {
39791
40638
  filters.push(["priority", "=", input.priority]);
39792
40639
  }
40640
+ if (input.date_from) {
40641
+ filters.push(["exp_start_date", ">=", input.date_from]);
40642
+ }
40643
+ if (input.date_to) {
40644
+ filters.push(["exp_end_date", "<=", input.date_to]);
40645
+ }
39793
40646
  const docs = await ctx.client.list("Task", {
39794
40647
  fields: [
39795
40648
  "name",
@@ -40032,19 +40885,31 @@ var projectTools = [
40032
40885
  type: "object",
40033
40886
  properties: {
40034
40887
  limit: { type: "number", description: "Max results (default 20)" },
40035
- employee: { type: "string", description: "Filter by employee ID" },
40888
+ employee: {
40889
+ type: "string",
40890
+ description: "Filter by employee ID or name (e.g. 'HR-EMP-00001' or 'John Doe')"
40891
+ },
40036
40892
  project: { type: "string", description: "Filter by project name" },
40037
40893
  status: {
40038
40894
  type: "string",
40039
40895
  description: "Filter by status (Draft, Submitted, Cancelled)"
40040
- }
40896
+ },
40897
+ date_from: {
40898
+ type: "string",
40899
+ description: "Start date filter YYYY-MM-DD"
40900
+ },
40901
+ date_to: { type: "string", description: "End date filter YYYY-MM-DD" }
40041
40902
  }
40042
40903
  },
40043
40904
  handler: async (input, ctx) => {
40044
40905
  const limit = input.limit ?? 20;
40045
40906
  const filters = [];
40046
40907
  if (input.employee) {
40047
- filters.push(["employee", "=", input.employee]);
40908
+ filters.push([
40909
+ "employee",
40910
+ "=",
40911
+ await resolveEmployee(ctx.client, input.employee)
40912
+ ]);
40048
40913
  }
40049
40914
  if (input.project) {
40050
40915
  filters.push(["project", "=", input.project]);
@@ -40052,6 +40917,12 @@ var projectTools = [
40052
40917
  if (input.status) {
40053
40918
  filters.push(["status", "=", input.status]);
40054
40919
  }
40920
+ if (input.date_from) {
40921
+ filters.push(["start_date", ">=", input.date_from]);
40922
+ }
40923
+ if (input.date_to) {
40924
+ filters.push(["end_date", "<=", input.date_to]);
40925
+ }
40055
40926
  const docs = await ctx.client.list("Timesheet", {
40056
40927
  fields: [
40057
40928
  "name",
@@ -40294,7 +41165,10 @@ var purchasingTools = [
40294
41165
  type: "object",
40295
41166
  properties: {
40296
41167
  limit: { type: "number", description: "Max results (default 20)" },
40297
- supplier: { type: "string", description: "Filter by supplier" },
41168
+ supplier: {
41169
+ type: "string",
41170
+ description: "Filter by supplier ID or name (e.g. 'SUPP-00001' or 'Acme Supplies')"
41171
+ },
40298
41172
  status: {
40299
41173
  type: "string",
40300
41174
  description: "Filter by status (Draft, To Receive and Bill, To Bill, Completed, Cancelled, etc.)"
@@ -40310,7 +41184,11 @@ var purchasingTools = [
40310
41184
  const limit = input.limit ?? 20;
40311
41185
  const filters = [];
40312
41186
  if (input.supplier) {
40313
- filters.push(["supplier", "=", input.supplier]);
41187
+ filters.push([
41188
+ "supplier",
41189
+ "=",
41190
+ await resolveSupplier(ctx.client, input.supplier)
41191
+ ]);
40314
41192
  }
40315
41193
  if (input.status) {
40316
41194
  filters.push(["status", "=", input.status]);
@@ -40442,7 +41320,10 @@ var purchasingTools = [
40442
41320
  type: "object",
40443
41321
  properties: {
40444
41322
  limit: { type: "number", description: "Max results (default 20)" },
40445
- supplier: { type: "string", description: "Filter by supplier" },
41323
+ supplier: {
41324
+ type: "string",
41325
+ description: "Filter by supplier ID or name (e.g. 'SUPP-00001' or 'Acme Supplies')"
41326
+ },
40446
41327
  status: {
40447
41328
  type: "string",
40448
41329
  description: "Filter by status (Draft, Unpaid, Paid, Overdue, Cancelled, etc.)"
@@ -40458,7 +41339,11 @@ var purchasingTools = [
40458
41339
  const limit = input.limit ?? 20;
40459
41340
  const filters = [];
40460
41341
  if (input.supplier) {
40461
- filters.push(["supplier", "=", input.supplier]);
41342
+ filters.push([
41343
+ "supplier",
41344
+ "=",
41345
+ await resolveSupplier(ctx.client, input.supplier)
41346
+ ]);
40462
41347
  }
40463
41348
  if (input.status) {
40464
41349
  filters.push(["status", "=", input.status]);
@@ -40528,7 +41413,10 @@ var purchasingTools = [
40528
41413
  type: "object",
40529
41414
  properties: {
40530
41415
  limit: { type: "number", description: "Max results (default 20)" },
40531
- supplier: { type: "string", description: "Filter by supplier" },
41416
+ supplier: {
41417
+ type: "string",
41418
+ description: "Filter by supplier ID or name (e.g. 'SUPP-00001' or 'Acme Supplies')"
41419
+ },
40532
41420
  status: {
40533
41421
  type: "string",
40534
41422
  description: "Filter by status (Draft, To Bill, Completed, Cancelled, etc.)"
@@ -40544,7 +41432,11 @@ var purchasingTools = [
40544
41432
  const limit = input.limit ?? 20;
40545
41433
  const filters = [];
40546
41434
  if (input.supplier) {
40547
- filters.push(["supplier", "=", input.supplier]);
41435
+ filters.push([
41436
+ "supplier",
41437
+ "=",
41438
+ await resolveSupplier(ctx.client, input.supplier)
41439
+ ]);
40548
41440
  }
40549
41441
  if (input.status) {
40550
41442
  filters.push(["status", "=", input.status]);
@@ -40613,22 +41505,40 @@ var purchasingTools = [
40613
41505
  type: "object",
40614
41506
  properties: {
40615
41507
  limit: { type: "number", description: "Max results (default 20)" },
40616
- supplier: { type: "string", description: "Filter by supplier" },
41508
+ supplier: {
41509
+ type: "string",
41510
+ description: "Filter by supplier ID or name (e.g. 'SUPP-00001' or 'Acme Supplies')"
41511
+ },
40617
41512
  status: {
40618
41513
  type: "string",
40619
41514
  description: "Filter by status (Draft, Submitted, Ordered, Lost, Cancelled)"
40620
- }
41515
+ },
41516
+ date_from: {
41517
+ type: "string",
41518
+ description: "Start date filter YYYY-MM-DD"
41519
+ },
41520
+ date_to: { type: "string", description: "End date filter YYYY-MM-DD" }
40621
41521
  }
40622
41522
  },
40623
41523
  handler: async (input, ctx) => {
40624
41524
  const limit = input.limit ?? 20;
40625
41525
  const filters = [];
40626
41526
  if (input.supplier) {
40627
- filters.push(["supplier", "=", input.supplier]);
41527
+ filters.push([
41528
+ "supplier",
41529
+ "=",
41530
+ await resolveSupplier(ctx.client, input.supplier)
41531
+ ]);
40628
41532
  }
40629
41533
  if (input.status) {
40630
41534
  filters.push(["status", "=", input.status]);
40631
41535
  }
41536
+ if (input.date_from) {
41537
+ filters.push(["transaction_date", ">=", input.date_from]);
41538
+ }
41539
+ if (input.date_to) {
41540
+ filters.push(["transaction_date", "<=", input.date_to]);
41541
+ }
40632
41542
  const docs = await ctx.client.list("Supplier Quotation", {
40633
41543
  fields: [
40634
41544
  "name",
@@ -40664,7 +41574,10 @@ var deliveryTools = [
40664
41574
  type: "object",
40665
41575
  properties: {
40666
41576
  limit: { type: "number", description: "Max results (default 20)" },
40667
- customer: { type: "string", description: "Filter by customer" },
41577
+ customer: {
41578
+ type: "string",
41579
+ description: "Filter by customer ID or name (e.g. 'CUST-00001' or 'Acme Corp')"
41580
+ },
40668
41581
  status: {
40669
41582
  type: "string",
40670
41583
  description: "Filter by status (Draft, To Bill, Completed, Cancelled, etc.)"
@@ -40680,7 +41593,11 @@ var deliveryTools = [
40680
41593
  const limit = input.limit ?? 20;
40681
41594
  const filters = [];
40682
41595
  if (input.customer) {
40683
- filters.push(["customer", "=", input.customer]);
41596
+ filters.push([
41597
+ "customer",
41598
+ "=",
41599
+ await resolveCustomer(ctx.client, input.customer)
41600
+ ]);
40684
41601
  }
40685
41602
  if (input.status) {
40686
41603
  filters.push(["status", "=", input.status]);
@@ -40899,7 +41816,7 @@ var manufacturingTools = [
40899
41816
  limit: { type: "number", description: "Max results (default 20)" },
40900
41817
  item: {
40901
41818
  type: "string",
40902
- description: "Filter by finished goods item code"
41819
+ description: "Filter by finished goods item code or name (e.g. 'ITEM-001' or 'Widget A')"
40903
41820
  },
40904
41821
  is_active: {
40905
41822
  type: "boolean",
@@ -40915,7 +41832,11 @@ var manufacturingTools = [
40915
41832
  const limit = input.limit ?? 20;
40916
41833
  const filters = [];
40917
41834
  if (input.item) {
40918
- filters.push(["item", "=", input.item]);
41835
+ filters.push([
41836
+ "item",
41837
+ "=",
41838
+ await resolveItem(ctx.client, input.item)
41839
+ ]);
40919
41840
  }
40920
41841
  if (input.is_active !== void 0) {
40921
41842
  filters.push(["is_active", "=", input.is_active ? 1 : 0]);
@@ -40983,7 +41904,7 @@ var manufacturingTools = [
40983
41904
  limit: { type: "number", description: "Max results (default 20)" },
40984
41905
  production_item: {
40985
41906
  type: "string",
40986
- description: "Filter by item being produced"
41907
+ description: "Filter by item being produced \u2014 item code or name (e.g. 'ITEM-001' or 'Widget A')"
40987
41908
  },
40988
41909
  status: {
40989
41910
  type: "string",
@@ -41003,7 +41924,11 @@ var manufacturingTools = [
41003
41924
  const limit = input.limit ?? 20;
41004
41925
  const filters = [];
41005
41926
  if (input.production_item) {
41006
- filters.push(["production_item", "=", input.production_item]);
41927
+ filters.push([
41928
+ "production_item",
41929
+ "=",
41930
+ await resolveItem(ctx.client, input.production_item)
41931
+ ]);
41007
41932
  }
41008
41933
  if (input.status) {
41009
41934
  filters.push(["status", "=", input.status]);
@@ -41327,9 +42252,14 @@ var crmTools = [
41327
42252
  type: "string",
41328
42253
  description: "Filter by assigned sales rep (user)"
41329
42254
  },
42255
+ opportunity_from: {
42256
+ type: "string",
42257
+ description: "Party type: Customer or Lead. Required when 'party_name' is set, so the party name/ID can be resolved against the right doctype.",
42258
+ enum: ["Customer", "Lead"]
42259
+ },
41330
42260
  party_name: {
41331
42261
  type: "string",
41332
- description: "Filter by customer or lead name"
42262
+ description: "Filter by party \u2014 ID or name (e.g. customer/lead name). Requires 'opportunity_from'."
41333
42263
  }
41334
42264
  }
41335
42265
  },
@@ -41347,7 +42277,20 @@ var crmTools = [
41347
42277
  ]);
41348
42278
  }
41349
42279
  if (input.party_name) {
41350
- filters.push(["party_name", "=", input.party_name]);
42280
+ if (!input.opportunity_from) {
42281
+ throw new Error(
42282
+ "[erpnext_opportunity_list] 'opportunity_from' is required when filtering by 'party_name'"
42283
+ );
42284
+ }
42285
+ filters.push([
42286
+ "party_name",
42287
+ "=",
42288
+ await resolveDynamicLink(
42289
+ ctx.client,
42290
+ input.opportunity_from,
42291
+ input.party_name
42292
+ )
42293
+ ]);
41351
42294
  }
41352
42295
  const docs = await ctx.client.list("Opportunity", {
41353
42296
  fields: [
@@ -41475,7 +42418,12 @@ var crmTools = [
41475
42418
  campaign_type: {
41476
42419
  type: "string",
41477
42420
  description: "Filter by campaign type"
41478
- }
42421
+ },
42422
+ date_from: {
42423
+ type: "string",
42424
+ description: "Start date filter YYYY-MM-DD"
42425
+ },
42426
+ date_to: { type: "string", description: "End date filter YYYY-MM-DD" }
41479
42427
  }
41480
42428
  },
41481
42429
  handler: async (input, ctx) => {
@@ -41484,6 +42432,12 @@ var crmTools = [
41484
42432
  if (input.campaign_type) {
41485
42433
  filters.push(["campaign_type", "=", input.campaign_type]);
41486
42434
  }
42435
+ if (input.date_from) {
42436
+ filters.push(["start_date", ">=", input.date_from]);
42437
+ }
42438
+ if (input.date_to) {
42439
+ filters.push(["end_date", "<=", input.date_to]);
42440
+ }
41487
42441
  const docs = await ctx.client.list("Campaign", {
41488
42442
  fields: [
41489
42443
  "name",
@@ -41531,7 +42485,15 @@ var assetsTools = [
41531
42485
  location: { type: "string", description: "Filter by location" },
41532
42486
  custodian: {
41533
42487
  type: "string",
41534
- description: "Filter by custodian (employee)"
42488
+ description: "Filter by custodian \u2014 employee ID or name (e.g. 'HR-EMP-00001' or 'John Doe')"
42489
+ },
42490
+ date_from: {
42491
+ type: "string",
42492
+ description: "Purchase date start filter YYYY-MM-DD"
42493
+ },
42494
+ date_to: {
42495
+ type: "string",
42496
+ description: "Purchase date end filter YYYY-MM-DD"
41535
42497
  }
41536
42498
  }
41537
42499
  },
@@ -41548,7 +42510,17 @@ var assetsTools = [
41548
42510
  filters.push(["location", "=", input.location]);
41549
42511
  }
41550
42512
  if (input.custodian) {
41551
- filters.push(["custodian", "=", input.custodian]);
42513
+ filters.push([
42514
+ "custodian",
42515
+ "=",
42516
+ await resolveEmployee(ctx.client, input.custodian)
42517
+ ]);
42518
+ }
42519
+ if (input.date_from) {
42520
+ filters.push(["purchase_date", ">=", input.date_from]);
42521
+ }
42522
+ if (input.date_to) {
42523
+ filters.push(["purchase_date", "<=", input.date_to]);
41552
42524
  }
41553
42525
  const docs = await ctx.client.list("Asset", {
41554
42526
  fields: [
@@ -41830,27 +42802,115 @@ var assetsTools = [
41830
42802
  properties: {
41831
42803
  limit: { type: "number", description: "Max results (default 20)" }
41832
42804
  }
41833
- },
41834
- handler: async (input, ctx) => {
41835
- const limit = input.limit ?? 20;
41836
- const docs = await ctx.client.list("Asset Category", {
41837
- fields: ["name", "asset_category_name"],
41838
- filters: [],
41839
- limit,
41840
- order_by: "modified desc"
42805
+ },
42806
+ handler: async (input, ctx) => {
42807
+ const limit = input.limit ?? 20;
42808
+ const docs = await ctx.client.list("Asset Category", {
42809
+ fields: ["name", "asset_category_name"],
42810
+ filters: [],
42811
+ limit,
42812
+ order_by: "modified desc"
42813
+ });
42814
+ return {
42815
+ doctype: "Asset Category",
42816
+ count: docs.length,
42817
+ data: docs,
42818
+ _meta: DOCLIST_META
42819
+ };
42820
+ }
42821
+ }
42822
+ ];
42823
+
42824
+ // src/tools/operations.ts
42825
+ var operationsTools = [
42826
+ // ── File Attachments ───────────────────────────────────────────────────────
42827
+ {
42828
+ name: "erpnext_file_upload",
42829
+ annotations: { destructiveHint: true },
42830
+ description: "Upload base64-encoded file content and attach it to any ERPNext document. Files are private by default.",
42831
+ category: "operations",
42832
+ inputSchema: {
42833
+ type: "object",
42834
+ properties: {
42835
+ file_name: {
42836
+ type: "string",
42837
+ description: "Filename only, without a path.",
42838
+ minLength: 1
42839
+ },
42840
+ content_base64: {
42841
+ type: "string",
42842
+ description: "File content as standard base64 (not a data URL).",
42843
+ minLength: 1
42844
+ },
42845
+ attached_to_doctype: {
42846
+ type: "string",
42847
+ description: "DocType of the document to attach the file to.",
42848
+ minLength: 1
42849
+ },
42850
+ attached_to_name: {
42851
+ type: "string",
42852
+ description: "Name/ID of the document to attach the file to.",
42853
+ minLength: 1
42854
+ },
42855
+ attached_to_field: {
42856
+ type: "string",
42857
+ description: "Optional Attach or Attach Image field to populate with the uploaded file."
42858
+ },
42859
+ is_private: {
42860
+ type: "boolean",
42861
+ description: "Whether the attachment is private. Defaults to true.",
42862
+ default: true
42863
+ }
42864
+ },
42865
+ required: [
42866
+ "file_name",
42867
+ "content_base64",
42868
+ "attached_to_doctype",
42869
+ "attached_to_name"
42870
+ ]
42871
+ },
42872
+ handler: async (input, ctx) => {
42873
+ const requiredStrings = [
42874
+ "file_name",
42875
+ "content_base64",
42876
+ "attached_to_doctype",
42877
+ "attached_to_name"
42878
+ ];
42879
+ for (const field of requiredStrings) {
42880
+ if (typeof input[field] !== "string" || !input[field].trim()) {
42881
+ throw new Error(
42882
+ `[erpnext_file_upload] '${field}' must be a non-empty string`
42883
+ );
42884
+ }
42885
+ }
42886
+ const fileName = input.file_name;
42887
+ if (/[\\/\0]/.test(fileName)) {
42888
+ throw new Error(
42889
+ "[erpnext_file_upload] 'file_name' must be a filename without a path"
42890
+ );
42891
+ }
42892
+ if (input.is_private !== void 0 && typeof input.is_private !== "boolean") {
42893
+ throw new Error("[erpnext_file_upload] 'is_private' must be a boolean");
42894
+ }
42895
+ if (input.attached_to_field !== void 0 && (typeof input.attached_to_field !== "string" || !input.attached_to_field.trim())) {
42896
+ throw new Error(
42897
+ "[erpnext_file_upload] 'attached_to_field' must be a non-empty string"
42898
+ );
42899
+ }
42900
+ const file = await ctx.client.uploadFile({
42901
+ fileName,
42902
+ contentBase64: input.content_base64,
42903
+ attachedToDoctype: input.attached_to_doctype,
42904
+ attachedToName: input.attached_to_name,
42905
+ ...input.attached_to_field !== void 0 ? { attachedToField: input.attached_to_field.trim() } : {},
42906
+ isPrivate: input.is_private === void 0 ? true : input.is_private
41841
42907
  });
41842
42908
  return {
41843
- doctype: "Asset Category",
41844
- count: docs.length,
41845
- data: docs,
41846
- _meta: DOCLIST_META
42909
+ data: file,
42910
+ message: `${fileName} attached to ${input.attached_to_doctype} ${input.attached_to_name}`
41847
42911
  };
41848
42912
  }
41849
- }
41850
- ];
41851
-
41852
- // src/tools/operations.ts
41853
- var operationsTools = [
42913
+ },
41854
42914
  // ── Generic Create ──────────────────────────────────────────────────────────
41855
42915
  {
41856
42916
  name: "erpnext_doc_create",
@@ -42002,16 +43062,22 @@ var operationsTools = [
42002
43062
  }
42003
43063
  const doc = await ctx.client.get(
42004
43064
  input.doctype,
42005
- input.name
43065
+ input.name,
43066
+ { skipCache: true }
42006
43067
  );
43068
+ const docWithDoctype = { ...doc, doctype: input.doctype };
43069
+ const patchedDoc = withRoundedTotalFallback(docWithDoctype);
42007
43070
  const result = await ctx.client.callMethod("frappe.client.submit", {
42008
- doc: { ...doc, doctype: input.doctype }
43071
+ doc: patchedDoc
42009
43072
  });
43073
+ ctx.client.invalidate(input.doctype, input.name);
43074
+ const warnings = roundedTotalFallbackWarning(docWithDoctype, patchedDoc);
42010
43075
  return {
42011
43076
  data: result,
42012
43077
  message: `${input.doctype} ${input.name} submitted successfully`,
42013
43078
  doctype: input.doctype,
42014
- name: input.name
43079
+ name: input.name,
43080
+ ...warnings.length > 0 ? { warnings } : {}
42015
43081
  };
42016
43082
  }
42017
43083
  },
@@ -42046,6 +43112,7 @@ var operationsTools = [
42046
43112
  doctype: input.doctype,
42047
43113
  name: input.name
42048
43114
  });
43115
+ ctx.client.invalidate(input.doctype, input.name);
42049
43116
  return {
42050
43117
  data: result,
42051
43118
  message: `${input.doctype} ${input.name} cancelled successfully`,
@@ -44542,500 +45609,206 @@ var issueKanbanAdapter = {
44542
45609
  ok: true,
44543
45610
  cardId: move.cardId,
44544
45611
  fromColumn: serverColumn,
44545
- toColumn: move.toColumn,
44546
- serverCard: buildIssueCard(serverIssue)
44547
- };
44548
- }
44549
- };
44550
-
44551
- // src/tools/kanban.ts
44552
- var ADAPTERS = {
44553
- task: taskKanbanAdapter,
44554
- opportunity: opportunityKanbanAdapter,
44555
- issue: issueKanbanAdapter
44556
- };
44557
- function getAdapter(doctype) {
44558
- const definition = getKanbanBoardDefinition(doctype);
44559
- if (!definition) {
44560
- throw new Error(`[erpnext_kanban] Unsupported kanban doctype: ${doctype}`);
44561
- }
44562
- const adapter = ADAPTERS[definition.adapterKey];
44563
- if (!adapter) {
44564
- throw new Error(`[erpnext_kanban] No adapter registered for ${doctype}`);
44565
- }
44566
- return { definition, adapter };
44567
- }
44568
- function withColumnCounts(columns, cards) {
44569
- const counts = /* @__PURE__ */ new Map();
44570
- for (const card of cards) {
44571
- counts.set(card.columnId, (counts.get(card.columnId) ?? 0) + 1);
44572
- }
44573
- return columns.map((column) => ({
44574
- ...column,
44575
- count: counts.get(column.id) ?? 0
44576
- }));
44577
- }
44578
- var kanbanTools = [
44579
- {
44580
- name: "erpnext_kanban_get_board",
44581
- annotations: { readOnlyHint: true },
44582
- category: "kanban",
44583
- _meta: KANBAN_META,
44584
- description: "Get a normalized kanban board for a supported ERPNext DocType. Supports Task, Opportunity, and Issue, with pagination and MCP App metadata.",
44585
- inputSchema: {
44586
- type: "object",
44587
- properties: {
44588
- doctype: {
44589
- type: "string",
44590
- description: "Kanban-enabled ERPNext DocType",
44591
- enum: ["Task", "Opportunity", "Issue"]
44592
- },
44593
- limit: { type: "number", description: "Page size (default 50)" },
44594
- offset: {
44595
- type: "number",
44596
- description: "Pagination offset (default 0)"
44597
- },
44598
- project: {
44599
- type: "string",
44600
- description: "Optional Task project filter"
44601
- },
44602
- priority: {
44603
- type: "string",
44604
- description: "Optional Task priority filter",
44605
- enum: ["Low", "Medium", "High", "Urgent"]
44606
- },
44607
- status: {
44608
- type: "string",
44609
- description: "Optional Opportunity status filter",
44610
- enum: ["Open", "Replied", "Quotation", "Converted", "Closed", "Lost"]
44611
- },
44612
- opportunity_owner: {
44613
- type: "string",
44614
- description: "Optional Opportunity owner filter"
44615
- },
44616
- party_name: {
44617
- type: "string",
44618
- description: "Optional Opportunity party filter"
44619
- },
44620
- customer: {
44621
- type: "string",
44622
- description: "Optional Issue customer filter"
44623
- },
44624
- raised_by: {
44625
- type: "string",
44626
- description: "Optional Issue reporter email filter"
44627
- }
44628
- },
44629
- required: ["doctype"]
44630
- },
44631
- handler: async (input, ctx) => {
44632
- const doctype = String(input.doctype ?? "");
44633
- const { definition, adapter } = getAdapter(doctype);
44634
- const limit = Math.max(1, Number(input.limit ?? 50));
44635
- const offset = Math.max(0, Number(input.offset ?? 0));
44636
- const rows = await ctx.client.list(doctype, {
44637
- fields: adapter.getListFields(),
44638
- filters: adapter.buildListFilters(input),
44639
- limit: limit + 1,
44640
- limit_start: offset,
44641
- order_by: "modified desc"
44642
- });
44643
- const hasMore = rows.length > limit;
44644
- const visibleRows = rows.slice(0, limit);
44645
- const cards = adapter.buildCards(visibleRows);
44646
- const board = {
44647
- boardId: `${doctype.toLowerCase()}-board`,
44648
- title: definition.title,
44649
- doctype,
44650
- generatedAt: (/* @__PURE__ */ new Date()).toISOString(),
44651
- moveToolName: definition.moveToolName,
44652
- refreshArguments: { ...input, doctype, limit, offset },
44653
- columns: withColumnCounts(adapter.getColumns(), cards),
44654
- cards,
44655
- allowedTransitions: adapter.getAllowedTransitions(),
44656
- capabilities: { canMoveCards: true },
44657
- pagination: {
44658
- limit,
44659
- offset,
44660
- loadedCount: offset + visibleRows.length,
44661
- hasMore
44662
- }
44663
- };
44664
- return {
44665
- ...board,
44666
- _meta: KANBAN_META
44667
- };
44668
- }
44669
- },
44670
- {
44671
- name: "erpnext_kanban_move_card",
44672
- category: "kanban",
44673
- description: "Move a kanban card for a supported ERPNext DocType. Returns structured success or business error details for MCP App reconciliation.",
44674
- inputSchema: {
44675
- type: "object",
44676
- properties: {
44677
- doctype: {
44678
- type: "string",
44679
- description: "Kanban-enabled ERPNext DocType",
44680
- enum: ["Task", "Opportunity", "Issue"]
44681
- },
44682
- card_id: { type: "string", description: "Card/document identifier" },
44683
- from_column: {
44684
- type: "string",
44685
- description: "Source column identifier"
44686
- },
44687
- to_column: {
44688
- type: "string",
44689
- description: "Destination column identifier"
44690
- }
44691
- },
44692
- required: ["doctype", "card_id", "from_column", "to_column"]
44693
- },
44694
- handler: async (input, ctx) => {
44695
- const doctype = String(input.doctype ?? "");
44696
- const { adapter } = getAdapter(doctype);
44697
- return await adapter.executeMove({
44698
- doctype,
44699
- cardId: String(input.card_id ?? ""),
44700
- fromColumn: String(input.from_column ?? ""),
44701
- toColumn: String(input.to_column ?? "")
44702
- }, ctx);
44703
- }
44704
- }
44705
- ];
44706
-
44707
- // src/tools/mod.ts
44708
- var toolsByCategory = {
44709
- sales: salesTools,
44710
- inventory: inventoryTools,
44711
- accounting: accountingTools,
44712
- hr: hrTools,
44713
- project: projectTools,
44714
- purchasing: purchasingTools,
44715
- delivery: deliveryTools,
44716
- manufacturing: manufacturingTools,
44717
- crm: crmTools,
44718
- assets: assetsTools,
44719
- operations: operationsTools,
44720
- setup: setupTools,
44721
- analytics: analyticsTools,
44722
- kanban: kanbanTools
44723
- };
44724
- var allTools = [
44725
- ...salesTools,
44726
- ...inventoryTools,
44727
- ...accountingTools,
44728
- ...hrTools,
44729
- ...projectTools,
44730
- ...purchasingTools,
44731
- ...deliveryTools,
44732
- ...manufacturingTools,
44733
- ...crmTools,
44734
- ...assetsTools,
44735
- ...operationsTools,
44736
- ...setupTools,
44737
- ...analyticsTools,
44738
- ...kanbanTools
44739
- ];
44740
- function getToolsByCategory(category) {
44741
- return toolsByCategory[category] ?? [];
44742
- }
44743
-
44744
- // src/runtime.ts
44745
- var isDeno2 = typeof globalThis.Deno?.version?.deno === "string";
44746
- var impl2 = isDeno2 ? await Promise.resolve().then(() => (init_runtime_deno2(), runtime_deno_exports2)) : await Promise.resolve().then(() => (init_runtime_node2(), runtime_node_exports2));
44747
- var env6 = impl2.env;
44748
- var readTextFile6 = impl2.readTextFile;
44749
- var statSync3 = impl2.statSync;
44750
- var readDirSync3 = impl2.readDirSync;
44751
- var getArgs3 = impl2.getArgs;
44752
- var exit3 = impl2.exit;
44753
- var onSignal3 = impl2.onSignal;
44754
-
44755
- // src/api/frappe-client.ts
44756
- var DEFAULT_RETRY_STATUSES = [408, 429, 502, 503, 504];
44757
- var DEFAULT_RETRY_METHODS = ["GET"];
44758
- var FrappeAPIError = class extends Error {
44759
- /**
44760
- * @param message - Human-readable error description
44761
- * @param status - HTTP status code (0 for network errors, 408 for timeouts)
44762
- * @param body - Raw response body (parsed JSON object or plain text string)
44763
- * @param retryAfterMs - When the server sent a `Retry-After` header on a
44764
- * retryable status (typically 429), the parsed delay
44765
- * in ms. Used by the retry loop; absent otherwise.
44766
- */
44767
- constructor(message2, status, body, retryAfterMs) {
44768
- super(`[FrappeClient] ${message2} (HTTP ${status})`);
44769
- this.status = status;
44770
- this.body = body;
44771
- this.retryAfterMs = retryAfterMs;
44772
- this.name = "FrappeAPIError";
45612
+ toColumn: move.toColumn,
45613
+ serverCard: buildIssueCard(serverIssue)
45614
+ };
44773
45615
  }
44774
45616
  };
44775
- function parseRetryAfter(raw2) {
44776
- if (!raw2) return void 0;
44777
- const trimmed = raw2.trim();
44778
- const seconds = Number(trimmed);
44779
- if (Number.isFinite(seconds) && seconds >= 0) {
44780
- return Math.round(seconds * 1e3);
45617
+
45618
+ // src/tools/kanban.ts
45619
+ var ADAPTERS = {
45620
+ task: taskKanbanAdapter,
45621
+ opportunity: opportunityKanbanAdapter,
45622
+ issue: issueKanbanAdapter
45623
+ };
45624
+ function getAdapter(doctype) {
45625
+ const definition = getKanbanBoardDefinition(doctype);
45626
+ if (!definition) {
45627
+ throw new Error(`[erpnext_kanban] Unsupported kanban doctype: ${doctype}`);
44781
45628
  }
44782
- const date3 = Date.parse(trimmed);
44783
- if (Number.isFinite(date3)) {
44784
- return Math.max(0, date3 - Date.now());
45629
+ const adapter = ADAPTERS[definition.adapterKey];
45630
+ if (!adapter) {
45631
+ throw new Error(`[erpnext_kanban] No adapter registered for ${doctype}`);
44785
45632
  }
44786
- return void 0;
45633
+ return { definition, adapter };
44787
45634
  }
44788
- function extractServerMessages(raw2) {
44789
- if (!raw2 || typeof raw2 !== "string") return void 0;
44790
- try {
44791
- const outer = JSON.parse(raw2);
44792
- if (!Array.isArray(outer)) return void 0;
44793
- const msgs = [];
44794
- for (const item of outer) {
44795
- try {
44796
- const inner = JSON.parse(item);
44797
- if (typeof inner === "object" && inner?.message) {
44798
- msgs.push(inner.message);
44799
- } else if (typeof inner === "string") {
44800
- msgs.push(inner);
44801
- }
44802
- } catch {
44803
- if (typeof item === "string") msgs.push(item);
44804
- }
44805
- }
44806
- return msgs.length > 0 ? msgs.join("; ") : void 0;
44807
- } catch {
44808
- return void 0;
45635
+ function withColumnCounts(columns, cards) {
45636
+ const counts = /* @__PURE__ */ new Map();
45637
+ for (const card of cards) {
45638
+ counts.set(card.columnId, (counts.get(card.columnId) ?? 0) + 1);
44809
45639
  }
45640
+ return columns.map((column) => ({
45641
+ ...column,
45642
+ count: counts.get(column.id) ?? 0
45643
+ }));
44810
45644
  }
44811
- var FrappeClient = class {
44812
- baseUrl;
44813
- authHeader;
44814
- timeoutMs;
44815
- retries;
44816
- retryStatuses;
44817
- retryBackoffMs;
44818
- retryMethods;
44819
- constructor(config2) {
44820
- this.baseUrl = config2.baseUrl.replace(/\/$/, "");
44821
- this.authHeader = `token ${config2.apiKey}:${config2.apiSecret}`;
44822
- this.timeoutMs = config2.timeoutMs ?? 3e4;
44823
- this.retries = config2.retries ?? 3;
44824
- this.retryStatuses = config2.retryStatuses ?? DEFAULT_RETRY_STATUSES;
44825
- this.retryBackoffMs = config2.retryBackoffMs ?? 200;
44826
- this.retryMethods = config2.retryMethods ?? DEFAULT_RETRY_METHODS;
44827
- }
44828
- // ── Private HTTP helpers ────────────────────────────────────────────────────
44829
- buildHeaders() {
44830
- return {
44831
- "Authorization": this.authHeader,
44832
- "Accept": "application/json",
44833
- "Content-Type": "application/json"
44834
- };
44835
- }
44836
- /**
44837
- * Decide whether an error is worth retrying.
44838
- * Network errors (status 0) and timeouts (status 408) are always retryable;
44839
- * other statuses are checked against `retryStatuses`.
44840
- */
44841
- isRetryable(err, method) {
44842
- if (!this.retryMethods.includes(method)) return false;
44843
- if (!(err instanceof FrappeAPIError)) return false;
44844
- if (err.status === 0) return true;
44845
- return this.retryStatuses.includes(err.status);
44846
- }
44847
- /** Compute the backoff delay for a given attempt (0-indexed). */
44848
- computeBackoff(attempt, err) {
44849
- if (err instanceof FrappeAPIError && err.retryAfterMs !== void 0) {
44850
- return err.retryAfterMs;
44851
- }
44852
- return this.retryBackoffMs * 2 ** attempt;
44853
- }
44854
- async request(method, path, body) {
44855
- let lastError;
44856
- for (let attempt = 0; attempt <= this.retries; attempt++) {
44857
- try {
44858
- return await this.requestOnce(method, path, body);
44859
- } catch (err) {
44860
- lastError = err;
44861
- if (attempt === this.retries || !this.isRetryable(err, method)) {
44862
- throw err;
44863
- }
44864
- const delay = this.computeBackoff(attempt, err);
44865
- if (delay > 0) {
44866
- await new Promise((resolve) => setTimeout(resolve, delay));
45645
+ var kanbanTools = [
45646
+ {
45647
+ name: "erpnext_kanban_get_board",
45648
+ annotations: { readOnlyHint: true },
45649
+ category: "kanban",
45650
+ _meta: KANBAN_META,
45651
+ description: "Get a normalized kanban board for a supported ERPNext DocType. Supports Task, Opportunity, and Issue, with pagination and MCP App metadata.",
45652
+ inputSchema: {
45653
+ type: "object",
45654
+ properties: {
45655
+ doctype: {
45656
+ type: "string",
45657
+ description: "Kanban-enabled ERPNext DocType",
45658
+ enum: ["Task", "Opportunity", "Issue"]
45659
+ },
45660
+ limit: { type: "number", description: "Page size (default 50)" },
45661
+ offset: {
45662
+ type: "number",
45663
+ description: "Pagination offset (default 0)"
45664
+ },
45665
+ project: {
45666
+ type: "string",
45667
+ description: "Optional Task project filter"
45668
+ },
45669
+ priority: {
45670
+ type: "string",
45671
+ description: "Optional Task priority filter",
45672
+ enum: ["Low", "Medium", "High", "Urgent"]
45673
+ },
45674
+ status: {
45675
+ type: "string",
45676
+ description: "Optional Opportunity status filter",
45677
+ enum: ["Open", "Replied", "Quotation", "Converted", "Closed", "Lost"]
45678
+ },
45679
+ opportunity_owner: {
45680
+ type: "string",
45681
+ description: "Optional Opportunity owner filter"
45682
+ },
45683
+ party_name: {
45684
+ type: "string",
45685
+ description: "Optional Opportunity party filter"
45686
+ },
45687
+ customer: {
45688
+ type: "string",
45689
+ description: "Optional Issue customer filter"
45690
+ },
45691
+ raised_by: {
45692
+ type: "string",
45693
+ description: "Optional Issue reporter email filter"
44867
45694
  }
44868
- }
44869
- }
44870
- throw lastError;
44871
- }
44872
- async requestOnce(method, path, body) {
44873
- const url = `${this.baseUrl}${path}`;
44874
- const controller = new AbortController();
44875
- const timer = setTimeout(() => controller.abort(), this.timeoutMs);
44876
- let response;
44877
- try {
44878
- response = await fetch(url, {
44879
- method,
44880
- headers: this.buildHeaders(),
44881
- body: body !== void 0 ? JSON.stringify(body) : void 0,
44882
- signal: controller.signal
45695
+ },
45696
+ required: ["doctype"]
45697
+ },
45698
+ handler: async (input, ctx) => {
45699
+ const doctype = String(input.doctype ?? "");
45700
+ const { definition, adapter } = getAdapter(doctype);
45701
+ const limit = Math.max(1, Number(input.limit ?? 50));
45702
+ const offset = Math.max(0, Number(input.offset ?? 0));
45703
+ const rows = await ctx.client.list(doctype, {
45704
+ fields: adapter.getListFields(),
45705
+ filters: adapter.buildListFilters(input),
45706
+ limit: limit + 1,
45707
+ limit_start: offset,
45708
+ order_by: "modified desc"
44883
45709
  });
44884
- } catch (err) {
44885
- clearTimeout(timer);
44886
- if (err instanceof Error && err.name === "AbortError") {
44887
- throw new FrappeAPIError(
44888
- `Request timed out after ${this.timeoutMs}ms: ${method} ${path}`,
44889
- 408,
44890
- null
44891
- );
44892
- }
44893
- throw new FrappeAPIError(
44894
- `Network error on ${method} ${path}: ${err.message}`,
44895
- 0,
44896
- null
44897
- );
44898
- }
44899
- clearTimeout(timer);
44900
- const rawText = await response.text();
44901
- const contentType = response.headers.get("content-type") ?? "";
44902
- let responseBody = rawText;
44903
- if (contentType.includes("application/json") && rawText.length > 0) {
44904
- try {
44905
- responseBody = JSON.parse(rawText);
44906
- } catch {
44907
- responseBody = rawText;
44908
- }
44909
- }
44910
- if (!response.ok) {
44911
- let msg = response.statusText;
44912
- if (typeof responseBody === "object" && responseBody !== null) {
44913
- const rb = responseBody;
44914
- const excType = rb.exc_type;
44915
- const baseMsg = rb.message ?? excType ?? response.statusText;
44916
- const serverDetails = extractServerMessages(rb._server_messages);
44917
- msg = serverDetails ? `${baseMsg}: ${serverDetails}` : baseMsg;
44918
- } else if (typeof responseBody === "string" && responseBody.length > 0) {
44919
- msg = responseBody.slice(0, 200);
44920
- }
44921
- const retryAfterMs = parseRetryAfter(
44922
- response.headers.get("retry-after")
44923
- );
44924
- throw new FrappeAPIError(
44925
- `${method} ${path} failed: ${msg}`,
44926
- response.status,
44927
- responseBody,
44928
- retryAfterMs
44929
- );
44930
- }
44931
- return responseBody;
44932
- }
44933
- // ── Resource CRUD ───────────────────────────────────────────────────────────
44934
- /**
44935
- * List documents of a DocType.
44936
- * Frappe list API: GET /api/resource/{doctype}?fields=...&filters=...
44937
- */
44938
- async list(doctype, options = {}) {
44939
- const params = new URLSearchParams();
44940
- if (options.fields && options.fields.length > 0) {
44941
- params.set("fields", JSON.stringify(options.fields));
44942
- }
44943
- if (options.filters && options.filters.length > 0) {
44944
- params.set("filters", JSON.stringify(options.filters));
44945
- }
44946
- if (options.order_by) {
44947
- params.set("order_by", options.order_by);
44948
- }
44949
- if (options.limit !== void 0) {
44950
- params.set("limit", String(options.limit));
45710
+ const hasMore = rows.length > limit;
45711
+ const visibleRows = rows.slice(0, limit);
45712
+ const cards = adapter.buildCards(visibleRows);
45713
+ const board = {
45714
+ boardId: `${doctype.toLowerCase()}-board`,
45715
+ title: definition.title,
45716
+ doctype,
45717
+ generatedAt: (/* @__PURE__ */ new Date()).toISOString(),
45718
+ moveToolName: definition.moveToolName,
45719
+ refreshArguments: { ...input, doctype, limit, offset },
45720
+ columns: withColumnCounts(adapter.getColumns(), cards),
45721
+ cards,
45722
+ allowedTransitions: adapter.getAllowedTransitions(),
45723
+ capabilities: { canMoveCards: true },
45724
+ pagination: {
45725
+ limit,
45726
+ offset,
45727
+ loadedCount: offset + visibleRows.length,
45728
+ hasMore
45729
+ }
45730
+ };
45731
+ return {
45732
+ ...board,
45733
+ _meta: KANBAN_META
45734
+ };
44951
45735
  }
44952
- if (options.limit_start !== void 0) {
44953
- params.set("limit_start", String(options.limit_start));
45736
+ },
45737
+ {
45738
+ name: "erpnext_kanban_move_card",
45739
+ category: "kanban",
45740
+ description: "Move a kanban card for a supported ERPNext DocType. Returns structured success or business error details for MCP App reconciliation.",
45741
+ inputSchema: {
45742
+ type: "object",
45743
+ properties: {
45744
+ doctype: {
45745
+ type: "string",
45746
+ description: "Kanban-enabled ERPNext DocType",
45747
+ enum: ["Task", "Opportunity", "Issue"]
45748
+ },
45749
+ card_id: { type: "string", description: "Card/document identifier" },
45750
+ from_column: {
45751
+ type: "string",
45752
+ description: "Source column identifier"
45753
+ },
45754
+ to_column: {
45755
+ type: "string",
45756
+ description: "Destination column identifier"
45757
+ }
45758
+ },
45759
+ required: ["doctype", "card_id", "from_column", "to_column"]
45760
+ },
45761
+ handler: async (input, ctx) => {
45762
+ const doctype = String(input.doctype ?? "");
45763
+ const { adapter } = getAdapter(doctype);
45764
+ return await adapter.executeMove({
45765
+ doctype,
45766
+ cardId: String(input.card_id ?? ""),
45767
+ fromColumn: String(input.from_column ?? ""),
45768
+ toColumn: String(input.to_column ?? "")
45769
+ }, ctx);
44954
45770
  }
44955
- params.set("as_dict", "1");
44956
- const query = params.toString() ? `?${params.toString()}` : "";
44957
- const res = await this.request(
44958
- "GET",
44959
- `/api/resource/${encodeURIComponent(doctype)}${query}`
44960
- );
44961
- return res.data ?? [];
44962
- }
44963
- /**
44964
- * Get a single document by name.
44965
- * GET /api/resource/{doctype}/{name}
44966
- */
44967
- async get(doctype, name) {
44968
- const res = await this.request(
44969
- "GET",
44970
- `/api/resource/${encodeURIComponent(doctype)}/${encodeURIComponent(name)}`
44971
- );
44972
- return res.data;
44973
- }
44974
- /**
44975
- * Create a new document.
44976
- * POST /api/resource/{doctype}
44977
- */
44978
- async create(doctype, data) {
44979
- const res = await this.request(
44980
- "POST",
44981
- `/api/resource/${encodeURIComponent(doctype)}`,
44982
- { data: { ...data, doctype } }
44983
- );
44984
- return res.data;
44985
- }
44986
- /**
44987
- * Update an existing document (partial update).
44988
- * PUT /api/resource/{doctype}/{name}
44989
- */
44990
- async update(doctype, name, data) {
44991
- const res = await this.request(
44992
- "PUT",
44993
- `/api/resource/${encodeURIComponent(doctype)}/${encodeURIComponent(name)}`,
44994
- { data }
44995
- );
44996
- return res.data;
44997
- }
44998
- /**
44999
- * Delete a document.
45000
- * DELETE /api/resource/{doctype}/{name}
45001
- */
45002
- async delete(doctype, name) {
45003
- await this.request(
45004
- "DELETE",
45005
- `/api/resource/${encodeURIComponent(doctype)}/${encodeURIComponent(name)}`
45006
- );
45007
- }
45008
- /**
45009
- * Call a whitelisted Frappe method.
45010
- * POST /api/method/{method}
45011
- */
45012
- async callMethod(method, args = {}) {
45013
- const res = await this.request(
45014
- "POST",
45015
- `/api/method/${method}`,
45016
- args
45017
- );
45018
- return res.message;
45019
45771
  }
45772
+ ];
45773
+
45774
+ // src/tools/mod.ts
45775
+ var toolsByCategory = {
45776
+ sales: salesTools,
45777
+ inventory: inventoryTools,
45778
+ accounting: accountingTools,
45779
+ hr: hrTools,
45780
+ project: projectTools,
45781
+ purchasing: purchasingTools,
45782
+ delivery: deliveryTools,
45783
+ manufacturing: manufacturingTools,
45784
+ crm: crmTools,
45785
+ assets: assetsTools,
45786
+ operations: operationsTools,
45787
+ setup: setupTools,
45788
+ analytics: analyticsTools,
45789
+ kanban: kanbanTools
45020
45790
  };
45021
- var _client = null;
45022
- function getFrappeClient() {
45023
- if (_client) return _client;
45024
- const url = env6("ERPNEXT_URL");
45025
- const apiKey = env6("ERPNEXT_API_KEY");
45026
- const apiSecret = env6("ERPNEXT_API_SECRET");
45027
- if (!url) {
45028
- throw new Error(
45029
- "[lib/erpnext] ERPNEXT_URL is required. Set it to your ERPNext instance URL, e.g. http://localhost:8000"
45030
- );
45031
- }
45032
- if (!apiKey || !apiSecret) {
45033
- throw new Error(
45034
- "[lib/erpnext] ERPNEXT_API_KEY and ERPNEXT_API_SECRET are required. Generate them in ERPNext: User Settings \u2192 API Access."
45035
- );
45036
- }
45037
- _client = new FrappeClient({ baseUrl: url, apiKey, apiSecret });
45038
- return _client;
45791
+ var allTools = [
45792
+ ...salesTools,
45793
+ ...inventoryTools,
45794
+ ...accountingTools,
45795
+ ...hrTools,
45796
+ ...projectTools,
45797
+ ...purchasingTools,
45798
+ ...deliveryTools,
45799
+ ...manufacturingTools,
45800
+ ...crmTools,
45801
+ ...assetsTools,
45802
+ ...operationsTools,
45803
+ ...setupTools,
45804
+ ...analyticsTools,
45805
+ ...kanbanTools
45806
+ ];
45807
+ function getToolsByCategory(category) {
45808
+ return toolsByCategory[category] ?? [];
45809
+ }
45810
+ function getToolByName(name) {
45811
+ return allTools.find((t) => t.name === name);
45039
45812
  }
45040
45813
 
45041
45814
  // src/tools/ui-refresh.ts
@@ -45332,6 +46105,168 @@ function resolveViewerDistPath2(moduleUrl, viewerName, exists) {
45332
46105
  return null;
45333
46106
  }
45334
46107
 
46108
+ // src/auth/composite-provider.ts
46109
+ var CompositeAuthProvider = class extends AuthProvider {
46110
+ constructor(providers) {
46111
+ super();
46112
+ this.providers = providers;
46113
+ if (providers.length === 0) {
46114
+ throw new Error(
46115
+ "[CompositeAuthProvider] at least one provider is required"
46116
+ );
46117
+ }
46118
+ }
46119
+ async verifyToken(token) {
46120
+ for (const provider of this.providers) {
46121
+ const info = await provider.verifyToken(token);
46122
+ if (info) return info;
46123
+ }
46124
+ return null;
46125
+ }
46126
+ getResourceMetadata() {
46127
+ const metadata = this.providers.map(
46128
+ (provider) => provider.getResourceMetadata()
46129
+ );
46130
+ const primary = metadata[0];
46131
+ const scopesSupported = [
46132
+ ...new Set(metadata.flatMap((item) => item.scopes_supported ?? []))
46133
+ ];
46134
+ return {
46135
+ ...primary,
46136
+ authorization_servers: [
46137
+ ...new Set(metadata.flatMap((item) => item.authorization_servers))
46138
+ ],
46139
+ ...scopesSupported.length > 0 ? { scopes_supported: scopesSupported } : {},
46140
+ bearer_methods_supported: [
46141
+ ...new Set(
46142
+ metadata.flatMap((item) => item.bearer_methods_supported)
46143
+ )
46144
+ ]
46145
+ };
46146
+ }
46147
+ };
46148
+
46149
+ // src/auth/config.ts
46150
+ function unquote(value) {
46151
+ if (value.length >= 2 && (value.startsWith('"') && value.endsWith('"') || value.startsWith("'") && value.endsWith("'"))) {
46152
+ return value.slice(1, -1);
46153
+ }
46154
+ return value;
46155
+ }
46156
+ function optionalEnvValue(value) {
46157
+ const trimmed = value?.trim();
46158
+ return trimmed ? unquote(trimmed) : void 0;
46159
+ }
46160
+ function loadAuthConfig2() {
46161
+ const single = optionalEnvValue(env6("MCP_AUTH_TOKEN"));
46162
+ const multi = env6("MCP_AUTH_TOKENS");
46163
+ const jwksUrl = optionalEnvValue(env6("MCP_OAUTH_JWKS_URL"));
46164
+ const tokens = /* @__PURE__ */ new Set();
46165
+ if (single) tokens.add(single);
46166
+ if (multi) {
46167
+ for (const t of multi.split(",")) {
46168
+ const token = optionalEnvValue(t);
46169
+ if (token) tokens.add(token);
46170
+ }
46171
+ }
46172
+ if (tokens.size === 0 && !jwksUrl) return null;
46173
+ return {
46174
+ tokens,
46175
+ resource: optionalEnvValue(env6("MCP_AUTH_RESOURCE")),
46176
+ jwksUrl,
46177
+ audience: optionalEnvValue(env6("MCP_OAUTH_AUDIENCE")),
46178
+ issuer: optionalEnvValue(env6("MCP_OAUTH_ISSUER"))
46179
+ };
46180
+ }
46181
+ function buildAuthProvider(config2) {
46182
+ const providers = [];
46183
+ if (config2.tokens.size > 0) {
46184
+ if (!config2.resource) {
46185
+ throw new Error(
46186
+ "[mcp-erpnext] MCP_AUTH_RESOURCE is required alongside MCP_AUTH_TOKEN(S) \u2014 set it to this server's public URL, e.g. https://mcp.example.com"
46187
+ );
46188
+ }
46189
+ providers.push(
46190
+ createStaticTokenAuthProvider([...config2.tokens], {
46191
+ resource: config2.resource
46192
+ })
46193
+ );
46194
+ }
46195
+ if (config2.jwksUrl) {
46196
+ if (!config2.issuer) {
46197
+ throw new Error(
46198
+ "[mcp-erpnext] MCP_OAUTH_ISSUER is required alongside MCP_OAUTH_JWKS_URL"
46199
+ );
46200
+ }
46201
+ if (!config2.audience) {
46202
+ throw new Error(
46203
+ "[mcp-erpnext] MCP_OAUTH_AUDIENCE is required alongside MCP_OAUTH_JWKS_URL"
46204
+ );
46205
+ }
46206
+ if (!config2.resource) {
46207
+ throw new Error(
46208
+ "[mcp-erpnext] MCP_AUTH_RESOURCE is required alongside MCP_OAUTH_JWKS_URL"
46209
+ );
46210
+ }
46211
+ providers.push(
46212
+ createOIDCAuthProvider({
46213
+ issuer: config2.issuer,
46214
+ audience: config2.audience,
46215
+ jwksUri: config2.jwksUrl,
46216
+ resource: config2.resource
46217
+ })
46218
+ );
46219
+ }
46220
+ return providers.length === 1 ? providers[0] : new CompositeAuthProvider(providers);
46221
+ }
46222
+
46223
+ // src/cache/warm.ts
46224
+ async function warmCache() {
46225
+ const configured = env6("MCP_CACHE_WARM_TOOLS");
46226
+ if (!configured?.trim()) return;
46227
+ const client = getFrappeClient();
46228
+ const names = configured.split(",").map((s) => s.trim()).filter(Boolean);
46229
+ for (const name of names) {
46230
+ const tool = getToolByName(name);
46231
+ if (!tool) {
46232
+ console.error(
46233
+ `[mcp-erpnext] Cache warm: unknown tool "${name}", skipping`
46234
+ );
46235
+ continue;
46236
+ }
46237
+ if (!tool.annotations?.readOnlyHint) {
46238
+ console.error(
46239
+ `[mcp-erpnext] Cache warm: "${name}" is not read-only, refusing to call it, skipping`
46240
+ );
46241
+ continue;
46242
+ }
46243
+ try {
46244
+ await tool.handler({}, { client });
46245
+ } catch (err) {
46246
+ console.error(
46247
+ `[mcp-erpnext] Cache warm: "${name}" failed (non-fatal):`,
46248
+ err
46249
+ );
46250
+ }
46251
+ }
46252
+ }
46253
+
46254
+ // src/auth/resource-metadata-route.ts
46255
+ var ROOT_METADATA_PATH = "/.well-known/oauth-protected-resource";
46256
+ function resourceMetadataRoute(provider) {
46257
+ const metadata = provider.getResourceMetadata();
46258
+ const path = new URL(metadata.resource_metadata_url).pathname;
46259
+ if (path === ROOT_METADATA_PATH) return void 0;
46260
+ return {
46261
+ method: "get",
46262
+ path,
46263
+ handler: () => jsonMetadata(metadata)
46264
+ };
46265
+ }
46266
+ function jsonMetadata(metadata) {
46267
+ return Response.json(metadata);
46268
+ }
46269
+
45335
46270
  // server.ts
45336
46271
  var DEFAULT_HTTP_PORT = 3012;
45337
46272
  async function main() {
@@ -45351,15 +46286,19 @@ async function main() {
45351
46286
  const httpPort = portArg ? parseInt(portArg.split("=")[1], 10) : DEFAULT_HTTP_PORT;
45352
46287
  const hostnameArg = args.find((arg) => arg.startsWith("--hostname="));
45353
46288
  const hostname = hostnameArg ? hostnameArg.split("=")[1] : "127.0.0.1";
46289
+ const authConfig = httpFlag ? loadAuthConfig2() : null;
46290
+ const authProvider = authConfig ? buildAuthProvider(authConfig) : void 0;
46291
+ const authMetadataRoute = authProvider ? resourceMetadataRoute(authProvider) : void 0;
45354
46292
  const toolsClient = new ErpNextToolsClient(
45355
46293
  categories ? { categories } : void 0
45356
46294
  );
45357
46295
  const server = new McpApp({
45358
46296
  name: "mcp-erpnext",
45359
- version: "2.4.2",
46297
+ version: "2.6.0",
45360
46298
  maxConcurrent: 10,
45361
46299
  backpressureStrategy: "queue",
45362
46300
  validateSchema: true,
46301
+ auth: authProvider ? { provider: authProvider } : void 0,
45363
46302
  logger: (msg) => console.error(`[mcp-erpnext] ${msg}`),
45364
46303
  toolErrorMapper: (error2) => {
45365
46304
  if (error2 instanceof FrappeAPIError) return error2.message;
@@ -45401,27 +46340,46 @@ async function main() {
45401
46340
  console.error(
45402
46341
  `[mcp-erpnext] Initialized \u2014 ${toolsClient.count} tools${categories ? ` (categories: ${categories.join(", ")})` : ""}`
45403
46342
  );
46343
+ warmCache().catch((err) => {
46344
+ console.error("[mcp-erpnext] Cache warm failed (non-fatal):", err);
46345
+ });
45404
46346
  if (httpFlag) {
45405
46347
  const isLoopback = hostname === "127.0.0.1" || hostname === "::1" || hostname === "localhost";
45406
46348
  if (!isLoopback) {
45407
46349
  console.error(
45408
- `[mcp-erpnext] WARNING: binding to ${hostname} exposes the HTTP server to the network. Every tool acts with the server's ERPNext API key, so restrict access (firewall, private network, or an authenticating reverse proxy).`
46350
+ `[mcp-erpnext] WARNING: binding to ${hostname} exposes the HTTP server to the network. Every tool acts with the server's ERPNext API key, so restrict access (firewall, private network) or configure auth via MCP_AUTH_TOKEN(S)/MCP_OAUTH_JWKS_URL.`
46351
+ );
46352
+ }
46353
+ onSignal3("SIGINT", () => {
46354
+ console.error("[mcp-erpnext] Shutting down...");
46355
+ exit3(0);
46356
+ });
46357
+ if (!authConfig) {
46358
+ console.error(
46359
+ "[mcp-erpnext] WARNING: No auth configured for HTTP mode. Set MCP_AUTH_TOKEN, MCP_AUTH_TOKENS, or MCP_OAUTH_JWKS_URL to restrict access."
45409
46360
  );
45410
46361
  }
45411
46362
  await server.startHttp({
45412
46363
  port: httpPort,
45413
46364
  hostname,
45414
46365
  cors: true,
46366
+ customRoutes: authMetadataRoute ? [authMetadataRoute] : void 0,
45415
46367
  onListen: (info) => {
45416
46368
  console.error(
45417
- `[mcp-erpnext] HTTP server listening on http://${info.hostname}:${info.port}`
46369
+ `[mcp-erpnext] HTTP server listening${authConfig ? "" : " (unauthenticated)"} on http://${info.hostname}:${info.port}`
45418
46370
  );
46371
+ if (authConfig) {
46372
+ const authMethods = [];
46373
+ if (authConfig.tokens.size > 0) {
46374
+ authMethods.push(`static tokens (${authConfig.tokens.size})`);
46375
+ }
46376
+ if (authConfig.jwksUrl) {
46377
+ authMethods.push(`OAuth JWT JWKS (${authConfig.jwksUrl})`);
46378
+ }
46379
+ console.error(`[mcp-erpnext] Auth: ${authMethods.join(", ")}`);
46380
+ }
45419
46381
  }
45420
46382
  });
45421
- onSignal3("SIGINT", () => {
45422
- console.error("[mcp-erpnext] Shutting down...");
45423
- exit3(0);
45424
- });
45425
46383
  } else {
45426
46384
  await server.start();
45427
46385
  console.error("[mcp-erpnext] stdio mode ready");