@casys/mcp-erpnext 3.2.0-beta.1 → 3.2.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.
package/mcp-erpnext.mjs CHANGED
@@ -18727,8 +18727,8 @@ function isRecursive(inst, stack, resolve) {
18727
18727
  result = answer;
18728
18728
  };
18729
18729
  const def = inst._zod.def;
18730
- const kind = def.type;
18731
- switch (kind) {
18730
+ const kind2 = def.type;
18731
+ switch (kind2) {
18732
18732
  case "object": {
18733
18733
  const raw2 = rawShape(def);
18734
18734
  merge2(raw2 ? shape(raw2, true) : ASSUMED);
@@ -18808,7 +18808,7 @@ function isRecursive(inst, stack, resolve) {
18808
18808
  case "custom":
18809
18809
  break;
18810
18810
  default: {
18811
- kind;
18811
+ kind2;
18812
18812
  for (const key in def) {
18813
18813
  const desc = Object.getOwnPropertyDescriptor(def, key);
18814
18814
  if (!desc || desc.get)
@@ -26730,7 +26730,7 @@ var RESERVED_ENVELOPE_META_KEYS = [
26730
26730
  LOG_LEVEL_META_KEY
26731
26731
  ];
26732
26732
  var RETRY_PARAMS_KEYS = ["inputResponses", "requestState"];
26733
- function liftWireOnlyMaterial(message2, kind) {
26733
+ function liftWireOnlyMaterial(message2, kind2) {
26734
26734
  const params = message2.params;
26735
26735
  if (!isPlainObject$1(params)) return {
26736
26736
  message: message2,
@@ -26738,7 +26738,7 @@ function liftWireOnlyMaterial(message2, kind) {
26738
26738
  };
26739
26739
  const meta2 = params._meta;
26740
26740
  const envelopeKeys = isPlainObject$1(meta2) ? RESERVED_ENVELOPE_META_KEYS.filter((key) => key in meta2) : [];
26741
- const retryKeys = kind === "request" ? RETRY_PARAMS_KEYS.filter((key) => key in params) : [];
26741
+ const retryKeys = kind2 === "request" ? RETRY_PARAMS_KEYS.filter((key) => key in params) : [];
26742
26742
  if (envelopeKeys.length === 0 && retryKeys.length === 0) return {
26743
26743
  message: message2,
26744
26744
  lifted: {}
@@ -48617,6 +48617,9 @@ var FrappeClient = class {
48617
48617
  if (options.order_by) {
48618
48618
  params.set("order_by", options.order_by);
48619
48619
  }
48620
+ if (options.group_by) {
48621
+ params.set("group_by", options.group_by);
48622
+ }
48620
48623
  if (options.limit !== void 0) {
48621
48624
  params.set("limit", String(options.limit));
48622
48625
  }
@@ -48837,6 +48840,20 @@ var FrappeClient = class {
48837
48840
  * the same way the list/resource GET endpoints already do in this client.
48838
48841
  */
48839
48842
  async callMethod(method, args = {}, httpMethod = "POST") {
48843
+ const res = await this.callMethodRaw(
48844
+ method,
48845
+ args,
48846
+ httpMethod
48847
+ );
48848
+ return res.message;
48849
+ }
48850
+ /**
48851
+ * Same call as `callMethod`, but returns the whole response body instead of
48852
+ * its `message`. Needed for methods that fill `frappe.response` rather than
48853
+ * returning a value, such as `frappe.desk.form.load.getdoctype` (`docs` at
48854
+ * the top level, no `message`).
48855
+ */
48856
+ async callMethodRaw(method, args = {}, httpMethod = "POST") {
48840
48857
  if (httpMethod === "GET") {
48841
48858
  const params = new URLSearchParams();
48842
48859
  for (const [key, value] of Object.entries(args)) {
@@ -48847,18 +48864,9 @@ var FrappeClient = class {
48847
48864
  );
48848
48865
  }
48849
48866
  const query = params.toString() ? `?${params.toString()}` : "";
48850
- const res2 = await this.request(
48851
- "GET",
48852
- `/api/method/${method}${query}`
48853
- );
48854
- return res2.message;
48867
+ return await this.request("GET", `/api/method/${method}${query}`);
48855
48868
  }
48856
- const res = await this.request(
48857
- "POST",
48858
- `/api/method/${method}`,
48859
- args
48860
- );
48861
- return res.message;
48869
+ return await this.request("POST", `/api/method/${method}`, args);
48862
48870
  }
48863
48871
  };
48864
48872
  var _client = null;
@@ -50095,6 +50103,215 @@ var salesTools = [
50095
50103
  }
50096
50104
  ];
50097
50105
 
50106
+ // src/tools/errors.ts
50107
+ var ToolInputError = class extends Error {
50108
+ tool;
50109
+ code;
50110
+ /** What was wrong, without the `[tool] CODE:` prefix or the recovery hint. */
50111
+ detail;
50112
+ recovery;
50113
+ /** Always false for input errors unless stated: resending the same call fails again. */
50114
+ retryable;
50115
+ context;
50116
+ constructor(tool, code, detail, options) {
50117
+ super(formatToolInputError(tool, code, detail, options.recovery));
50118
+ this.name = "ToolInputError";
50119
+ this.tool = tool;
50120
+ this.code = code;
50121
+ this.detail = detail;
50122
+ this.recovery = options.recovery;
50123
+ this.retryable = options.retryable === true;
50124
+ this.context = Object.freeze({ ...options.context ?? {} });
50125
+ }
50126
+ };
50127
+ function sentence(text) {
50128
+ const trimmed = text.trim();
50129
+ return /[.!?]$/.test(trimmed) ? trimmed : `${trimmed}.`;
50130
+ }
50131
+ function formatToolError(subject, code, detail, recovery, closeRecoverySentence = false) {
50132
+ return `[${subject}] ${code}: ${detail.trim().replace(/[.!?]$/, "")}. Recovery: ${closeRecoverySentence ? sentence(recovery) : recovery}`;
50133
+ }
50134
+ function formatToolInputError(tool, code, detail, recovery) {
50135
+ return formatToolError(tool, code, detail, recovery, true);
50136
+ }
50137
+
50138
+ // src/tools/doc-list-query.ts
50139
+ var DOC_LIST_DEFAULT_LIMIT = 20;
50140
+ var DEFAULT_ORDER_BY = "modified desc";
50141
+ var FILTER_FORMS = "[fieldname, operator, value] or [child doctype, fieldname, operator, value]";
50142
+ var FRAPPE_FILTERS_INPUT_SCHEMA = {
50143
+ type: "array",
50144
+ description: "Frappe filters: an array of " + FILTER_FORMS + '. The 4-element form filters on a child table (e.g. ["Sales Invoice Item","item_code","=","SKU-1"]). Values may be strings, numbers, booleans or null, or an array of strings/numbers for "in" / "not in". Example: [["status","=","Open"],["company","=","Acme"]]. Fewer than 3 elements are rejected with INVALID_FILTER. Longer tuples stay accepted for compatibility; Frappe reads only the first 4 elements.',
50145
+ items: {
50146
+ type: "array",
50147
+ items: {
50148
+ anyOf: [
50149
+ { type: "string" },
50150
+ { type: "number" },
50151
+ { type: "boolean" },
50152
+ { type: "null" },
50153
+ {
50154
+ type: "array",
50155
+ items: { anyOf: [{ type: "string" }, { type: "number" }] }
50156
+ }
50157
+ ]
50158
+ }
50159
+ }
50160
+ // `anyOf` nodes carry no `type` on purpose (Gemini rejects both together),
50161
+ // which the shared `JSONSchema` type cannot express.
50162
+ };
50163
+ function preview(value) {
50164
+ let text;
50165
+ try {
50166
+ text = JSON.stringify(value) ?? String(value);
50167
+ } catch {
50168
+ text = String(value);
50169
+ }
50170
+ return text.length > 120 ? `${text.slice(0, 117)}...` : text;
50171
+ }
50172
+ function kind(value) {
50173
+ if (value === null) return "null";
50174
+ if (Array.isArray(value)) return "array";
50175
+ return typeof value;
50176
+ }
50177
+ function isNonEmptyString(value) {
50178
+ return typeof value === "string" && value.trim() !== "";
50179
+ }
50180
+ function isFilterValue(value) {
50181
+ if (value === null || typeof value === "string") return true;
50182
+ if (typeof value === "boolean") return true;
50183
+ if (typeof value === "number") return Number.isFinite(value);
50184
+ return Array.isArray(value) && value.every(
50185
+ (item) => typeof item === "string" || typeof item === "number" && Number.isFinite(item)
50186
+ );
50187
+ }
50188
+ function parseFrappeFilters(tool, raw2) {
50189
+ if (raw2 === void 0) return [];
50190
+ const recovery = `Send filters as an array of ${FILTER_FORMS}, for example [["status","=","Open"]].`;
50191
+ if (!Array.isArray(raw2)) {
50192
+ throw new ToolInputError(
50193
+ tool,
50194
+ "INVALID_FILTER",
50195
+ `'filters' must be an array of filter tuples, got ${kind(raw2)}`,
50196
+ { recovery, context: { received: kind(raw2) } }
50197
+ );
50198
+ }
50199
+ raw2.forEach((entry, index) => {
50200
+ const at = `filters[${index}]`;
50201
+ const fail = (detail, extra = {}) => {
50202
+ throw new ToolInputError(
50203
+ tool,
50204
+ "INVALID_FILTER",
50205
+ `${at} ${detail} (got ${preview(entry)})`,
50206
+ { recovery, context: { index, ...extra } }
50207
+ );
50208
+ };
50209
+ if (!Array.isArray(entry)) {
50210
+ fail(`must be an array, got ${kind(entry)}`);
50211
+ return;
50212
+ }
50213
+ if (entry.length < 3) {
50214
+ fail(`has ${entry.length} element(s); expected 3 or 4`, {
50215
+ arity: entry.length
50216
+ });
50217
+ return;
50218
+ }
50219
+ const child = entry.length >= 4;
50220
+ const names = child ? ["child doctype", "fieldname", "operator"] : ["fieldname", "operator"];
50221
+ names.forEach((label, position) => {
50222
+ const value = entry[position];
50223
+ const ok = label === "operator" ? typeof value === "string" : isNonEmptyString(value);
50224
+ if (!ok) {
50225
+ fail(
50226
+ `has an invalid ${label} at position ${position}: expected a ${label === "operator" ? "string" : "non-empty string"}, got ${value === "" || typeof value === "string" && !value.trim() ? "empty string" : kind(value)}`,
50227
+ { position }
50228
+ );
50229
+ }
50230
+ });
50231
+ const valuePosition = child ? 3 : 2;
50232
+ if (!isFilterValue(entry[valuePosition])) {
50233
+ fail(
50234
+ `has an invalid value at position ${valuePosition}: expected a string, number, boolean, null, or an array of strings/numbers`,
50235
+ { position: valuePosition }
50236
+ );
50237
+ }
50238
+ });
50239
+ return raw2;
50240
+ }
50241
+ function parsePagination(tool, input) {
50242
+ const invalid2 = (field, value) => {
50243
+ throw new ToolInputError(
50244
+ tool,
50245
+ "INVALID_PAGINATION",
50246
+ `'${field}' must be a non-negative integer, got ${preview(value)}`,
50247
+ {
50248
+ recovery: field === "limit" ? "Send an integer >= 1 (default 20), or 0 for every row." : "Send an integer >= 0, or omit 'offset'.",
50249
+ context: { field, value }
50250
+ }
50251
+ );
50252
+ };
50253
+ const limit = input.limit === void 0 ? DOC_LIST_DEFAULT_LIMIT : input.limit;
50254
+ if (typeof limit !== "number" || !Number.isSafeInteger(limit) || limit < 0) {
50255
+ return invalid2("limit", input.limit);
50256
+ }
50257
+ const offset = input.offset;
50258
+ if (offset !== void 0) {
50259
+ if (typeof offset !== "number" || !Number.isSafeInteger(offset) || offset < 0) {
50260
+ return invalid2("offset", offset);
50261
+ }
50262
+ if (limit === 0 && offset > 0) {
50263
+ throw new ToolInputError(
50264
+ tool,
50265
+ "INVALID_PAGINATION",
50266
+ `'limit' 0 means "every row" in Frappe, which then ignores 'offset' (${offset}) and would return the wrong rows`,
50267
+ {
50268
+ recovery: "Use a positive 'limit' together with 'offset', or drop 'offset' when 'limit' is 0.",
50269
+ context: { limit, offset }
50270
+ }
50271
+ );
50272
+ }
50273
+ return { limit, offset };
50274
+ }
50275
+ return { limit };
50276
+ }
50277
+ var QUOTABLE_DOCTYPE = /^[A-Za-z0-9_ -]+$/;
50278
+ var PLAIN_IDENTIFIER = /^[A-Za-z_][A-Za-z0-9_]*$/;
50279
+ var BARE_ORDER_CLAUSE = /^`?([A-Za-z_][A-Za-z0-9_]*)`?(?:\s+(asc|desc))?$/i;
50280
+ function tableOf(doctype) {
50281
+ return `\`tab${doctype}\``;
50282
+ }
50283
+ function hasChildTableFilter(doctype, filters) {
50284
+ return filters.some(
50285
+ (filter) => Array.isArray(filter) && filter.length >= 4 && filter[0] !== doctype
50286
+ );
50287
+ }
50288
+ function qualifyOrderBy(doctype, orderBy) {
50289
+ if (orderBy.includes("(")) return orderBy;
50290
+ return orderBy.split(",").map((clause) => {
50291
+ const trimmed = clause.trim();
50292
+ const bare = BARE_ORDER_CLAUSE.exec(trimmed);
50293
+ return bare ? `${tableOf(doctype)}.${bare[1]}${bare[2] ? ` ${bare[2]}` : ""}` : trimmed;
50294
+ }).join(", ");
50295
+ }
50296
+ function planListQuery(doctype, input) {
50297
+ const requested = input.order_by;
50298
+ if (!hasChildTableFilter(doctype, input.filters)) {
50299
+ return { order_by: requested ?? DEFAULT_ORDER_BY };
50300
+ }
50301
+ if (!QUOTABLE_DOCTYPE.test(doctype)) {
50302
+ return requested === void 0 ? {} : { order_by: requested };
50303
+ }
50304
+ const order_by = qualifyOrderBy(doctype, requested ?? DEFAULT_ORDER_BY);
50305
+ if (order_by === "") return {};
50306
+ const parentColumn = `${tableOf(doctype)}.`;
50307
+ const parentOnly = input.fields.every(
50308
+ (field) => field === "*" || PLAIN_IDENTIFIER.test(field)
50309
+ ) && order_by.split(",").every(
50310
+ (clause) => clause.trim().startsWith(parentColumn)
50311
+ );
50312
+ return parentOnly ? { order_by, group_by: `${parentColumn}name` } : { order_by };
50313
+ }
50314
+
50098
50315
  // src/tools/inventory.ts
50099
50316
  var inventoryTools = [
50100
50317
  // ── Items ─────────────────────────────────────────────────────────────────
@@ -50398,7 +50615,9 @@ var inventoryTools = [
50398
50615
  }
50399
50616
  },
50400
50617
  handler: async (input, ctx) => {
50401
- const limit = input.limit ?? 20;
50618
+ const { limit } = parsePagination("erpnext_stock_entry_list", {
50619
+ limit: input.limit ?? void 0
50620
+ });
50402
50621
  const filters = [];
50403
50622
  if (input.item_code) {
50404
50623
  filters.push([
@@ -50421,18 +50640,24 @@ var inventoryTools = [
50421
50640
  if (input.date_to) {
50422
50641
  filters.push(["posting_date", "<=", input.date_to]);
50423
50642
  }
50643
+ const fields = [
50644
+ "name",
50645
+ "stock_entry_type",
50646
+ "posting_date",
50647
+ "from_warehouse",
50648
+ "to_warehouse",
50649
+ "total_amount"
50650
+ ];
50651
+ const { order_by, group_by } = planListQuery("Stock Entry", {
50652
+ filters,
50653
+ fields
50654
+ });
50424
50655
  const docs = await ctx.client.list("Stock Entry", {
50425
- fields: [
50426
- "name",
50427
- "stock_entry_type",
50428
- "posting_date",
50429
- "from_warehouse",
50430
- "to_warehouse",
50431
- "total_amount"
50432
- ],
50656
+ fields,
50433
50657
  filters,
50434
50658
  limit,
50435
- order_by: "modified desc"
50659
+ ...order_by !== void 0 ? { order_by } : {},
50660
+ ...group_by !== void 0 ? { group_by } : {}
50436
50661
  });
50437
50662
  return {
50438
50663
  doctype: "Stock Entry",
@@ -50684,7 +50909,7 @@ var accountingTools = [
50684
50909
  name: "erpnext_payment_entry_list",
50685
50910
  annotations: { readOnlyHint: true },
50686
50911
  _meta: DOCLIST_META,
50687
- description: "List Payment Entries. Filterable by payment_type, party_type, date range. Fields: name, payment_type, party_type, party, posting_date, paid_amount, currency. payment_type values: Receive, Pay, Internal Transfer.",
50912
+ description: "List Payment Entries. Filterable by payment_type, party_type, date range. Fields: name, payment_type, party_type, party, posting_date, paid_amount, currency, received_amount, paid_from_account_currency, paid_to_account_currency. currency is an alias of paid_from_account_currency and applies to paid_amount. received_amount uses paid_to_account_currency. The party is the source for Receive and destination for Pay. payment_type values: Receive, Pay, Internal Transfer.",
50688
50913
  category: "accounting",
50689
50914
  inputSchema: {
50690
50915
  type: "object",
@@ -50747,7 +50972,9 @@ var accountingTools = [
50747
50972
  "party",
50748
50973
  "posting_date",
50749
50974
  "paid_amount",
50750
- "currency"
50975
+ "received_amount",
50976
+ "paid_from_account_currency",
50977
+ "paid_to_account_currency"
50751
50978
  ],
50752
50979
  filters,
50753
50980
  limit,
@@ -50756,7 +50983,10 @@ var accountingTools = [
50756
50983
  return {
50757
50984
  doctype: "Payment Entry",
50758
50985
  count: docs.length,
50759
- data: docs,
50986
+ data: docs.map((doc) => ({
50987
+ ...doc,
50988
+ currency: doc.paid_from_account_currency
50989
+ })),
50760
50990
  _meta: DOCLIST_META
50761
50991
  };
50762
50992
  }
@@ -52930,7 +53160,7 @@ var deliveryTools = [
52930
53160
  name: "erpnext_shipment_list",
52931
53161
  annotations: { readOnlyHint: true },
52932
53162
  _meta: DOCLIST_META,
52933
- description: "List Shipments. Filterable by status, pickup_from date range. Fields: name, status, pickup_date, delivery_date, carrier, shipment_amount.",
53163
+ description: "List Shipments. Filterable by status, pickup_date range, and carrier. Fields: name, status, pickup_date, carrier, shipment_amount.",
52934
53164
  category: "delivery",
52935
53165
  inputSchema: {
52936
53166
  type: "object",
@@ -52938,7 +53168,7 @@ var deliveryTools = [
52938
53168
  limit: { type: "number", description: "Max results (default 20)" },
52939
53169
  status: {
52940
53170
  type: "string",
52941
- description: "Filter by status (Draft, Submitted, Booked, Delivered, Cancelled, etc.)"
53171
+ description: "Filter by status (Draft, Submitted, Booked, Completed, Cancelled)"
52942
53172
  },
52943
53173
  carrier: { type: "string", description: "Filter by carrier name" },
52944
53174
  date_from: {
@@ -52971,7 +53201,6 @@ var deliveryTools = [
52971
53201
  "name",
52972
53202
  "status",
52973
53203
  "pickup_date",
52974
- "delivery_date",
52975
53204
  "carrier",
52976
53205
  "shipment_amount"
52977
53206
  ],
@@ -53688,7 +53917,7 @@ var assetsTools = [
53688
53917
  name: "erpnext_asset_list",
53689
53918
  annotations: { readOnlyHint: true },
53690
53919
  _meta: DOCLIST_META,
53691
- description: "List Fixed Assets. Filterable by status, asset_category, location, custodian. Fields: name, asset_name, asset_category, status, purchase_date, gross_purchase_amount, current_value, location, custodian.",
53920
+ description: "List Fixed Assets. Filterable by status, asset_category, location, custodian. Fields: name, asset_name, asset_category, status, purchase_date, gross_purchase_amount, net_purchase_amount, current_value, value_after_depreciation, location, custodian. gross_purchase_amount is an alias of net_purchase_amount (the ERPNext v15 field was renamed in v16). current_value is an alias of value_after_depreciation, the book value rather than market value.",
53692
53921
  category: "assets",
53693
53922
  inputSchema: {
53694
53923
  type: "object",
@@ -53745,17 +53974,8 @@ var assetsTools = [
53745
53974
  filters.push(["purchase_date", "<=", input.date_to]);
53746
53975
  }
53747
53976
  const docs = await ctx.client.list("Asset", {
53748
- fields: [
53749
- "name",
53750
- "asset_name",
53751
- "asset_category",
53752
- "status",
53753
- "purchase_date",
53754
- "gross_purchase_amount",
53755
- "current_value",
53756
- "location",
53757
- "custodian"
53758
- ],
53977
+ // The purchase amount was renamed in v16. Project only the list columns below.
53978
+ fields: ["*"],
53759
53979
  filters,
53760
53980
  limit,
53761
53981
  order_by: "modified desc"
@@ -53763,7 +53983,27 @@ var assetsTools = [
53763
53983
  return {
53764
53984
  doctype: "Asset",
53765
53985
  count: docs.length,
53766
- data: docs,
53986
+ data: docs.map((doc) => {
53987
+ if (!("net_purchase_amount" in doc) && !("gross_purchase_amount" in doc)) {
53988
+ throw new Error(
53989
+ "[erpnext_asset_list] Asset has no supported net purchase amount field"
53990
+ );
53991
+ }
53992
+ const purchaseAmount = "net_purchase_amount" in doc ? doc.net_purchase_amount : doc.gross_purchase_amount;
53993
+ return {
53994
+ name: doc.name,
53995
+ asset_name: doc.asset_name,
53996
+ asset_category: doc.asset_category,
53997
+ status: doc.status,
53998
+ purchase_date: doc.purchase_date,
53999
+ gross_purchase_amount: purchaseAmount,
54000
+ net_purchase_amount: purchaseAmount,
54001
+ current_value: doc.value_after_depreciation,
54002
+ value_after_depreciation: doc.value_after_depreciation,
54003
+ location: doc.location,
54004
+ custodian: doc.custodian
54005
+ };
54006
+ }),
53767
54007
  _meta: DOCLIST_META
53768
54008
  };
53769
54009
  }
@@ -53791,7 +54031,7 @@ var assetsTools = [
53791
54031
  },
53792
54032
  {
53793
54033
  name: "erpnext_asset_create",
53794
- description: "Create a new Asset record. Requires asset_name, asset_category, company, purchase_date, gross_purchase_amount. Optionally set location, custodian, item_code.",
54034
+ description: "Create a new Asset record. Requires asset_name, asset_category, company, purchase_date, gross_purchase_amount (net purchase amount before depreciation, using the legacy field name). Supports ERPNext v15 and v16. Set a fixed asset item_code and location as required by ERPNext.",
53795
54035
  category: "assets",
53796
54036
  inputSchema: {
53797
54037
  type: "object",
@@ -53811,11 +54051,11 @@ var assetsTools = [
53811
54051
  },
53812
54052
  gross_purchase_amount: {
53813
54053
  type: "number",
53814
- description: "Purchase cost (before depreciation)"
54054
+ description: "Net purchase amount before depreciation (legacy parameter name)"
53815
54055
  },
53816
54056
  item_code: {
53817
54057
  type: "string",
53818
- description: "Linked item code (optional)"
54058
+ description: "Fixed asset item code (required by ERPNext)"
53819
54059
  },
53820
54060
  location: {
53821
54061
  type: "string",
@@ -53857,7 +54097,9 @@ var assetsTools = [
53857
54097
  asset_category: input.asset_category,
53858
54098
  company: input.company,
53859
54099
  purchase_date: input.purchase_date,
54100
+ // Frappe persists only DocType fields; v15/v16 use different names for this amount.
53860
54101
  gross_purchase_amount: input.gross_purchase_amount,
54102
+ net_purchase_amount: input.gross_purchase_amount,
53861
54103
  item_code: input.item_code ?? void 0,
53862
54104
  location: input.location ?? void 0,
53863
54105
  custodian: input.custodian ?? void 0
@@ -54046,6 +54288,27 @@ var assetsTools = [
54046
54288
  }
54047
54289
  ];
54048
54290
 
54291
+ // src/tools/file-download-error.ts
54292
+ var FILE_DOWNLOAD_TOOL_NAME = "erpnext_file_download";
54293
+ var FileDownloadError = class extends Error {
54294
+ code;
54295
+ detail;
54296
+ retryable;
54297
+ context;
54298
+ recovery;
54299
+ constructor(code, detail, options) {
54300
+ super(
54301
+ formatToolError(FILE_DOWNLOAD_TOOL_NAME, code, detail, options.recovery)
54302
+ );
54303
+ this.name = "FileDownloadError";
54304
+ this.code = code;
54305
+ this.detail = detail;
54306
+ this.retryable = options.retryable === true;
54307
+ this.context = Object.freeze({ ...options.context ?? {} });
54308
+ this.recovery = options.recovery;
54309
+ }
54310
+ };
54311
+
54049
54312
  // src/tools/method-allowlist.ts
54050
54313
  var METHOD_PATH_RE = /^[A-Za-z_][A-Za-z0-9_]*(\.[A-Za-z_][A-Za-z0-9_]*)*$/;
54051
54314
  function isValidMethodPath(method) {
@@ -54129,8 +54392,6 @@ function isEmptyPartyAccountCurrencyNone(text) {
54129
54392
  }
54130
54393
 
54131
54394
  // src/tools/operations.ts
54132
- var DOC_LIST_MAX_LIMIT = 5e3;
54133
- var DOC_LIST_MAX_OFFSET = 1e5;
54134
54395
  function bytesToBase64(bytes) {
54135
54396
  const chunkSize = 32 * 1024;
54136
54397
  let binary = "";
@@ -54141,6 +54402,23 @@ function bytesToBase64(bytes) {
54141
54402
  }
54142
54403
  return btoa(binary);
54143
54404
  }
54405
+ function assertMcpAppsClient(ctx) {
54406
+ const capability = getMcpAppsCapability(ctx.clientCapabilities);
54407
+ if (capability?.mimeTypes?.includes(MCP_APP_MIME_TYPE) === true) return;
54408
+ throw new FileDownloadError(
54409
+ "APP_CONTEXT_REQUIRED",
54410
+ "attachment content is only served to MCP Apps hosts, never to the model",
54411
+ {
54412
+ context: {
54413
+ tool: FILE_DOWNLOAD_TOOL_NAME,
54414
+ requiredExtension: MCP_APPS_EXTENSION_ID,
54415
+ requiredMimeType: MCP_APP_MIME_TYPE,
54416
+ clientAnnouncedExtension: capability !== void 0
54417
+ },
54418
+ recovery: "open the document in an MCP Apps viewer to download the attachment; use erpnext_file_list to read attachment metadata (name, size, file_url)"
54419
+ }
54420
+ );
54421
+ }
54144
54422
  async function withPiAccountHintsIfNeeded(doctype, fn) {
54145
54423
  return isPurchaseInvoiceDoctype(doctype) ? await withPurchaseInvoiceAccountHints(fn) : await fn();
54146
54424
  }
@@ -54230,7 +54508,7 @@ var operationsTools = [
54230
54508
  visibility: ["app"]
54231
54509
  }
54232
54510
  },
54233
- description: "Download one ERPNext attachment for the document viewer. The tool accepts a File ID, verifies its document attachment, and returns one embedded binary resource.",
54511
+ description: "Download one ERPNext attachment for the document viewer. The tool accepts a File ID, verifies its document attachment, and returns one embedded binary resource. Refused with APP_CONTEXT_REQUIRED unless the MCP client announces the MCP Apps extension.",
54234
54512
  category: "operations",
54235
54513
  inputSchema: {
54236
54514
  type: "object",
@@ -54266,6 +54544,7 @@ var operationsTools = [
54266
54544
  );
54267
54545
  }
54268
54546
  }
54547
+ assertMcpAppsClient(ctx);
54269
54548
  const file = await ctx.client.downloadFile({
54270
54549
  fileId: input.file_id.trim(),
54271
54550
  attachedToDoctype: input.attached_to_doctype.trim(),
@@ -54639,7 +54918,7 @@ var operationsTools = [
54639
54918
  name: "erpnext_doc_list",
54640
54919
  annotations: { readOnlyHint: true },
54641
54920
  _meta: DOCLIST_META,
54642
- description: "List any ERPNext documents by DocType. Useful for DocTypes not covered by dedicated tools. Supports field selection, filters (as JSON array), bounded pagination, and an optional fresh read.",
54921
+ description: "List any ERPNext documents by DocType. Useful for DocTypes not covered by dedicated tools. Supports field selection, filters (as JSON array, including child-table filters), limit/offset pagination, and an optional fresh read. Errors read '[erpnext_doc_list] CODE: message. Recovery: ...'.",
54643
54922
  category: "operations",
54644
54923
  inputSchema: {
54645
54924
  type: "object",
@@ -54653,67 +54932,16 @@ var operationsTools = [
54653
54932
  description: "Fields to fetch (default: ['name', 'modified']). Use ['*'] for all fields.",
54654
54933
  items: { type: "string" }
54655
54934
  },
54656
- filters: {
54657
- type: "array",
54658
- description: 'Frappe filters as array of [fieldname, operator, value] tuples, or [child doctype, fieldname, operator, value] to filter on a child table. Values may be strings, numbers, booleans, null, or string/number arrays for in/not in. Example: [["status","=","Open"],["company","=","Acme"]]',
54659
- items: {
54660
- type: "array",
54661
- anyOf: [
54662
- {
54663
- prefixItems: [
54664
- { type: "string", minLength: 1 },
54665
- { type: "string", minLength: 1 },
54666
- {
54667
- oneOf: [
54668
- { type: ["string", "number", "boolean", "null"] },
54669
- {
54670
- type: "array",
54671
- items: { type: ["string", "number"] }
54672
- }
54673
- ]
54674
- }
54675
- ],
54676
- minItems: 3,
54677
- maxItems: 3
54678
- },
54679
- {
54680
- prefixItems: [
54681
- { type: "string", minLength: 1 },
54682
- { type: "string", minLength: 1 },
54683
- { type: "string", minLength: 1 },
54684
- {
54685
- oneOf: [
54686
- { type: ["string", "number", "boolean", "null"] },
54687
- {
54688
- type: "array",
54689
- items: { type: ["string", "number"] }
54690
- }
54691
- ]
54692
- }
54693
- ],
54694
- minItems: 4,
54695
- maxItems: 4
54696
- },
54697
- {
54698
- // 3.0.x advertised arbitrary string arrays. Keep accepting
54699
- // that legacy surface while describing real 3/4-part Frappe
54700
- // tuples precisely for modern clients.
54701
- items: { type: "string" }
54702
- }
54703
- ]
54704
- }
54705
- },
54935
+ filters: FRAPPE_FILTERS_INPUT_SCHEMA,
54706
54936
  limit: {
54707
54937
  type: "integer",
54708
- minimum: 1,
54709
- maximum: DOC_LIST_MAX_LIMIT,
54710
- description: "Max results (default 20, maximum 5000)"
54938
+ minimum: 0,
54939
+ description: `Max rows (default ${DOC_LIST_DEFAULT_LIMIT}). No server-side cap. 0 = every row (Frappe semantics), potentially very large: prefer limit + offset. limit 0 cannot be combined with offset > 0 (INVALID_PAGINATION).`
54711
54940
  },
54712
54941
  offset: {
54713
54942
  type: "integer",
54714
54943
  minimum: 0,
54715
- maximum: DOC_LIST_MAX_OFFSET,
54716
- description: "Pagination offset (default 0, maximum 100000)"
54944
+ description: "Rows to skip (default 0), for pagination with a positive limit."
54717
54945
  },
54718
54946
  skip_cache: {
54719
54947
  type: "boolean",
@@ -54727,33 +54955,66 @@ var operationsTools = [
54727
54955
  required: ["doctype"]
54728
54956
  },
54729
54957
  handler: async (input, ctx) => {
54730
- if (!input.doctype) {
54731
- throw new Error("[erpnext_doc_list] 'doctype' is required");
54958
+ const tool = "erpnext_doc_list";
54959
+ if (typeof input.doctype !== "string" || !input.doctype.trim()) {
54960
+ throw new ToolInputError(
54961
+ tool,
54962
+ "INVALID_INPUT",
54963
+ "'doctype' is required and must be a non-empty string",
54964
+ {
54965
+ recovery: "Pass the DocType name, e.g. doctype: 'Lead'.",
54966
+ context: { field: "doctype" }
54967
+ }
54968
+ );
54732
54969
  }
54733
- if (input.limit !== void 0 && (typeof input.limit !== "number" || !Number.isInteger(input.limit) || input.limit < 1 || input.limit > DOC_LIST_MAX_LIMIT)) {
54734
- throw new Error(
54735
- `[erpnext_doc_list] 'limit' must be an integer between 1 and ${DOC_LIST_MAX_LIMIT}`
54970
+ if (input.skip_cache !== void 0 && typeof input.skip_cache !== "boolean") {
54971
+ throw new ToolInputError(
54972
+ tool,
54973
+ "INVALID_INPUT",
54974
+ "'skip_cache' must be a boolean",
54975
+ {
54976
+ recovery: "Pass true to bypass the list cache, or omit it.",
54977
+ context: { field: "skip_cache" }
54978
+ }
54736
54979
  );
54737
54980
  }
54738
- if (input.offset !== void 0 && (typeof input.offset !== "number" || !Number.isInteger(input.offset) || input.offset < 0 || input.offset > DOC_LIST_MAX_OFFSET)) {
54739
- throw new Error(
54740
- `[erpnext_doc_list] 'offset' must be an integer between 0 and ${DOC_LIST_MAX_OFFSET}`
54981
+ if (input.fields !== void 0 && (!Array.isArray(input.fields) || input.fields.some((field) => typeof field !== "string"))) {
54982
+ throw new ToolInputError(
54983
+ tool,
54984
+ "INVALID_INPUT",
54985
+ "'fields' must be an array of strings",
54986
+ {
54987
+ recovery: "Pass field names, e.g. ['name', 'status'], or ['*'].",
54988
+ context: { field: "fields" }
54989
+ }
54741
54990
  );
54742
54991
  }
54743
- if (input.skip_cache !== void 0 && typeof input.skip_cache !== "boolean") {
54744
- throw new Error("[erpnext_doc_list] 'skip_cache' must be a boolean");
54992
+ if (input.order_by !== void 0 && typeof input.order_by !== "string") {
54993
+ throw new ToolInputError(
54994
+ tool,
54995
+ "INVALID_INPUT",
54996
+ "'order_by' must be a string",
54997
+ {
54998
+ recovery: "Pass a clause such as 'modified desc', or omit it.",
54999
+ context: { field: "order_by" }
55000
+ }
55001
+ );
54745
55002
  }
54746
- const limit = input.limit ?? 20;
55003
+ const { limit, offset } = parsePagination(tool, input);
55004
+ const filters = parseFrappeFilters(tool, input.filters);
54747
55005
  const fields = input.fields ?? ["name", "modified"];
54748
- const filters = input.filters ?? [];
54749
- const order_by = input.order_by ?? "modified desc";
54750
- const offset = input.offset;
55006
+ const { order_by, group_by } = planListQuery(input.doctype, {
55007
+ filters,
55008
+ fields,
55009
+ order_by: input.order_by
55010
+ });
54751
55011
  const listOptions = {
54752
55012
  fields,
54753
55013
  filters,
54754
55014
  limit,
54755
55015
  ...offset !== void 0 ? { limit_start: offset } : {},
54756
- order_by
55016
+ ...order_by !== void 0 ? { order_by } : {},
55017
+ ...group_by !== void 0 ? { group_by } : {}
54757
55018
  };
54758
55019
  const docs = input.skip_cache === true ? await ctx.client.list(input.doctype, listOptions, {
54759
55020
  skipCache: true
@@ -55106,6 +55367,129 @@ var setupTools = [
55106
55367
  }
55107
55368
  ];
55108
55369
 
55370
+ // src/tools/analytics-money.ts
55371
+ function moneyScopeSchema(currencyDescription) {
55372
+ return {
55373
+ type: "object",
55374
+ properties: {
55375
+ company: {
55376
+ type: "string",
55377
+ minLength: 1,
55378
+ description: "Filter by company name"
55379
+ },
55380
+ currency: {
55381
+ type: "string",
55382
+ minLength: 1,
55383
+ description: currencyDescription
55384
+ }
55385
+ }
55386
+ };
55387
+ }
55388
+ function normalizedCurrency(value) {
55389
+ const trimmed = value.trim();
55390
+ return /^[a-z]{3}$/i.test(trimmed) ? trimmed.toUpperCase() : trimmed;
55391
+ }
55392
+ function parseMoneyScope(tool, input) {
55393
+ const scope = {};
55394
+ for (const field of ["company", "currency"]) {
55395
+ const value = input[field];
55396
+ if (value === void 0) continue;
55397
+ if (typeof value !== "string" || !value.trim()) {
55398
+ throw new ToolInputError(
55399
+ tool,
55400
+ "INVALID_INPUT",
55401
+ `'${field}' must be a non-empty string`,
55402
+ {
55403
+ context: { field },
55404
+ recovery: `Pass the exact ${field} name, or omit '${field}' to include every ${field}.`
55405
+ }
55406
+ );
55407
+ }
55408
+ scope[field] = field === "currency" ? normalizedCurrency(value) : value.trim();
55409
+ }
55410
+ return scope;
55411
+ }
55412
+ function moneyScopeFilters(scope, currencyField = "currency") {
55413
+ return [
55414
+ ...scope.company ? [["company", "=", scope.company]] : [],
55415
+ ...scope.currency ? [[currencyField, "=", scope.currency]] : []
55416
+ ];
55417
+ }
55418
+ var AnalyticsDataError = class extends Error {
55419
+ constructor(tool, code, detail, recovery, context2 = {}) {
55420
+ super(formatToolError(tool, code, detail, recovery));
55421
+ this.tool = tool;
55422
+ this.code = code;
55423
+ this.recovery = recovery;
55424
+ this.name = "AnalyticsDataError";
55425
+ this.context = Object.freeze({ ...context2 });
55426
+ }
55427
+ retryable = false;
55428
+ context;
55429
+ };
55430
+ function moneyValues(tool, rows, amountField, currencyField, selectedCurrency) {
55431
+ const currencies = /* @__PURE__ */ new Set();
55432
+ const amounts = rows.map((row, index) => {
55433
+ const document = typeof row.name === "string" ? `document '${row.name}'` : `document at row ${index}`;
55434
+ const currency2 = row[currencyField];
55435
+ if (typeof currency2 !== "string" || !currency2.trim()) {
55436
+ throw new AnalyticsDataError(
55437
+ tool,
55438
+ "MISSING_CURRENCY",
55439
+ `Cannot determine '${currencyField}' for ${document}`,
55440
+ `Inspect and repair '${currencyField}' on the source document; no currency is guessed.`,
55441
+ { currencyField, name: row.name, index }
55442
+ );
55443
+ }
55444
+ currencies.add(normalizedCurrency(currency2));
55445
+ const raw2 = row[amountField];
55446
+ const amount = typeof raw2 === "number" || typeof raw2 === "string" && raw2.trim() ? Number(raw2) : Number.NaN;
55447
+ if (!Number.isFinite(amount)) {
55448
+ throw new AnalyticsDataError(
55449
+ tool,
55450
+ "INVALID_AMOUNT",
55451
+ `${document} has no finite '${amountField}'`,
55452
+ `Inspect and repair '${amountField}' on the source document before calculating this KPI.`,
55453
+ { amountField, name: row.name, index }
55454
+ );
55455
+ }
55456
+ return amount;
55457
+ });
55458
+ if (currencies.size > 1 || selectedCurrency && currencies.size === 1 && !currencies.has(selectedCurrency)) {
55459
+ throw new AnalyticsDataError(
55460
+ tool,
55461
+ "MIXED_CURRENCIES",
55462
+ `Amounts in '${currencyField}' have incompatible currencies: ${[...currencies].sort().join(", ")}`,
55463
+ "Call this KPI with one 'currency' (and optionally 'company'); amounts in different currencies are never added or converted.",
55464
+ { currencyField, currencies: [...currencies].sort(), selectedCurrency }
55465
+ );
55466
+ }
55467
+ const currency = currencies.values().next().value ?? selectedCurrency;
55468
+ return { ...currency ? { currency } : {}, amounts };
55469
+ }
55470
+ function sumMoney(tool, amounts) {
55471
+ let sum = 0;
55472
+ let correction = 0;
55473
+ for (const amount of amounts) {
55474
+ const corrected = amount - correction;
55475
+ const next = sum + corrected;
55476
+ correction = next - sum - corrected;
55477
+ sum = next;
55478
+ }
55479
+ if (!Number.isFinite(sum)) {
55480
+ throw new AnalyticsDataError(
55481
+ tool,
55482
+ "INVALID_AMOUNT",
55483
+ "The total exceeds the supported numeric range",
55484
+ "Narrow this KPI with 'company' or 'currency' and inspect unusually large source amounts."
55485
+ );
55486
+ }
55487
+ return sum;
55488
+ }
55489
+ function formatMoney(amount, currency) {
55490
+ return currency && /^[A-Z]{3}$/.test(currency) ? amount.toLocaleString("en-US", { style: "currency", currency }) : `${amount.toLocaleString("en-US")}${currency ? ` ${currency}` : ""}`;
55491
+ }
55492
+
55109
55493
  // src/tools/analytics.ts
55110
55494
  var MAX_RADAR_ITEMS = 8;
55111
55495
  function monthlyLabels(now, count) {
@@ -55339,11 +55723,14 @@ var analyticsTools = [
55339
55723
  name: "erpnext_revenue_trend",
55340
55724
  annotations: { readOnlyHint: true },
55341
55725
  _meta: CHART_META,
55342
- description: "Sales revenue trend over time. Returns a line chart (or area if type='area') with monthly revenue from Sales Orders. Add group_by='customer' for multi-line per customer. Use type='stacked-area' to stack customers.",
55726
+ description: "Sales revenue trend over time. Returns a line chart (or area if type='area') with monthly revenue from Sales Orders. Add group_by='customer' for multi-line per customer. Use type='stacked-area' to stack customers. Uses the Sales Order document currency without conversion; mixed currencies require a currency filter.",
55343
55727
  category: "analytics",
55344
55728
  inputSchema: {
55345
55729
  type: "object",
55346
55730
  properties: {
55731
+ ...moneyScopeSchema(
55732
+ "Filter by Sales Order document currency (e.g. USD); no conversion"
55733
+ ).properties ?? {},
55347
55734
  months: {
55348
55735
  type: "number",
55349
55736
  description: "How many months back to include (default 6)"
@@ -55361,6 +55748,8 @@ var analyticsTools = [
55361
55748
  }
55362
55749
  },
55363
55750
  handler: async (input, ctx) => {
55751
+ const tool = "erpnext_revenue_trend";
55752
+ const scope = parseMoneyScope(tool, input);
55364
55753
  const monthsBack = input.months ?? 6;
55365
55754
  const chartType = input.type ?? "line";
55366
55755
  const groupBy = input.group_by ?? "total";
@@ -55372,28 +55761,61 @@ var analyticsTools = [
55372
55761
  );
55373
55762
  const startStr = localCalendarDate(startDate);
55374
55763
  const orders = await ctx.client.list("Sales Order", {
55375
- fields: ["customer_name", "grand_total", "transaction_date"],
55376
- filters: [["transaction_date", ">=", startStr], ["docstatus", "!=", 2]],
55377
- limit: 1e3,
55764
+ fields: [
55765
+ "name",
55766
+ "customer_name",
55767
+ "grand_total",
55768
+ "transaction_date",
55769
+ "company",
55770
+ "currency"
55771
+ ],
55772
+ filters: [
55773
+ ["transaction_date", ">=", startStr],
55774
+ ["transaction_date", "<=", localCalendarDate(now)],
55775
+ ["docstatus", "!=", 2],
55776
+ ...moneyScopeFilters(scope)
55777
+ ],
55778
+ limit: 0,
55378
55779
  order_by: "transaction_date asc"
55379
- });
55780
+ }, { skipCache: true });
55781
+ const { currency, amounts } = moneyValues(
55782
+ tool,
55783
+ orders,
55784
+ "grand_total",
55785
+ "currency",
55786
+ scope.currency
55787
+ );
55380
55788
  const months = monthlyLabels(now, monthsBack);
55381
55789
  if (groupBy === "customer") {
55382
55790
  const byCustomerMonth = {};
55383
- for (const order2 of orders) {
55791
+ for (const [index, order2] of orders.entries()) {
55384
55792
  const calendarMonth = frappeCalendarMonth(order2.transaction_date);
55385
- if (!calendarMonth) continue;
55793
+ if (!calendarMonth) {
55794
+ throw new AnalyticsDataError(
55795
+ tool,
55796
+ "INVALID_DATE",
55797
+ "A Sales Order has no valid transaction_date",
55798
+ "Inspect the source Sales Order before calculating the revenue trend.",
55799
+ { name: order2.name }
55800
+ );
55801
+ }
55386
55802
  const mIdx = (calendarMonth.year - startDate.getFullYear()) * 12 + calendarMonth.month - startDate.getMonth();
55387
55803
  if (mIdx < 0 || mIdx >= monthsBack) continue;
55388
55804
  const cust = order2.customer_name ?? "Unknown";
55389
55805
  if (!byCustomerMonth[cust]) {
55390
- byCustomerMonth[cust] = new Array(monthsBack).fill(0);
55806
+ byCustomerMonth[cust] = Array.from(
55807
+ { length: monthsBack },
55808
+ () => []
55809
+ );
55391
55810
  }
55392
- byCustomerMonth[cust][mIdx] += Number(order2.grand_total) || 0;
55811
+ byCustomerMonth[cust][mIdx].push(amounts[index]);
55393
55812
  }
55394
- const sorted = Object.entries(byCustomerMonth).sort(
55395
- ([, a], [, b]) => b.reduce((s, v) => s + v, 0) - a.reduce((s, v) => s + v, 0)
55396
- ).slice(0, 5);
55813
+ const sorted = Object.entries(byCustomerMonth).map(
55814
+ ([name, monthAmounts]) => [
55815
+ name,
55816
+ monthAmounts.map((values) => sumMoney(tool, values))
55817
+ ]
55818
+ ).sort(([, a], [, b]) => sumMoney(tool, b) - sumMoney(tool, a)).slice(0, 5);
55397
55819
  const COLORS = ["#60a5fa", "#4ade80", "#fbbf24", "#c084fc", "#f472b6"];
55398
55820
  return {
55399
55821
  title: "Revenue by Customer",
@@ -55407,20 +55829,34 @@ var analyticsTools = [
55407
55829
  showDots: chartType === "line",
55408
55830
  ...chartType === "stacked-area" ? { stack: "revenue" } : {}
55409
55831
  })),
55410
- currency: "EUR",
55832
+ ...currency ? { currency } : {},
55411
55833
  yAxisLabel: "Revenue",
55412
55834
  _meta: CHART_META
55413
55835
  };
55414
55836
  }
55415
- const monthlyTotals = new Array(monthsBack).fill(0);
55416
- for (const order2 of orders) {
55837
+ const monthlyAmounts = Array.from(
55838
+ { length: monthsBack },
55839
+ () => []
55840
+ );
55841
+ for (const [index, order2] of orders.entries()) {
55417
55842
  const calendarMonth = frappeCalendarMonth(order2.transaction_date);
55418
- if (!calendarMonth) continue;
55843
+ if (!calendarMonth) {
55844
+ throw new AnalyticsDataError(
55845
+ tool,
55846
+ "INVALID_DATE",
55847
+ "A Sales Order has no valid transaction_date",
55848
+ "Inspect the source Sales Order before calculating the revenue trend.",
55849
+ { name: order2.name }
55850
+ );
55851
+ }
55419
55852
  const mIdx = (calendarMonth.year - startDate.getFullYear()) * 12 + calendarMonth.month - startDate.getMonth();
55420
55853
  if (mIdx >= 0 && mIdx < monthsBack) {
55421
- monthlyTotals[mIdx] += Number(order2.grand_total) || 0;
55854
+ monthlyAmounts[mIdx].push(amounts[index]);
55422
55855
  }
55423
55856
  }
55857
+ const monthlyTotals = monthlyAmounts.map(
55858
+ (values) => sumMoney(tool, values)
55859
+ );
55424
55860
  return {
55425
55861
  title: "Revenue Trend",
55426
55862
  subtitle: `Last ${monthsBack} months`,
@@ -55432,7 +55868,7 @@ var analyticsTools = [
55432
55868
  color: "#60a5fa",
55433
55869
  showDots: true
55434
55870
  }],
55435
- currency: "EUR",
55871
+ ...currency ? { currency } : {},
55436
55872
  yAxisLabel: "Revenue",
55437
55873
  _meta: CHART_META
55438
55874
  };
@@ -55842,39 +56278,62 @@ var analyticsTools = [
55842
56278
  name: "erpnext_kpi_revenue",
55843
56279
  annotations: { readOnlyHint: true },
55844
56280
  _meta: KPI_META,
55845
- description: "KPI card: total Sales Order revenue for the current month, with delta % vs previous month and sparkline of last 6 months.",
56281
+ description: "KPI card: total Sales Order revenue for the current month, with delta % vs previous month and sparkline of last 6 months. Uses the document currency, with complete fresh reads. Mixed currencies require a currency filter.",
55846
56282
  category: "analytics",
55847
- inputSchema: { type: "object", properties: {} },
55848
- handler: async (_input, ctx) => {
56283
+ inputSchema: moneyScopeSchema(
56284
+ "Filter by Sales Order document currency (e.g. USD); no conversion"
56285
+ ),
56286
+ handler: async (input, ctx) => {
56287
+ const tool = "erpnext_kpi_revenue";
56288
+ const scope = parseMoneyScope(tool, input);
55849
56289
  const now = /* @__PURE__ */ new Date();
55850
56290
  const sixMonthsAgo = new Date(now.getFullYear(), now.getMonth() - 5, 1);
55851
56291
  const sinceStr = localCalendarDate(sixMonthsAgo);
55852
56292
  const allOrders = await ctx.client.list("Sales Order", {
55853
- fields: ["grand_total", "transaction_date"],
56293
+ fields: ["name", "grand_total", "transaction_date", "currency"],
55854
56294
  filters: [
55855
56295
  ["transaction_date", ">=", sinceStr],
55856
- ["docstatus", "!=", 2]
56296
+ ["transaction_date", "<=", localCalendarDate(now)],
56297
+ ["docstatus", "!=", 2],
56298
+ ...moneyScopeFilters(scope)
55857
56299
  ],
55858
- limit: 5e3
55859
- });
55860
- const sparkline = [0, 0, 0, 0, 0, 0];
55861
- const sparklineLabels = monthlyLabels(now, sparkline.length);
55862
- for (const o of allOrders) {
56300
+ // Frappe limit 0 fetches every match, without a silent total cap.
56301
+ limit: 0
56302
+ }, { skipCache: true });
56303
+ const { currency, amounts } = moneyValues(
56304
+ tool,
56305
+ allOrders,
56306
+ "grand_total",
56307
+ "currency",
56308
+ scope.currency
56309
+ );
56310
+ const monthlyAmounts = [[], [], [], [], [], []];
56311
+ const sparklineLabels = monthlyLabels(now, monthlyAmounts.length);
56312
+ for (const [index, o] of allOrders.entries()) {
55863
56313
  const calendarMonth = frappeCalendarMonth(o.transaction_date);
55864
- if (!calendarMonth) continue;
56314
+ if (!calendarMonth) {
56315
+ throw new AnalyticsDataError(
56316
+ tool,
56317
+ "INVALID_DATE",
56318
+ "A Sales Order has no valid transaction_date",
56319
+ "Inspect the source Sales Order before calculating the monthly revenue.",
56320
+ { name: o.name }
56321
+ );
56322
+ }
55865
56323
  const monthDiff = (now.getFullYear() - calendarMonth.year) * 12 + (now.getMonth() - calendarMonth.month);
55866
56324
  const idx = 5 - monthDiff;
55867
56325
  if (idx >= 0 && idx < 6) {
55868
- sparkline[idx] += Number(o.grand_total) || 0;
56326
+ monthlyAmounts[idx].push(amounts[index]);
55869
56327
  }
55870
56328
  }
56329
+ const sparkline = monthlyAmounts.map((month) => sumMoney(tool, month));
55871
56330
  const currentTotal = sparkline[5];
55872
56331
  const prevTotal = sparkline[4];
55873
56332
  const delta = prevTotal > 0 ? (currentTotal - prevTotal) / prevTotal * 100 : 0;
55874
56333
  return {
55875
56334
  label: "Revenue MTD",
55876
56335
  value: currentTotal,
55877
- currency: "EUR",
56336
+ ...currency ? { currency } : {},
55878
56337
  delta: Math.round(delta * 10) / 10,
55879
56338
  deltaLabel: "vs last month",
55880
56339
  trend: delta > 0 ? "up" : delta < 0 ? "down" : "flat",
@@ -55891,28 +56350,38 @@ var analyticsTools = [
55891
56350
  name: "erpnext_kpi_outstanding",
55892
56351
  annotations: { readOnlyHint: true },
55893
56352
  _meta: KPI_META,
55894
- description: "KPI card: total outstanding receivables from submitted Sales Invoices with outstanding_amount > 0. Shows count of open invoices.",
56353
+ description: "KPI card: total outstanding receivables from submitted Sales Invoices with outstanding_amount > 0. Shows count of open invoices. Amounts use the receivable party account currency; mixed currencies require a currency filter. Complete fresh read.",
55895
56354
  category: "analytics",
55896
- inputSchema: { type: "object", properties: {} },
55897
- handler: async (_input, ctx) => {
56355
+ inputSchema: moneyScopeSchema(
56356
+ "Filter by receivable party account currency, not invoice currency (e.g. USD)"
56357
+ ),
56358
+ handler: async (input, ctx) => {
56359
+ const tool = "erpnext_kpi_outstanding";
56360
+ const scope = parseMoneyScope(tool, input);
55898
56361
  const invoices = await ctx.client.list("Sales Invoice", {
55899
- fields: ["outstanding_amount"],
56362
+ fields: ["name", "outstanding_amount", "party_account_currency"],
55900
56363
  filters: [
55901
56364
  ["outstanding_amount", ">", 0],
55902
- ["docstatus", "=", 1]
56365
+ ["docstatus", "=", 1],
56366
+ ...moneyScopeFilters(scope, "party_account_currency")
55903
56367
  ],
55904
- limit: 1e3
55905
- });
55906
- const total = invoices.reduce(
55907
- (sum, inv) => sum + (Number(inv.outstanding_amount) || 0),
55908
- 0
56368
+ limit: 0
56369
+ }, { skipCache: true });
56370
+ const { currency, amounts } = moneyValues(
56371
+ tool,
56372
+ invoices,
56373
+ "outstanding_amount",
56374
+ "party_account_currency",
56375
+ scope.currency
55909
56376
  );
56377
+ const total = sumMoney(tool, amounts);
55910
56378
  const count = invoices.length;
55911
56379
  return {
55912
56380
  label: "Outstanding Receivables",
55913
56381
  value: total,
55914
- formattedValue: `${count} inv. / ${total.toLocaleString("en-US", { style: "currency", currency: "EUR" })}`,
55915
- currency: "EUR",
56382
+ formattedValue: `${count} inv. / ${formatMoney(total, currency)}`,
56383
+ ...currency ? { currency } : {},
56384
+ secondary: { kind: "invoice_count", value: count },
55916
56385
  trend: total > 0 ? "up" : "flat",
55917
56386
  trendIsGood: false,
55918
56387
  color: "#fbbf24",
@@ -55925,10 +56394,13 @@ var analyticsTools = [
55925
56394
  name: "erpnext_kpi_orders",
55926
56395
  annotations: { readOnlyHint: true },
55927
56396
  _meta: KPI_META,
55928
- description: "KPI card: count and total value of Sales Orders created this month, with delta % vs last month.",
56397
+ description: "KPI card: count of non-cancelled Sales Orders dated this month through today, with delta % vs last month. Complete fresh reads, optionally scoped by company and document currency.",
55929
56398
  category: "analytics",
55930
- inputSchema: { type: "object", properties: {} },
55931
- handler: async (_input, ctx) => {
56399
+ inputSchema: moneyScopeSchema(
56400
+ "Filter by Sales Order document currency (e.g. USD)"
56401
+ ),
56402
+ handler: async (input, ctx) => {
56403
+ const scope = parseMoneyScope("erpnext_kpi_orders", input);
55932
56404
  const now = /* @__PURE__ */ new Date();
55933
56405
  const thisMonthStart = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, "0")}-01`;
55934
56406
  const lastMonthStart = new Date(now.getFullYear(), now.getMonth() - 1, 1);
@@ -55936,23 +56408,26 @@ var analyticsTools = [
55936
56408
  const lastMonthStartStr = localCalendarDate(lastMonthStart);
55937
56409
  const lastMonthEndStr = localCalendarDate(lastMonthEnd);
55938
56410
  const currentOrders = await ctx.client.list("Sales Order", {
55939
- fields: ["grand_total"],
56411
+ fields: ["name"],
55940
56412
  filters: [
55941
56413
  ["transaction_date", ">=", thisMonthStart],
55942
- ["docstatus", "!=", 2]
56414
+ ["transaction_date", "<=", localCalendarDate(now)],
56415
+ ["docstatus", "!=", 2],
56416
+ ...moneyScopeFilters(scope)
55943
56417
  ],
55944
- limit: 1e3
55945
- });
56418
+ limit: 0
56419
+ }, { skipCache: true });
55946
56420
  const currentCount = currentOrders.length;
55947
56421
  const prevOrders = await ctx.client.list("Sales Order", {
55948
- fields: ["grand_total"],
56422
+ fields: ["name"],
55949
56423
  filters: [
55950
56424
  ["transaction_date", ">=", lastMonthStartStr],
55951
56425
  ["transaction_date", "<=", lastMonthEndStr],
55952
- ["docstatus", "!=", 2]
56426
+ ["docstatus", "!=", 2],
56427
+ ...moneyScopeFilters(scope)
55953
56428
  ],
55954
- limit: 1e3
55955
- });
56429
+ limit: 0
56430
+ }, { skipCache: true });
55956
56431
  const prevCount = prevOrders.length;
55957
56432
  const delta = prevCount > 0 ? (currentCount - prevCount) / prevCount * 100 : 0;
55958
56433
  return {
@@ -56029,31 +56504,50 @@ var analyticsTools = [
56029
56504
  name: "erpnext_kpi_overdue",
56030
56505
  annotations: { readOnlyHint: true },
56031
56506
  _meta: KPI_META,
56032
- description: "KPI card: count and total value of overdue Sales Invoices (due_date < today, outstanding_amount > 0, submitted).",
56507
+ description: "KPI card: count and total value of overdue Sales Invoices (due_date < today, outstanding_amount > 0, submitted). Amounts use the receivable party account currency; mixed currencies require a currency filter. Complete fresh read.",
56033
56508
  category: "analytics",
56034
- inputSchema: { type: "object", properties: {} },
56035
- handler: async (_input, ctx) => {
56509
+ inputSchema: moneyScopeSchema(
56510
+ "Filter by receivable party account currency, not invoice currency (e.g. USD)"
56511
+ ),
56512
+ handler: async (input, ctx) => {
56513
+ const tool = "erpnext_kpi_overdue";
56514
+ const scope = parseMoneyScope(tool, input);
56036
56515
  const now = /* @__PURE__ */ new Date();
56037
56516
  const pad = (n) => String(n).padStart(2, "0");
56038
56517
  const today = `${now.getFullYear()}-${pad(now.getMonth() + 1)}-${pad(now.getDate())}`;
56039
56518
  const invoices = await ctx.client.list("Sales Invoice", {
56040
- fields: ["outstanding_amount", "due_date"],
56519
+ fields: [
56520
+ "name",
56521
+ "outstanding_amount",
56522
+ "due_date",
56523
+ "party_account_currency"
56524
+ ],
56041
56525
  filters: [
56042
56526
  ["due_date", "<", today],
56043
56527
  ["outstanding_amount", ">", 0],
56044
- ["docstatus", "=", 1]
56528
+ ["docstatus", "=", 1],
56529
+ ...moneyScopeFilters(scope, "party_account_currency")
56045
56530
  ],
56046
- limit: 1e3
56047
- });
56531
+ limit: 0
56532
+ }, { skipCache: true });
56048
56533
  const count = invoices.length;
56049
- const total = invoices.reduce(
56050
- (sum, inv) => sum + (Number(inv.outstanding_amount) || 0),
56051
- 0
56534
+ const { currency, amounts } = moneyValues(
56535
+ tool,
56536
+ invoices,
56537
+ "outstanding_amount",
56538
+ "party_account_currency",
56539
+ scope.currency
56052
56540
  );
56541
+ const total = sumMoney(tool, amounts);
56053
56542
  return {
56054
56543
  label: "Overdue Invoices",
56055
56544
  value: count,
56056
- formattedValue: `${count} inv. / ${total.toLocaleString("en-US", { style: "currency", currency: "EUR" })}`,
56545
+ formattedValue: `${count} inv. / ${formatMoney(total, currency)}`,
56546
+ secondary: {
56547
+ kind: "amount",
56548
+ value: total,
56549
+ ...currency ? { currency } : {}
56550
+ },
56057
56551
  trend: count > 0 ? "up" : "flat",
56058
56552
  trendIsGood: false,
56059
56553
  color: "#f87171",
@@ -57524,7 +58018,7 @@ var BUY_EVIDENCE_VIEWER_URI = "ui://mcp-erpnext/buy-evidence-viewer";
57524
58018
  var BUY_VIEW_APP_MANIFEST_URI = "ui://mcp-erpnext/buy-evidence-manifest";
57525
58019
  var BUY_VIEW_APP_ID = "io.casys.mcp-erpnext.buy-evidence";
57526
58020
  var BUY_VIEW_APP_TITLE = "ERPNext Buy Evidence";
57527
- var BUY_VIEW_APP_VERSION = "3.2.0-beta.1";
58021
+ var BUY_VIEW_APP_VERSION = "3.1.0";
57528
58022
  var BUY_SOURCE_INSTANCE_KIND = "erpnext-site";
57529
58023
  var BUY_CAPTURE_DOCTYPE = [
57530
58024
  "Item",
@@ -58894,13 +59388,152 @@ var allTools = [
58894
59388
  ...kanbanTools,
58895
59389
  ...buyTools
58896
59390
  ];
58897
- function getToolsByCategory(category) {
58898
- return toolsByCategory[category] ?? [];
58899
- }
58900
59391
  function getToolByName(name) {
58901
59392
  return allTools.find((t) => t.name === name);
58902
59393
  }
58903
59394
 
59395
+ // src/tools/selection.ts
59396
+ var TOOL_FILTER_ERROR_CODE = "INVALID_TOOL_FILTER";
59397
+ var SUBJECT = "mcp-erpnext";
59398
+ function formatToolFilterMessage(detail, recovery, subject = SUBJECT) {
59399
+ return formatToolError(subject, TOOL_FILTER_ERROR_CODE, detail, recovery);
59400
+ }
59401
+ var ToolFilterError = class extends Error {
59402
+ code = TOOL_FILTER_ERROR_CODE;
59403
+ reason;
59404
+ retryable = false;
59405
+ context;
59406
+ recovery;
59407
+ detail;
59408
+ constructor(reason, detail, options) {
59409
+ super(formatToolFilterMessage(detail, options.recovery));
59410
+ this.name = "ToolFilterError";
59411
+ this.reason = reason;
59412
+ this.detail = detail;
59413
+ this.recovery = options.recovery;
59414
+ this.context = Object.freeze({ ...options.context ?? {}, reason });
59415
+ }
59416
+ };
59417
+ function isToolFilterError(error2) {
59418
+ return error2 instanceof ToolFilterError;
59419
+ }
59420
+ var DEFAULT_REGISTRY = { toolsByCategory, allTools };
59421
+ function unique(values) {
59422
+ return [...new Set(values)];
59423
+ }
59424
+ function unknownCategoryDetail(unknown2, valid, ignored = false) {
59425
+ return `Unknown categor${unknown2.length > 1 ? "ies" : "y"} ${unknown2.map((c) => `"${c}"`).join(", ")}${ignored ? " (ignored)" : ""}; valid categories: ${valid.join(", ")}`;
59426
+ }
59427
+ var CATEGORY_RECOVERY = "use only valid category names (case-sensitive, comma-separated)";
59428
+ function resolveToolSelection(options = {}, registry2 = DEFAULT_REGISTRY) {
59429
+ const { categories, excludeCategories, excludeTools } = options;
59430
+ if (categories === void 0 && excludeCategories === void 0 && excludeTools === void 0) {
59431
+ return {
59432
+ tools: registry2.allTools,
59433
+ excludedCategories: [],
59434
+ excludedTools: [],
59435
+ warnings: []
59436
+ };
59437
+ }
59438
+ const validCategories = Object.keys(registry2.toolsByCategory);
59439
+ const validSet = new Set(validCategories);
59440
+ const warnings = [];
59441
+ const excludedCategories = unique(excludeCategories ?? []);
59442
+ const unknownExcluded = excludedCategories.filter((c) => !validSet.has(c));
59443
+ if (unknownExcluded.length > 0) {
59444
+ throw new ToolFilterError(
59445
+ "UNKNOWN_CATEGORY",
59446
+ unknownCategoryDetail(unknownExcluded, validCategories),
59447
+ {
59448
+ context: {
59449
+ option: "excludeCategories",
59450
+ received: unknownExcluded,
59451
+ valid: validCategories
59452
+ },
59453
+ recovery: CATEGORY_RECOVERY
59454
+ }
59455
+ );
59456
+ }
59457
+ const excludedTools = unique(excludeTools ?? []);
59458
+ const validToolNames = registry2.allTools.map((t) => t.name);
59459
+ const toolNameSet = new Set(validToolNames);
59460
+ const unknownTools = excludedTools.filter((t) => !toolNameSet.has(t));
59461
+ if (unknownTools.length > 0) {
59462
+ throw new ToolFilterError(
59463
+ "UNKNOWN_TOOL",
59464
+ `Unknown tool${unknownTools.length > 1 ? "s" : ""} ${unknownTools.slice(0, 5).map((t) => `"${t}"`).join(", ")}${unknownTools.length > 5 ? `, and ${unknownTools.length - 5} more` : ""}`,
59465
+ {
59466
+ context: {
59467
+ option: "excludeTools",
59468
+ received: unknownTools,
59469
+ valid: validToolNames
59470
+ },
59471
+ recovery: "use exact tool names such as erpnext_file_list (comma-separated); see docs/tools.md or call tools/list for the complete list"
59472
+ }
59473
+ );
59474
+ }
59475
+ let base;
59476
+ let allowList;
59477
+ if (categories === void 0) {
59478
+ base = registry2.allTools;
59479
+ } else {
59480
+ allowList = unique(categories);
59481
+ const unknown2 = allowList.filter((c) => !validSet.has(c));
59482
+ if (unknown2.length > 0) {
59483
+ warnings.push({
59484
+ code: TOOL_FILTER_ERROR_CODE,
59485
+ reason: "UNKNOWN_CATEGORY",
59486
+ message: formatToolFilterMessage(
59487
+ unknownCategoryDetail(unknown2, validCategories, true),
59488
+ CATEGORY_RECOVERY
59489
+ ),
59490
+ context: Object.freeze({
59491
+ option: "categories",
59492
+ received: unknown2,
59493
+ valid: validCategories,
59494
+ reason: "UNKNOWN_CATEGORY"
59495
+ }),
59496
+ recovery: CATEGORY_RECOVERY
59497
+ });
59498
+ }
59499
+ base = allowList.filter((c) => validSet.has(c)).flatMap((c) => registry2.toolsByCategory[c]);
59500
+ }
59501
+ const excludedCategorySet = new Set(excludedCategories);
59502
+ const excludedToolSet = new Set(excludedTools);
59503
+ const seen = /* @__PURE__ */ new Set();
59504
+ const tools = [];
59505
+ for (const tool of base) {
59506
+ if (seen.has(tool.name)) continue;
59507
+ seen.add(tool.name);
59508
+ if (excludedCategorySet.has(tool.category)) continue;
59509
+ if (excludedToolSet.has(tool.name)) continue;
59510
+ tools.push(tool);
59511
+ }
59512
+ return {
59513
+ tools,
59514
+ ...allowList ? { categories: allowList } : {},
59515
+ excludedCategories,
59516
+ excludedTools,
59517
+ warnings
59518
+ };
59519
+ }
59520
+ function assertNonEmptySelection(selection, registry2 = DEFAULT_REGISTRY) {
59521
+ if (selection.tools.length > 0) return;
59522
+ throw new ToolFilterError(
59523
+ "EMPTY_SELECTION",
59524
+ "The tool selection is empty: no tool would be registered",
59525
+ {
59526
+ context: {
59527
+ categories: selection.categories ?? null,
59528
+ excludedCategories: selection.excludedCategories,
59529
+ excludedTools: selection.excludedTools,
59530
+ valid: Object.keys(registry2.toolsByCategory)
59531
+ },
59532
+ recovery: "check --categories / --exclude-categories / --exclude-tools (and their ERPNEXT_* variables) so that at least one tool remains"
59533
+ }
59534
+ );
59535
+ }
59536
+
58904
59537
  // src/mrtr/link-disambiguation.ts
58905
59538
  function linkDisambiguationRequestKey(inputPath) {
58906
59539
  return `link-disambiguation:${inputPath}`;
@@ -59404,8 +60037,32 @@ var INVOICE_LIST_FIELDS = [
59404
60037
  "posting_date",
59405
60038
  "due_date",
59406
60039
  "status",
59407
- "outstanding_amount"
60040
+ "outstanding_amount",
60041
+ "party_account_currency"
59408
60042
  ];
60043
+ var FINANCIAL_KPI_CURRENCY_FIELDS = {
60044
+ erpnext_kpi_revenue: "currency",
60045
+ erpnext_kpi_outstanding: "party_account_currency",
60046
+ erpnext_kpi_overdue: "party_account_currency"
60047
+ };
60048
+ function scopedKpiJumps(toolName, jumps, args) {
60049
+ const currencyField = FINANCIAL_KPI_CURRENCY_FIELDS[toolName];
60050
+ if (!currencyField && toolName !== "erpnext_kpi_orders") return jumps;
60051
+ if (!jumps.number) return jumps;
60052
+ const scopeFilters = moneyScopeFilters(
60053
+ parseMoneyScope(toolName, args),
60054
+ currencyField ?? "currency"
60055
+ );
60056
+ const filters = [
60057
+ ...Array.isArray(jumps.number.args.filters) ? jumps.number.args.filters : [],
60058
+ ...scopeFilters
60059
+ ];
60060
+ return {
60061
+ ...jumps,
60062
+ number: { ...jumps.number, args: { ...jumps.number.args, filters } },
60063
+ ...scopeFilters.length > 0 ? { trend: void 0 } : {}
60064
+ };
60065
+ }
59409
60066
  var KPI_JUMPS = {
59410
60067
  "erpnext_kpi_revenue": (r) => ({
59411
60068
  number: {
@@ -59419,24 +60076,18 @@ var KPI_JUMPS = {
59419
60076
  "customer",
59420
60077
  "transaction_date",
59421
60078
  "status",
59422
- "grand_total"
60079
+ "grand_total",
60080
+ "currency"
59423
60081
  ],
59424
60082
  filters: [
59425
60083
  ["transaction_date", ">=", r.from],
59426
- ["transaction_date", "<=", r.to],
60084
+ ["transaction_date", "<=", r.today],
59427
60085
  // comme le KPI : les commandes annulées ne comptent pas
59428
60086
  ["docstatus", "<", 2]
59429
60087
  ],
59430
60088
  limit: 20
59431
60089
  },
59432
60090
  kind: "list"
59433
- },
59434
- trend: {
59435
- key: "revenue_trend",
59436
- label: "Revenue trend",
59437
- tool: "erpnext_revenue_trend",
59438
- args: { months: 12 },
59439
- kind: "chart"
59440
60091
  }
59441
60092
  }),
59442
60093
  "erpnext_kpi_outstanding": () => ({
@@ -59451,13 +60102,6 @@ var KPI_JUMPS = {
59451
60102
  limit: 20
59452
60103
  },
59453
60104
  kind: "list"
59454
- },
59455
- trend: {
59456
- key: "receivables_aging",
59457
- label: "Receivables aging",
59458
- tool: "erpnext_ar_aging",
59459
- args: {},
59460
- kind: "chart"
59461
60105
  }
59462
60106
  }),
59463
60107
  "erpnext_kpi_orders": (r) => ({
@@ -59472,11 +60116,12 @@ var KPI_JUMPS = {
59472
60116
  "customer",
59473
60117
  "transaction_date",
59474
60118
  "status",
59475
- "grand_total"
60119
+ "grand_total",
60120
+ "currency"
59476
60121
  ],
59477
60122
  filters: [
59478
60123
  ["transaction_date", ">=", r.from],
59479
- ["transaction_date", "<=", r.to],
60124
+ ["transaction_date", "<=", r.today],
59480
60125
  // comme le KPI : les commandes annulées ne comptent pas
59481
60126
  ["docstatus", "<", 2]
59482
60127
  ],
@@ -59517,13 +60162,6 @@ var KPI_JUMPS = {
59517
60162
  limit: 20
59518
60163
  },
59519
60164
  kind: "list"
59520
- },
59521
- trend: {
59522
- key: "receivables_aging",
59523
- label: "Receivables aging",
59524
- tool: "erpnext_ar_aging",
59525
- args: {},
59526
- kind: "chart"
59527
60165
  }
59528
60166
  })
59529
60167
  };
@@ -59652,7 +60290,8 @@ var SALES_ORDER_LIST_FIELDS = [
59652
60290
  "customer",
59653
60291
  "transaction_date",
59654
60292
  "status",
59655
- "grand_total"
60293
+ "grand_total",
60294
+ "currency"
59656
60295
  ];
59657
60296
  var PURCHASE_ORDER_LIST_FIELDS = [
59658
60297
  "name",
@@ -59693,12 +60332,20 @@ function chartPointJumps(toolName, args, labels, now) {
59693
60332
  switch (toolName) {
59694
60333
  case "erpnext_revenue_trend": {
59695
60334
  const months = Number(args.months ?? 6);
60335
+ const scopeFilters = moneyScopeFilters(
60336
+ parseMoneyScope(toolName, args)
60337
+ );
59696
60338
  for (const { label, range } of monthBuckets(months, now)) {
59697
60339
  if (!labels.includes(label)) continue;
59698
60340
  jumps[label] = docList(label, "Sales Order", SALES_ORDER_LIST_FIELDS, [
59699
60341
  ["transaction_date", ">=", range.from],
59700
- ["transaction_date", "<=", range.to],
59701
- ["docstatus", "<", 2]
60342
+ [
60343
+ "transaction_date",
60344
+ "<=",
60345
+ label === monthLabel(now) ? monthRange(now).today : range.to
60346
+ ],
60347
+ ["docstatus", "<", 2],
60348
+ ...scopeFilters
59702
60349
  ]);
59703
60350
  }
59704
60351
  break;
@@ -59855,27 +60502,60 @@ var VIEWER_TOOL_CANDIDATES = {
59855
60502
  "erpnext_stock_entry_list"
59856
60503
  ]
59857
60504
  };
59858
- var SUBMITTABLE_VIEWER_DOCTYPES = /* @__PURE__ */ new Set([
60505
+ var SUBMITTABLE_VIEWER_DOCTYPES = [
60506
+ // ERPNext — ventes
59859
60507
  "Quotation",
59860
60508
  "Sales Order",
59861
60509
  "Delivery Note",
59862
60510
  "Sales Invoice",
60511
+ "POS Invoice",
60512
+ "Installation Note",
60513
+ // ERPNext — achats
60514
+ "Request for Quotation",
60515
+ "Supplier Quotation",
59863
60516
  "Purchase Order",
59864
60517
  "Purchase Receipt",
59865
60518
  "Purchase Invoice",
59866
- "Stock Entry",
60519
+ // ERPNext — comptabilité
59867
60520
  "Journal Entry",
59868
60521
  "Payment Entry",
59869
- "Timesheet",
59870
- "Leave Application",
59871
- "Salary Slip",
60522
+ "Payment Request",
60523
+ "Period Closing Voucher",
60524
+ // ERPNext — stock
60525
+ "Material Request",
60526
+ "Stock Entry",
60527
+ "Stock Reconciliation",
60528
+ "Pick List",
60529
+ "Delivery Trip",
60530
+ "Landed Cost Voucher",
60531
+ "Quality Inspection",
60532
+ "Shipment",
60533
+ // ERPNext — fabrication
59872
60534
  "BOM",
59873
60535
  "Work Order",
59874
60536
  "Job Card",
60537
+ "Blanket Order",
60538
+ // ERPNext — projets et actifs
60539
+ "Timesheet",
59875
60540
  "Asset",
59876
60541
  "Asset Movement",
59877
- "Shipment"
59878
- ]);
60542
+ // HRMS
60543
+ "Attendance",
60544
+ "Leave Application",
60545
+ "Leave Allocation",
60546
+ "Leave Encashment",
60547
+ "Expense Claim",
60548
+ "Employee Advance",
60549
+ "Payroll Entry",
60550
+ "Salary Slip",
60551
+ "Additional Salary"
60552
+ ];
60553
+ var SUBMITTABLE_CANONICAL = new Map(
60554
+ SUBMITTABLE_VIEWER_DOCTYPES.map((doctype) => [doctype.toLowerCase(), doctype])
60555
+ );
60556
+ function submittableViewerDoctype(doctype) {
60557
+ return SUBMITTABLE_CANONICAL.get(doctype.trim().toLowerCase());
60558
+ }
59879
60559
  var INVOICE_DEDICATED_MUTATION_TOOLS = {
59880
60560
  "Sales Order": [
59881
60561
  "erpnext_sales_order_submit",
@@ -59884,13 +60564,14 @@ var INVOICE_DEDICATED_MUTATION_TOOLS = {
59884
60564
  "Sales Invoice": ["erpnext_sales_invoice_submit"]
59885
60565
  };
59886
60566
  function addDoctypeMutationCandidates(target, uri, doctype) {
59887
- if (!doctype || !SUBMITTABLE_VIEWER_DOCTYPES.has(doctype)) return;
59888
- if (uri === "ui://mcp-erpnext/invoice-viewer" || uri === "ui://mcp-erpnext/doclist-viewer" || uri === "ui://mcp-erpnext/doc-viewer") {
60567
+ const submittable = doctype ? submittableViewerDoctype(doctype) : void 0;
60568
+ if (!submittable) return;
60569
+ if (uri === "ui://mcp-erpnext/invoice-viewer" || uri === "ui://mcp-erpnext/doc-viewer") {
59889
60570
  target.add("erpnext_doc_submit");
59890
60571
  target.add("erpnext_doc_cancel");
59891
60572
  }
59892
60573
  if (uri === "ui://mcp-erpnext/invoice-viewer") {
59893
- for (const tool of INVOICE_DEDICATED_MUTATION_TOOLS[doctype] ?? []) {
60574
+ for (const tool of INVOICE_DEDICATED_MUTATION_TOOLS[submittable] ?? []) {
59894
60575
  target.add(tool);
59895
60576
  }
59896
60577
  }
@@ -60056,14 +60737,16 @@ function withUiRefreshRequest(result, toolName, args, now = /* @__PURE__ */ new
60056
60737
  if (kpiHints._drillDown && !enriched._drillDown) {
60057
60738
  enriched._drillDown = kpiHints._drillDown;
60058
60739
  }
60059
- if (kpiHints._trendDrillDown && !enriched._trendDrillDown) {
60740
+ if (kpiHints._trendDrillDown && !enriched._trendDrillDown && !FINANCIAL_KPI_CURRENCY_FIELDS[toolName] && !(args.company || args.currency)) {
60060
60741
  enriched._trendDrillDown = kpiHints._trendDrillDown;
60061
60742
  }
60062
60743
  }
60063
60744
  }
60064
60745
  if (isKpiViewer(enriched) && !enriched._jumps) {
60065
60746
  const jumps = KPI_JUMPS[toolName];
60066
- if (jumps) enriched._jumps = jumps(monthRange(now));
60747
+ if (jumps) {
60748
+ enriched._jumps = scopedKpiJumps(toolName, jumps(monthRange(now)), args);
60749
+ }
60067
60750
  }
60068
60751
  if (isFunnelViewer(enriched) && !enriched._stageJumps) {
60069
60752
  enriched._stageJumps = funnelStageJumps(args, now);
@@ -60094,37 +60777,237 @@ function withUiRefreshRequest(result, toolName, args, now = /* @__PURE__ */ new
60094
60777
  return filterNavJumpsByAvailableTools(enriched, availableToolNames);
60095
60778
  }
60096
60779
 
60097
- // src/client.ts
60780
+ // src/tools/doctype-layout.ts
60781
+ var DOCTYPE_LAYOUT_TTL_MS = 60 * 60 * 1e3;
60782
+ var DOCTYPE_LAYOUT_FAILURE_TTL_MS = 60 * 1e3;
60783
+ var DOCTYPE_LAYOUT_MAX_ENTRIES = 200;
60784
+ var DOCTYPE_LAYOUT_SKIPPED = /* @__PURE__ */ new Set([
60785
+ "Company",
60786
+ "DocType"
60787
+ ]);
60788
+ var GETDOCTYPE_METHOD = "frappe.desk.form.load.getdoctype";
60789
+ var VISUAL_TYPES = /* @__PURE__ */ new Set([
60790
+ "Section Break",
60791
+ "Tab Break",
60792
+ "Column Break",
60793
+ "HTML",
60794
+ "Button",
60795
+ "Heading",
60796
+ "Fold",
60797
+ "Image"
60798
+ ]);
60799
+ var TABLE_TYPES = /* @__PURE__ */ new Set(["Table", "Table MultiSelect"]);
60098
60800
  function isRecord9(value) {
60801
+ return value !== null && typeof value === "object" && !Array.isArray(value);
60802
+ }
60803
+ function nonEmptyString(value) {
60804
+ return typeof value === "string" && value.trim().length > 0 ? value : null;
60805
+ }
60806
+ function isFlag(value) {
60807
+ return value === 1 || value === true || value === "1";
60808
+ }
60809
+ function metaFieldOf(value) {
60810
+ if (!isRecord9(value)) return null;
60811
+ const fieldname = nonEmptyString(value.fieldname);
60812
+ const fieldtype = nonEmptyString(value.fieldtype);
60813
+ if (!fieldname || !fieldtype) return null;
60814
+ return {
60815
+ fieldname,
60816
+ fieldtype,
60817
+ label: nonEmptyString(value.label),
60818
+ preview: isFlag(value.in_preview),
60819
+ bold: isFlag(value.bold),
60820
+ inListView: isFlag(value.in_list_view)
60821
+ };
60822
+ }
60823
+ function isDataField(field) {
60824
+ return !VISUAL_TYPES.has(field.fieldtype) && !TABLE_TYPES.has(field.fieldtype);
60825
+ }
60826
+ function previewOf(dataFields, titleField) {
60827
+ const flagged = dataFields.filter((field) => field.preview || field.bold);
60828
+ const chosen = flagged.length > 0 ? flagged : dataFields.filter((field) => field.inListView);
60829
+ return [
60830
+ .../* @__PURE__ */ new Set([
60831
+ ...titleField ? [titleField] : [],
60832
+ ...chosen.map((field) => field.fieldname)
60833
+ ])
60834
+ ];
60835
+ }
60836
+ function sectionsOf(fields) {
60837
+ const sections = [];
60838
+ let current = { label: null, fields: [] };
60839
+ let tabOpen = false;
60840
+ const flush = () => {
60841
+ if (current.fields.length > 0) sections.push(current);
60842
+ };
60843
+ for (const field of fields) {
60844
+ if (field.fieldtype === "Tab Break") {
60845
+ flush();
60846
+ current = { label: field.label, fields: [] };
60847
+ tabOpen = true;
60848
+ } else if (field.fieldtype === "Section Break") {
60849
+ if (!(tabOpen && field.label === null)) {
60850
+ flush();
60851
+ current = { label: field.label, fields: [] };
60852
+ }
60853
+ tabOpen = false;
60854
+ } else if (isDataField(field)) {
60855
+ current.fields.push(field.fieldname);
60856
+ tabOpen = false;
60857
+ }
60858
+ }
60859
+ flush();
60860
+ return sections;
60861
+ }
60862
+ function docTypeLayoutOf(response) {
60863
+ if (!isRecord9(response) || !Array.isArray(response.docs)) return null;
60864
+ const doctype = response.docs[0];
60865
+ if (!isRecord9(doctype) || !Array.isArray(doctype.fields)) return null;
60866
+ const fields = doctype.fields.flatMap((raw2) => {
60867
+ const field = metaFieldOf(raw2);
60868
+ return field ? [field] : [];
60869
+ });
60870
+ const dataFields = fields.filter(isDataField);
60871
+ const labels = {};
60872
+ for (const field of dataFields) {
60873
+ if (field.label) labels[field.fieldname] = field.label;
60874
+ }
60875
+ return {
60876
+ version: 1,
60877
+ preview: previewOf(dataFields, nonEmptyString(doctype.title_field)),
60878
+ sections: sectionsOf(fields),
60879
+ labels
60880
+ };
60881
+ }
60882
+ function createDocTypeLayoutMemo(options = {}) {
60883
+ const maxEntries = options.maxEntries ?? DOCTYPE_LAYOUT_MAX_ENTRIES;
60884
+ const now = options.now ?? Date.now;
60885
+ const entries = /* @__PURE__ */ new Map();
60886
+ return {
60887
+ get(doctype) {
60888
+ const entry = entries.get(doctype);
60889
+ if (!entry) return void 0;
60890
+ if (now() >= entry.expiresAt) {
60891
+ entries.delete(doctype);
60892
+ return void 0;
60893
+ }
60894
+ return entry.layout === null ? null : structuredClone(entry.layout);
60895
+ },
60896
+ set(doctype, layout) {
60897
+ const at = now();
60898
+ entries.delete(doctype);
60899
+ for (const [key, entry] of entries) {
60900
+ if (at >= entry.expiresAt) entries.delete(key);
60901
+ }
60902
+ entries.set(doctype, {
60903
+ layout,
60904
+ expiresAt: at + (layout === null ? DOCTYPE_LAYOUT_FAILURE_TTL_MS : DOCTYPE_LAYOUT_TTL_MS)
60905
+ });
60906
+ while (entries.size > maxEntries) {
60907
+ entries.delete(entries.keys().next().value);
60908
+ }
60909
+ },
60910
+ clear() {
60911
+ entries.clear();
60912
+ },
60913
+ get size() {
60914
+ return entries.size;
60915
+ }
60916
+ };
60917
+ }
60918
+ var docTypeLayoutMemo = createDocTypeLayoutMemo();
60919
+ function logToStderr(message2) {
60920
+ console.error(`[mcp-erpnext] ${message2}`);
60921
+ }
60922
+ async function fetchDocTypeLayout(client, doctype, options = {}) {
60923
+ if (DOCTYPE_LAYOUT_SKIPPED.has(doctype)) return null;
60924
+ const memo3 = options.memo ?? docTypeLayoutMemo;
60925
+ const remembered = memo3.get(doctype);
60926
+ if (remembered !== void 0) return remembered;
60927
+ try {
60928
+ const response = await client.callMethodRaw(
60929
+ GETDOCTYPE_METHOD,
60930
+ { doctype },
60931
+ "GET"
60932
+ );
60933
+ const layout = docTypeLayoutOf(response);
60934
+ if (!layout) throw new Error("unreadable getdoctype response");
60935
+ memo3.set(doctype, layout);
60936
+ return layout;
60937
+ } catch (error2) {
60938
+ (options.log ?? logToStderr)(
60939
+ `DocType layout unavailable for "${doctype}" (non-fatal): ${error2 instanceof Error ? error2.message : String(error2)}`
60940
+ );
60941
+ memo3.set(doctype, null);
60942
+ return null;
60943
+ }
60944
+ }
60945
+
60946
+ // src/tools/model-visible.ts
60947
+ var VIEWER_META_KEY = "io.casys/viewer";
60948
+ var VIEWER_ONLY_RESULT_KEYS = [
60949
+ "_meta",
60950
+ "refreshRequest",
60951
+ "_availableTools",
60952
+ "_rowAction",
60953
+ "_sendMessageHints",
60954
+ "_drillDown",
60955
+ "_trendDrillDown",
60956
+ "_jumps",
60957
+ "_stageJumps",
60958
+ "_pointJumps",
60959
+ "_seriesPointJumps",
60960
+ "_fieldLayout"
60961
+ ];
60962
+ function modelVisiblePayload(result) {
60963
+ const payload = { ...result };
60964
+ for (const key of VIEWER_ONLY_RESULT_KEYS) delete payload[key];
60965
+ return payload;
60966
+ }
60967
+ function splitViewerResult(result) {
60968
+ const viewer2 = {};
60969
+ for (const key of VIEWER_ONLY_RESULT_KEYS) {
60970
+ if (key !== "_meta" && key in result) viewer2[key] = result[key];
60971
+ }
60972
+ return { data: modelVisiblePayload(result), viewer: viewer2 };
60973
+ }
60974
+
60975
+ // src/client.ts
60976
+ function isRecord10(value) {
60099
60977
  return typeof value === "object" && value !== null && !Array.isArray(value);
60100
60978
  }
60101
60979
  function isSingleEmbeddedBlobResult(value) {
60102
- if (!isRecord9(value) || !Array.isArray(value.content)) return false;
60980
+ if (!isRecord10(value) || !Array.isArray(value.content)) return false;
60103
60981
  if (Object.keys(value).some((key) => key !== "content")) return false;
60104
60982
  if (value.content.length !== 2) return false;
60105
60983
  const [summary, embedded] = value.content;
60106
- if (!isRecord9(summary) || summary.type !== "text" || typeof summary.text !== "string") {
60984
+ if (!isRecord10(summary) || summary.type !== "text" || typeof summary.text !== "string") {
60107
60985
  return false;
60108
60986
  }
60109
- if (!isRecord9(embedded) || embedded.type !== "resource") return false;
60110
- if (!isRecord9(embedded.resource)) return false;
60987
+ if (!isRecord10(embedded) || embedded.type !== "resource") return false;
60988
+ if (!isRecord10(embedded.resource)) return false;
60111
60989
  const resource = embedded.resource;
60112
60990
  return typeof resource.uri === "string" && resource.uri.startsWith("file:///") && typeof resource.mimeType === "string" && typeof resource.blob === "string";
60113
60991
  }
60992
+ function singleDocumentDoctypeOf(result) {
60993
+ const nonEmpty2 = (value) => typeof value === "string" && value.trim().length > 0;
60994
+ const document = isRecord10(result.data) && nonEmpty2(result.data.doctype) && nonEmpty2(result.data.name) ? result.data : !Array.isArray(result.data) && nonEmpty2(result.doctype) && nonEmpty2(result.name) ? result : void 0;
60995
+ return document ? document.doctype.trim() : void 0;
60996
+ }
60114
60997
  function withSafeUiRefresh(result, tool, args, availableToolNames) {
60115
60998
  const readOnly = tool.annotations?.readOnlyHint === true;
60116
- if (!isRecord9(result)) {
60999
+ if (!isRecord10(result)) {
60117
61000
  return result;
60118
61001
  }
60119
- const resultMeta = isRecord9(result._meta) ? result._meta : {};
61002
+ const resultMeta = isRecord10(result._meta) ? result._meta : {};
60120
61003
  const declaredUi = tool._meta?.ui;
60121
- const viewerResult = declaredUi && !isRecord9(resultMeta.ui) ? {
61004
+ const viewerResult = declaredUi && !isRecord10(resultMeta.ui) ? {
60122
61005
  ...result,
60123
61006
  _meta: { ...resultMeta, ...tool._meta, ui: declaredUi }
60124
61007
  } : result;
60125
- const request = isRecord9(viewerResult.refreshRequest) ? viewerResult.refreshRequest : null;
61008
+ const request = isRecord10(viewerResult.refreshRequest) ? viewerResult.refreshRequest : null;
60126
61009
  const target = request && typeof request.toolName === "string" ? getToolByName(request.toolName) : void 0;
60127
- const hasSafeExplicitRefresh = request !== null && typeof request.toolName === "string" && availableToolNames.has(request.toolName) && isRecord9(request.arguments) && (request.toolName === tool.name ? readOnly : target?.annotations?.readOnlyHint === true);
61010
+ const hasSafeExplicitRefresh = request !== null && typeof request.toolName === "string" && availableToolNames.has(request.toolName) && isRecord10(request.arguments) && (request.toolName === tool.name ? readOnly : target?.annotations?.readOnlyHint === true);
60128
61011
  if (hasSafeExplicitRefresh) {
60129
61012
  return withUiRefreshRequest(
60130
61013
  viewerResult,
@@ -60150,13 +61033,16 @@ function withSafeUiRefresh(result, tool, args, availableToolNames) {
60150
61033
  var ErpNextToolsClient = class {
60151
61034
  tools;
60152
61035
  enableLinkDisambiguation;
61036
+ /** How the active tool list was resolved (warnings, effective filters). */
61037
+ selection;
60153
61038
  constructor(options) {
60154
61039
  this.enableLinkDisambiguation = options?.enableLinkDisambiguation ?? false;
60155
- if (options?.categories) {
60156
- this.tools = options.categories.flatMap((cat) => getToolsByCategory(cat));
60157
- } else {
60158
- this.tools = allTools;
60159
- }
61040
+ this.selection = resolveToolSelection({
61041
+ categories: options?.categories,
61042
+ excludeCategories: options?.excludeCategories,
61043
+ excludeTools: options?.excludeTools
61044
+ });
61045
+ this.tools = this.selection.tools;
60160
61046
  }
60161
61047
  /** List available tools (with handler attached) */
60162
61048
  listTools() {
@@ -60182,8 +61068,11 @@ var ErpNextToolsClient = class {
60182
61068
  *
60183
61069
  * Object results are returned as pre-formatted MCP results with both
60184
61070
  * `content` (text JSON for model clients) and `structuredContent` (the exact
60185
- * machine-readable value). Viewer metadata is attached only when declared.
60186
- * McpApp passes these results through unchanged.
61071
+ * machine-readable value). Both carry only domain data : l'enrichissement
61072
+ * viewer (refresh, sauts, `_availableTools`…) part dans
61073
+ * `_meta["io.casys/viewer"]`, le seul canal caché au modèle sur tous les
61074
+ * hôtes, que la vue refusionne à la lecture. McpApp passes these results
61075
+ * through unchanged.
60187
61076
  */
60188
61077
  buildHandlersMap() {
60189
61078
  const handlers = /* @__PURE__ */ new Map();
@@ -60223,10 +61112,21 @@ var ErpNextToolsClient = class {
60223
61112
  const resultUi = r?._meta && typeof r._meta === "object" && r._meta.ui;
60224
61113
  const hasViewer = resultUi || toolMeta?.ui;
60225
61114
  if (r) {
61115
+ const layoutDoctype = hasViewer ? singleDocumentDoctypeOf(r) : void 0;
61116
+ const fieldLayout = layoutDoctype ? await fetchDocTypeLayout(client, layoutDoctype) : null;
61117
+ const { data, viewer: viewer2 } = splitViewerResult(
61118
+ fieldLayout ? { ...r, _fieldLayout: fieldLayout } : r
61119
+ );
61120
+ const declaredMeta = r._meta ?? toolMeta;
61121
+ const hasEnrichment = Object.keys(viewer2).length > 0;
61122
+ const meta2 = hasEnrichment ? {
61123
+ ...isRecord10(declaredMeta) ? declaredMeta : {},
61124
+ [VIEWER_META_KEY]: { version: 1, ...viewer2 }
61125
+ } : hasViewer ? declaredMeta : void 0;
60226
61126
  return {
60227
- content: [{ type: "text", text: JSON.stringify(result) }],
60228
- structuredContent: r,
60229
- ...hasViewer ? { _meta: r._meta ?? toolMeta } : {}
61127
+ content: [{ type: "text", text: JSON.stringify(data) }],
61128
+ structuredContent: data,
61129
+ ...meta2 !== void 0 ? { _meta: meta2 } : {}
60230
61130
  };
60231
61131
  }
60232
61132
  return result;
@@ -60234,7 +61134,16 @@ var ErpNextToolsClient = class {
60234
61134
  }
60235
61135
  return handlers;
60236
61136
  }
60237
- /** Execute a tool by name */
61137
+ /**
61138
+ * Execute a tool by name.
61139
+ *
61140
+ * API bibliothèque : renvoie le résultat enrichi complet, clés viewer
61141
+ * incluses, sans le découpage modèle/vue de `buildHandlersMap`. Le handler
61142
+ * ne reçoit que le client Frappe, sans capacités MCP du client :
61143
+ * `erpnext_file_download` y est donc toujours refusé (`FileDownloadError`,
61144
+ * `APP_CONTEXT_REQUIRED`) — les octets des pièces jointes ne sont servis
61145
+ * qu'aux hôtes MCP Apps via le serveur MCP.
61146
+ */
60238
61147
  async execute(name, args) {
60239
61148
  const tool = this.tools.find((t) => t.name === name);
60240
61149
  if (!tool) {
@@ -60261,6 +61170,117 @@ var ErpNextToolsClient = class {
60261
61170
  };
60262
61171
  var defaultClient = new ErpNextToolsClient();
60263
61172
 
61173
+ // src/tools/selection-config.ts
61174
+ var TOOL_FILTER_OPTIONS = [
61175
+ {
61176
+ key: "categories",
61177
+ flag: "--categories",
61178
+ envVar: "ERPNEXT_CATEGORIES"
61179
+ },
61180
+ {
61181
+ key: "excludeCategories",
61182
+ flag: "--exclude-categories",
61183
+ envVar: "ERPNEXT_EXCLUDE_CATEGORIES"
61184
+ },
61185
+ {
61186
+ key: "excludeTools",
61187
+ flag: "--exclude-tools",
61188
+ envVar: "ERPNEXT_EXCLUDE_TOOLS"
61189
+ }
61190
+ ];
61191
+ function splitList(raw2) {
61192
+ return [...new Set(raw2.split(",").map((s) => s.trim()).filter(Boolean))];
61193
+ }
61194
+ function emptyValue(option, source, received) {
61195
+ const where = source === "cli" ? option.flag : option.envVar;
61196
+ return new ToolFilterError(
61197
+ "EMPTY_VALUE",
61198
+ `${where} is set but contains no value`,
61199
+ {
61200
+ context: { option: option.key, source, received },
61201
+ recovery: source === "cli" ? `use ${option.flag}=a,b or remove the flag` : `set ${option.envVar}=a,b or unset it`
61202
+ }
61203
+ );
61204
+ }
61205
+ function readSelectionConfig(args, getEnv = env6) {
61206
+ const config2 = { sources: {}, warnings: [] };
61207
+ for (const option of TOOL_FILTER_OPTIONS) {
61208
+ const bareIndex = args.indexOf(option.flag);
61209
+ if (bareIndex !== -1) {
61210
+ const next = args[bareIndex + 1];
61211
+ const received = next !== void 0 && !next.startsWith("-") ? `${option.flag} ${next}` : option.flag;
61212
+ const detail = `${option.flag} needs a value in the form ${option.flag}=a,b`;
61213
+ const recovery = `write ${option.flag}=a,b (an equals sign, no space); the flag was not applied`;
61214
+ const context2 = { option: option.key, flag: option.flag, received };
61215
+ if (option.key !== "categories") {
61216
+ throw new ToolFilterError("MALFORMED_FLAG", detail, {
61217
+ context: context2,
61218
+ recovery
61219
+ });
61220
+ }
61221
+ config2.warnings.push({
61222
+ code: TOOL_FILTER_ERROR_CODE,
61223
+ reason: "MALFORMED_FLAG",
61224
+ message: formatToolFilterMessage(`${detail} (ignored)`, recovery),
61225
+ context: Object.freeze({
61226
+ ...context2,
61227
+ reason: "MALFORMED_FLAG",
61228
+ ignored: true
61229
+ }),
61230
+ recovery
61231
+ });
61232
+ }
61233
+ const prefix = `${option.flag}=`;
61234
+ const cliArgs = args.filter((arg) => arg.startsWith(prefix));
61235
+ let raw2;
61236
+ let source;
61237
+ if (cliArgs.length > 0) {
61238
+ if (cliArgs.length > 1) {
61239
+ if (option.key !== "categories") {
61240
+ throw new ToolFilterError(
61241
+ "DUPLICATE_FLAG",
61242
+ `${option.flag} is given ${cliArgs.length} times`,
61243
+ {
61244
+ context: { option: option.key, received: cliArgs },
61245
+ recovery: `give ${option.flag} once, with a comma-separated list (${option.flag}=a,b)`
61246
+ }
61247
+ );
61248
+ }
61249
+ const recovery = `give ${option.flag} once, with a comma-separated list; only the first occurrence is used`;
61250
+ config2.warnings.push({
61251
+ code: TOOL_FILTER_ERROR_CODE,
61252
+ reason: "DUPLICATE_FLAG",
61253
+ message: formatToolFilterMessage(
61254
+ `${option.flag} is given ${cliArgs.length} times (using the first)`,
61255
+ recovery
61256
+ ),
61257
+ context: Object.freeze({
61258
+ option: option.key,
61259
+ received: cliArgs,
61260
+ reason: "DUPLICATE_FLAG"
61261
+ }),
61262
+ recovery
61263
+ });
61264
+ }
61265
+ raw2 = cliArgs[0].slice(prefix.length);
61266
+ source = "cli";
61267
+ if (splitList(raw2).length === 0) throw emptyValue(option, "cli", raw2);
61268
+ } else {
61269
+ const fromEnv = getEnv(option.envVar);
61270
+ if (fromEnv !== void 0 && fromEnv.trim() !== "") {
61271
+ raw2 = fromEnv;
61272
+ source = "env";
61273
+ if (splitList(raw2).length === 0) throw emptyValue(option, "env", raw2);
61274
+ }
61275
+ }
61276
+ if (raw2 !== void 0 && source !== void 0) {
61277
+ config2[option.key] = splitList(raw2);
61278
+ config2.sources[option.key] = source;
61279
+ }
61280
+ }
61281
+ return config2;
61282
+ }
61283
+
60264
61284
  // src/ui/viewers.ts
60265
61285
  var UI_VIEWERS = [
60266
61286
  "invoice-viewer",
@@ -60668,16 +61688,16 @@ function buildAuthProvider(config2) {
60668
61688
  }
60669
61689
 
60670
61690
  // src/cache/warm.ts
60671
- async function warmCache() {
61691
+ async function warmCache(activeTools = allTools) {
60672
61692
  const configured = env6("MCP_CACHE_WARM_TOOLS");
60673
61693
  if (!configured?.trim()) return;
60674
61694
  const client = getFrappeClient();
60675
61695
  const names = configured.split(",").map((s) => s.trim()).filter(Boolean);
60676
61696
  for (const name of names) {
60677
- const tool = getToolByName(name);
61697
+ const tool = activeTools.find((t) => t.name === name);
60678
61698
  if (!tool) {
60679
61699
  console.error(
60680
- `[mcp-erpnext] Cache warm: unknown tool "${name}", skipping`
61700
+ allTools.some((t) => t.name === name) ? `[mcp-erpnext] Cache warm: "${name}" is not in active tool selection, skipping` : `[mcp-erpnext] Cache warm: unknown tool "${name}", skipping`
60681
61701
  );
60682
61702
  continue;
60683
61703
  }
@@ -60768,8 +61788,10 @@ async function main() {
60768
61788
  ]);
60769
61789
  return;
60770
61790
  }
60771
- const categoriesArg = args.find((arg) => arg.startsWith("--categories="));
60772
- const categories = categoriesArg ? categoriesArg.split("=")[1].split(",") : void 0;
61791
+ const selectionConfig = readSelectionConfig(args);
61792
+ for (const warning of selectionConfig.warnings) {
61793
+ console.error(warning.message);
61794
+ }
60773
61795
  const httpFlag = args.includes("--http");
60774
61796
  const portArg = args.find((arg) => arg.startsWith("--port="));
60775
61797
  const httpPort = portArg ? parseInt(portArg.split("=")[1], 10) : DEFAULT_HTTP_PORT;
@@ -60781,13 +61803,19 @@ async function main() {
60781
61803
  const mrtrConfig = loadMrtrConfig();
60782
61804
  const toolsClient = new ErpNextToolsClient(
60783
61805
  {
60784
- ...categories ? { categories } : {},
61806
+ ...selectionConfig.categories ? { categories: selectionConfig.categories } : {},
61807
+ ...selectionConfig.excludeCategories ? { excludeCategories: selectionConfig.excludeCategories } : {},
61808
+ ...selectionConfig.excludeTools ? { excludeTools: selectionConfig.excludeTools } : {},
60785
61809
  enableLinkDisambiguation: mrtrConfig !== void 0
60786
61810
  }
60787
61811
  );
61812
+ for (const warning of toolsClient.selection.warnings) {
61813
+ console.error(warning.message);
61814
+ }
61815
+ assertNonEmptySelection(toolsClient.selection);
60788
61816
  const server = new McpApp({
60789
61817
  name: "mcp-erpnext",
60790
- version: "3.2.0-beta.1",
61818
+ version: "3.2.0",
60791
61819
  transport: "stateless",
60792
61820
  cache: {
60793
61821
  ttlMs: 36e5,
@@ -60822,7 +61850,7 @@ async function main() {
60822
61850
  const remoteTarget = distPath ? remoteViewerNetTarget(distPath) : null;
60823
61851
  if (distPath && remoteTarget !== null && !isViewerNetAllowed(netPermission3(remoteTarget))) {
60824
61852
  skippedRemoteViewers.push(viewerName);
60825
- skippedRemoteHost = new URL(distPath).hostname;
61853
+ skippedRemoteHost = remoteTarget;
60826
61854
  } else if (distPath) {
60827
61855
  server.registerResource(
60828
61856
  withViewerResourceMeta({
@@ -60840,7 +61868,9 @@ async function main() {
60840
61868
  });
60841
61869
  }
60842
61870
  );
60843
- console.error(`[mcp-erpnext] Registered UI resource: ${resourceUri}`);
61871
+ console.error(
61872
+ remoteTarget !== null ? `[mcp-erpnext] Registered UI resource (remote, fetched on first read, cached): ${resourceUri}` : `[mcp-erpnext] Registered UI resource: ${resourceUri}`
61873
+ );
60844
61874
  } else {
60845
61875
  console.error(
60846
61876
  `[mcp-erpnext] Warning: UI not built for ${resourceUri}. Run 'cd lib/erpnext/src/ui && node build-all.mjs' first or package ui-dist with the npm bundle.`
@@ -60848,16 +61878,28 @@ async function main() {
60848
61878
  }
60849
61879
  }
60850
61880
  if (skippedRemoteViewers.length > 0) {
60851
- console.error(`[mcp-erpnext] ${JSON.stringify({
60852
- code: "VIEWER_REMOTE_PERMISSION_DENIED",
60853
- context: { host: skippedRemoteHost, viewers: skippedRemoteViewers },
60854
- recovery: viewerNetRecovery(skippedRemoteHost)
60855
- })}`);
61881
+ console.error(
61882
+ `[mcp-erpnext] ${JSON.stringify({
61883
+ level: "warn",
61884
+ code: "VIEWER_REMOTE_PERMISSION_DENIED",
61885
+ context: {
61886
+ host: skippedRemoteHost,
61887
+ viewers: skippedRemoteViewers
61888
+ },
61889
+ recovery: viewerNetRecovery(skippedRemoteHost.split(":")[0])
61890
+ })}`
61891
+ );
60856
61892
  }
61893
+ const { sources } = selectionConfig;
61894
+ const filterSummary = [
61895
+ selectionConfig.categories && `categories: ${selectionConfig.categories.join(", ")} [${sources.categories}]`,
61896
+ selectionConfig.excludeCategories && `excluded categories: ${selectionConfig.excludeCategories.join(", ")} [${sources.excludeCategories}]`,
61897
+ selectionConfig.excludeTools && `excluded tools: ${selectionConfig.excludeTools.join(", ")} [${sources.excludeTools}]`
61898
+ ].filter(Boolean).join("; ");
60857
61899
  console.error(
60858
- `[mcp-erpnext] Initialized \u2014 ${toolsClient.count} tools${categories ? ` (categories: ${categories.join(", ")})` : ""}`
61900
+ `[mcp-erpnext] Initialized \u2014 ${toolsClient.count} tools${filterSummary ? ` (${filterSummary})` : ""}`
60859
61901
  );
60860
- warmCache().catch((err) => {
61902
+ warmCache(toolsClient.listTools()).catch((err) => {
60861
61903
  console.error("[mcp-erpnext] Cache warm failed (non-fatal):", err);
60862
61904
  });
60863
61905
  if (httpFlag) {
@@ -60903,6 +61945,18 @@ async function main() {
60903
61945
  }
60904
61946
  }
60905
61947
  main().catch((err) => {
61948
+ if (isToolFilterError(err)) {
61949
+ console.error(
61950
+ `[mcp-erpnext] ${JSON.stringify({
61951
+ code: err.code,
61952
+ message: err.detail,
61953
+ retryable: err.retryable,
61954
+ context: err.context,
61955
+ recovery: err.recovery
61956
+ })}`
61957
+ );
61958
+ exit3(1);
61959
+ }
60906
61960
  console.error("[mcp-erpnext] Fatal error:", err);
60907
61961
  exit3(1);
60908
61962
  });