@casys/mcp-erpnext 2.5.0 → 2.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (3) hide show
  1. package/README.md +14 -11
  2. package/mcp-erpnext.mjs +450 -26
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -1,3 +1,5 @@
1
+ English | [繁體中文](README.zh-TW.md)
2
+
1
3
  # @casys/mcp-erpnext
2
4
 
3
5
  [![JSR](https://jsr.io/badges/@casys/mcp-erpnext)](https://jsr.io/@casys/mcp-erpnext)
@@ -6,7 +8,7 @@
6
8
  [![MCP](https://img.shields.io/badge/MCP-server-1f6feb?logo=modelcontextprotocol&logoColor=white)](https://modelcontextprotocol.io)
7
9
  [![License: MIT](https://img.shields.io/badge/license-MIT-blue.svg)](LICENSE)
8
10
 
9
- MCP server for [ERPNext](https://erpnext.com) / Frappe ERP — **123 tools**
11
+ MCP server for [ERPNext](https://erpnext.com) / Frappe ERP — **124 tools**
10
12
  across **14 categories**, with **7 interactive UI viewers**.
11
13
 
12
14
  Connect any MCP-compatible AI agent (Claude Desktop, Claude Code, VS Code
@@ -239,9 +241,9 @@ npm install
239
241
  node build-all.mjs
240
242
  ```
241
243
 
242
- ## Tools (123)
244
+ ## Tools (124)
243
245
 
244
- 123 tools across 14 categories. Each `_list` tool returns interactive results
246
+ 124 tools across 14 categories. Each `_list` tool returns interactive results
245
247
  via the doclist-viewer with row click, inline detail, and cross-viewer
246
248
  navigation.
247
249
 
@@ -258,8 +260,8 @@ navigation.
258
260
  - **Manufacturing** (7) — BOMs, Work Orders, and Job Cards.
259
261
  - **CRM** (8) — Leads, Opportunities, Contacts, and Campaigns.
260
262
  - **Assets** (8) — Assets, Movements, Maintenance records, and Categories.
261
- - **Operations** (9) — Generic CRUD and native assignment for any DocType
262
- (`erpnext_doc_*`).
263
+ - **Operations** (10) — Generic CRUD, native assignment, and file upload for any
264
+ DocType (`erpnext_doc_*`, `erpnext_file_upload`).
263
265
  - **Kanban** (2) — Read-write boards for Task, Opportunity, and Issue with
264
266
  drag-and-drop.
265
267
  - **Analytics** (17) — 11 analytics charts (bar, area, treemap, radar, scatter,
@@ -270,11 +272,12 @@ Full per-tool reference with parameters: [`docs/tools.md`](docs/tools.md).
270
272
 
271
273
  ## Environment Variables
272
274
 
273
- | Variable | Required | Description |
274
- | -------------------- | -------- | ------------------------------------------------------------------------------------------------------------- |
275
- | `ERPNEXT_URL` | Yes | ERPNext base URL — self-hosted (e.g. `http://localhost:8000`) or cloud (e.g. `https://mycompany.erpnext.com`) |
276
- | `ERPNEXT_API_KEY` | Yes | API Key from User Settings |
277
- | `ERPNEXT_API_SECRET` | Yes | API Secret from User Settings |
275
+ | Variable | Required | Description |
276
+ | -------------------------- | -------- | ------------------------------------------------------------------------------------------------------------- |
277
+ | `ERPNEXT_URL` | Yes | ERPNext base URL — self-hosted (e.g. `http://localhost:8000`) or cloud (e.g. `https://mycompany.erpnext.com`) |
278
+ | `ERPNEXT_API_KEY` | Yes | API Key from User Settings |
279
+ | `ERPNEXT_API_SECRET` | Yes | API Secret from User Settings |
280
+ | `ERPNEXT_MAX_UPLOAD_BYTES` | No | Maximum decoded file-upload size in bytes (positive integer; default: 10 MiB) |
278
281
 
279
282
  ## Architecture
280
283
 
@@ -301,7 +304,7 @@ src/
301
304
  manufacturing.ts # 7 manufacturing tools
302
305
  crm.ts # 8 CRM tools
303
306
  assets.ts # 8 asset tools
304
- operations.ts # 9 generic CRUD tools
307
+ operations.ts # 10 generic operations tools
305
308
  setup.ts # 3 company/setup tools
306
309
  kanban.ts # 2 read-write kanban tools
307
310
  analytics.ts # 17 analytics tools (charts, KPIs, funnel)
package/mcp-erpnext.mjs CHANGED
@@ -34542,7 +34542,7 @@ function isCloudflareWorkers() {
34542
34542
  var USER_AGENT;
34543
34543
  if (typeof navigator === "undefined" || !navigator.userAgent?.startsWith?.("Mozilla/5.0 ")) {
34544
34544
  const NAME = "jose";
34545
- const VERSION2 = "v6.2.3";
34545
+ const VERSION2 = "v6.2.4";
34546
34546
  USER_AGENT = `${NAME}/${VERSION2}`;
34547
34547
  }
34548
34548
  var customFetch = Symbol();
@@ -37314,6 +37314,65 @@ function defaultHumanName(name) {
37314
37314
  return name.split("-").map((w) => w.charAt(0).toUpperCase() + w.slice(1)).join(" ");
37315
37315
  }
37316
37316
 
37317
+ // node_modules/@casys/mcp-server/src/auth/static-token-provider.ts
37318
+ var StaticTokenAuthProvider = class extends AuthProvider {
37319
+ tokens;
37320
+ authInfo;
37321
+ resource;
37322
+ resourceMetadataUrl;
37323
+ scopesSupported;
37324
+ constructor(tokens, options) {
37325
+ super();
37326
+ if (tokens.length === 0) {
37327
+ throw new Error(
37328
+ "[StaticTokenAuthProvider] `tokens` must contain at least one token"
37329
+ );
37330
+ }
37331
+ if (tokens.some((t) => t.trim().length === 0)) {
37332
+ throw new Error(
37333
+ "[StaticTokenAuthProvider] `tokens` must not contain empty entries"
37334
+ );
37335
+ }
37336
+ if (!options.resource?.trim()) {
37337
+ throw new Error("[StaticTokenAuthProvider] `resource` is required");
37338
+ }
37339
+ const resourceUrl = httpsUrl(options.resource);
37340
+ this.tokens = new Set(tokens.map((t) => t.trim()));
37341
+ const scopes = Object.freeze([...options.scopes ?? []]);
37342
+ this.authInfo = Object.freeze({
37343
+ subject: options.subject ?? "static-token-user",
37344
+ scopes
37345
+ });
37346
+ this.resource = options.resource;
37347
+ this.scopesSupported = options.scopesSupported ? Object.freeze([...options.scopesSupported]) : scopes.length > 0 ? scopes : void 0;
37348
+ if (options.resourceMetadataUrl?.trim()) {
37349
+ this.resourceMetadataUrl = httpsUrl(options.resourceMetadataUrl);
37350
+ } else {
37351
+ const parsed = new URL(resourceUrl);
37352
+ const pathPart = parsed.pathname === "/" ? "" : parsed.pathname;
37353
+ this.resourceMetadataUrl = httpsUrl(
37354
+ `${parsed.origin}/.well-known/oauth-protected-resource${pathPart}${parsed.search}`
37355
+ );
37356
+ }
37357
+ }
37358
+ async verifyToken(token) {
37359
+ return this.tokens.has(token.trim()) ? this.authInfo : null;
37360
+ }
37361
+ getResourceMetadata() {
37362
+ return {
37363
+ resource: this.resource,
37364
+ resource_metadata_url: this.resourceMetadataUrl,
37365
+ // No authorization server: static tokens are provisioned out of band.
37366
+ authorization_servers: [],
37367
+ scopes_supported: this.scopesSupported,
37368
+ bearer_methods_supported: ["header"]
37369
+ };
37370
+ }
37371
+ };
37372
+ function createStaticTokenAuthProvider(tokens, options) {
37373
+ return new StaticTokenAuthProvider(tokens, options);
37374
+ }
37375
+
37317
37376
  // node_modules/@casys/mcp-server/src/inspector/launcher.ts
37318
37377
  async function launchInspector(serverCommand, serverArgs, options) {
37319
37378
  const { spawn } = await import("node:child_process");
@@ -37480,6 +37539,34 @@ function stableStringify(value) {
37480
37539
  }
37481
37540
  var DEFAULT_RETRY_STATUSES = [408, 429, 502, 503, 504];
37482
37541
  var DEFAULT_RETRY_METHODS = ["GET"];
37542
+ var DEFAULT_MAX_UPLOAD_BYTES = 10 * 1024 * 1024;
37543
+ function decodeBase64File(contentBase64, maxBytes) {
37544
+ if (contentBase64.length === 0) {
37545
+ throw new Error("[FrappeClient] File content must not be empty");
37546
+ }
37547
+ const unpadded = contentBase64.replace(/=+$/, "");
37548
+ const padding = contentBase64.slice(unpadded.length);
37549
+ if (!/^[A-Za-z0-9+/]+$/.test(unpadded) || !/^={0,2}$/.test(padding) || unpadded.length % 4 === 1) {
37550
+ throw new Error("[FrappeClient] File content must be valid base64");
37551
+ }
37552
+ const decodedSize = Math.floor(unpadded.length * 6 / 8);
37553
+ if (decodedSize > maxBytes) {
37554
+ throw new Error(
37555
+ `[FrappeClient] Decoded file size ${decodedSize} bytes exceeds the ${maxBytes}-byte upload limit`
37556
+ );
37557
+ }
37558
+ const normalized = unpadded + "=".repeat((4 - unpadded.length % 4) % 4);
37559
+ let binary;
37560
+ try {
37561
+ binary = atob(normalized);
37562
+ } catch {
37563
+ throw new Error("[FrappeClient] File content must be valid base64");
37564
+ }
37565
+ if (binary.length === 0) {
37566
+ throw new Error("[FrappeClient] File content must not be empty");
37567
+ }
37568
+ return Uint8Array.from(binary, (character) => character.charCodeAt(0));
37569
+ }
37483
37570
  var FrappeAPIError = class extends Error {
37484
37571
  /**
37485
37572
  * @param message - Human-readable error description
@@ -37537,6 +37624,7 @@ var FrappeClient = class {
37537
37624
  baseUrl;
37538
37625
  authHeader;
37539
37626
  timeoutMs;
37627
+ maxUploadBytes;
37540
37628
  retries;
37541
37629
  retryStatuses;
37542
37630
  retryBackoffMs;
@@ -37546,6 +37634,12 @@ var FrappeClient = class {
37546
37634
  this.baseUrl = config2.baseUrl.replace(/\/$/, "");
37547
37635
  this.authHeader = `token ${config2.apiKey}:${config2.apiSecret}`;
37548
37636
  this.timeoutMs = config2.timeoutMs ?? 3e4;
37637
+ this.maxUploadBytes = config2.maxUploadBytes ?? DEFAULT_MAX_UPLOAD_BYTES;
37638
+ if (!Number.isInteger(this.maxUploadBytes) || this.maxUploadBytes <= 0) {
37639
+ throw new Error(
37640
+ "[FrappeClient] maxUploadBytes must be a positive integer"
37641
+ );
37642
+ }
37549
37643
  this.retries = config2.retries ?? 3;
37550
37644
  this.retryStatuses = config2.retryStatuses ?? DEFAULT_RETRY_STATUSES;
37551
37645
  this.retryBackoffMs = config2.retryBackoffMs ?? 200;
@@ -37553,12 +37647,15 @@ var FrappeClient = class {
37553
37647
  this.cache = config2.cache ?? new MemoryCache();
37554
37648
  }
37555
37649
  // ── Private HTTP helpers ────────────────────────────────────────────────────
37556
- buildHeaders() {
37557
- return {
37650
+ buildHeaders(includeJsonContentType = true) {
37651
+ const headers = {
37558
37652
  "Authorization": this.authHeader,
37559
- "Accept": "application/json",
37560
- "Content-Type": "application/json"
37653
+ "Accept": "application/json"
37561
37654
  };
37655
+ if (includeJsonContentType) {
37656
+ headers["Content-Type"] = "application/json";
37657
+ }
37658
+ return headers;
37562
37659
  }
37563
37660
  /**
37564
37661
  * Decide whether an error is worth retrying.
@@ -37578,11 +37675,11 @@ var FrappeClient = class {
37578
37675
  }
37579
37676
  return this.retryBackoffMs * 2 ** attempt;
37580
37677
  }
37581
- async request(method, path, body) {
37678
+ async request(method, path, body, multipart = false) {
37582
37679
  let lastError;
37583
37680
  for (let attempt = 0; attempt <= this.retries; attempt++) {
37584
37681
  try {
37585
- return await this.requestOnce(method, path, body);
37682
+ return await this.requestOnce(method, path, body, multipart);
37586
37683
  } catch (err) {
37587
37684
  lastError = err;
37588
37685
  if (attempt === this.retries || !this.isRetryable(err, method)) {
@@ -37596,7 +37693,7 @@ var FrappeClient = class {
37596
37693
  }
37597
37694
  throw lastError;
37598
37695
  }
37599
- async requestOnce(method, path, body) {
37696
+ async requestOnce(method, path, body, multipart = false) {
37600
37697
  const url = `${this.baseUrl}${path}`;
37601
37698
  const controller = new AbortController();
37602
37699
  const timer = setTimeout(() => controller.abort(), this.timeoutMs);
@@ -37604,8 +37701,8 @@ var FrappeClient = class {
37604
37701
  try {
37605
37702
  response = await fetch(url, {
37606
37703
  method,
37607
- headers: this.buildHeaders(),
37608
- body: body !== void 0 ? JSON.stringify(body) : void 0,
37704
+ headers: this.buildHeaders(!multipart),
37705
+ body: body === void 0 ? void 0 : multipart ? body : JSON.stringify(body),
37609
37706
  signal: controller.signal
37610
37707
  });
37611
37708
  } catch (err) {
@@ -37778,6 +37875,56 @@ var FrappeClient = class {
37778
37875
  );
37779
37876
  this.invalidate(doctype, name);
37780
37877
  }
37878
+ /**
37879
+ * Upload file bytes and attach the native File document to another document.
37880
+ * POST /api/method/upload_file
37881
+ */
37882
+ async uploadFile(input) {
37883
+ const fileName = input.fileName.trim();
37884
+ const attachedToDoctype = input.attachedToDoctype.trim();
37885
+ const attachedToName = input.attachedToName.trim();
37886
+ const attachedToField = input.attachedToField?.trim();
37887
+ if (!fileName || /[\\/\0]/.test(fileName)) {
37888
+ throw new Error(
37889
+ "[FrappeClient] fileName must be a filename without path separators"
37890
+ );
37891
+ }
37892
+ if (!attachedToDoctype) {
37893
+ throw new Error("[FrappeClient] attachedToDoctype must not be empty");
37894
+ }
37895
+ if (!attachedToName) {
37896
+ throw new Error("[FrappeClient] attachedToName must not be empty");
37897
+ }
37898
+ const bytes = decodeBase64File(
37899
+ input.contentBase64,
37900
+ this.maxUploadBytes
37901
+ );
37902
+ const fileBuffer = new ArrayBuffer(bytes.byteLength);
37903
+ new Uint8Array(fileBuffer).set(bytes);
37904
+ const form = new FormData();
37905
+ form.append("file", new Blob([fileBuffer]), fileName);
37906
+ form.append("doctype", attachedToDoctype);
37907
+ form.append("docname", attachedToName);
37908
+ if (attachedToField) {
37909
+ form.append("fieldname", attachedToField);
37910
+ }
37911
+ form.append("is_private", input.isPrivate === false ? "0" : "1");
37912
+ const res = await this.request(
37913
+ "POST",
37914
+ "/api/method/upload_file",
37915
+ form,
37916
+ true
37917
+ );
37918
+ const file = res.message;
37919
+ this.invalidate("File", file.name);
37920
+ this.invalidate(attachedToDoctype, attachedToName);
37921
+ if (attachedToField) {
37922
+ await this.update(attachedToDoctype, attachedToName, {
37923
+ [attachedToField]: file.file_url
37924
+ });
37925
+ }
37926
+ return file;
37927
+ }
37781
37928
  /**
37782
37929
  * Call a whitelisted Frappe method.
37783
37930
  * POST /api/method/{method}
@@ -37797,6 +37944,7 @@ function getFrappeClient() {
37797
37944
  const url = env6("ERPNEXT_URL");
37798
37945
  const apiKey = env6("ERPNEXT_API_KEY");
37799
37946
  const apiSecret = env6("ERPNEXT_API_SECRET");
37947
+ const maxUploadBytesRaw = env6("ERPNEXT_MAX_UPLOAD_BYTES");
37800
37948
  if (!url) {
37801
37949
  throw new Error(
37802
37950
  "[lib/erpnext] ERPNEXT_URL is required. Set it to your ERPNext instance URL, e.g. http://localhost:8000"
@@ -37811,7 +37959,8 @@ function getFrappeClient() {
37811
37959
  baseUrl: url,
37812
37960
  apiKey,
37813
37961
  apiSecret,
37814
- cache: getCache()
37962
+ cache: getCache(),
37963
+ maxUploadBytes: maxUploadBytesRaw?.trim() ? Number(maxUploadBytesRaw) : void 0
37815
37964
  });
37816
37965
  return _client;
37817
37966
  }
@@ -37899,6 +38048,23 @@ async function resolveDynamicLink(client, targetDoctype, identifier, options = {
37899
38048
  return resolveLink(client, targetDoctype, identifier, searchField, options);
37900
38049
  }
37901
38050
 
38051
+ // src/tools/submit-helpers.ts
38052
+ function withRoundedTotalFallback(doc) {
38053
+ const hasNullRoundedTotal = "base_rounded_total" in doc && doc.base_rounded_total == null || "rounded_total" in doc && doc.rounded_total == null;
38054
+ if (!hasNullRoundedTotal || doc.disable_rounded_total) {
38055
+ return doc;
38056
+ }
38057
+ return { ...doc, disable_rounded_total: 1 };
38058
+ }
38059
+ function roundedTotalFallbackWarning(original, patched) {
38060
+ if (patched.disable_rounded_total === 1 && original.disable_rounded_total !== 1) {
38061
+ return [
38062
+ "disable_rounded_total auto-set \u2014 rounded totals were null (rounding not configured on this instance)"
38063
+ ];
38064
+ }
38065
+ return [];
38066
+ }
38067
+
37902
38068
  // src/tools/sales.ts
37903
38069
  function mapLineItems(items, options) {
37904
38070
  if (!Array.isArray(items) || items.length === 0) {
@@ -38339,13 +38505,20 @@ var salesTools = [
38339
38505
  if (!input.name) {
38340
38506
  throw new Error("[erpnext_sales_order_submit] 'name' is required");
38341
38507
  }
38342
- const doc = await ctx.client.get("Sales Order", input.name);
38508
+ const doc = await ctx.client.get("Sales Order", input.name, {
38509
+ skipCache: true
38510
+ });
38511
+ const docWithDoctype = { ...doc, doctype: "Sales Order" };
38512
+ const patchedDoc = withRoundedTotalFallback(docWithDoctype);
38343
38513
  const result = await ctx.client.callMethod("frappe.client.submit", {
38344
- doc: { ...doc, doctype: "Sales Order" }
38514
+ doc: patchedDoc
38345
38515
  });
38516
+ ctx.client.invalidate("Sales Order", input.name);
38517
+ const warnings = roundedTotalFallbackWarning(docWithDoctype, patchedDoc);
38346
38518
  return {
38347
38519
  data: result,
38348
- message: `Sales Order ${input.name} submitted successfully`
38520
+ message: `Sales Order ${input.name} submitted successfully`,
38521
+ ...warnings.length > 0 ? { warnings } : {}
38349
38522
  };
38350
38523
  }
38351
38524
  },
@@ -38372,6 +38545,7 @@ var salesTools = [
38372
38545
  doctype: "Sales Order",
38373
38546
  name: input.name
38374
38547
  });
38548
+ ctx.client.invalidate("Sales Order", input.name);
38375
38549
  return {
38376
38550
  data: result,
38377
38551
  message: `Sales Order ${input.name} cancelled successfully`
@@ -38578,14 +38752,21 @@ var salesTools = [
38578
38752
  if (!input.name) {
38579
38753
  throw new Error("[erpnext_sales_invoice_submit] 'name' is required");
38580
38754
  }
38581
- const doc = await ctx.client.get("Sales Invoice", input.name);
38755
+ const doc = await ctx.client.get("Sales Invoice", input.name, {
38756
+ skipCache: true
38757
+ });
38758
+ const docWithDoctype = { ...doc, doctype: "Sales Invoice" };
38759
+ const patchedDoc = withRoundedTotalFallback(docWithDoctype);
38582
38760
  const result = await ctx.client.callMethod("frappe.client.submit", {
38583
- doc: { ...doc, doctype: "Sales Invoice" }
38761
+ doc: patchedDoc
38584
38762
  });
38763
+ ctx.client.invalidate("Sales Invoice", input.name);
38764
+ const warnings = roundedTotalFallbackWarning(docWithDoctype, patchedDoc);
38585
38765
  return {
38586
38766
  data: result,
38587
38767
  message: `Sales Invoice ${input.name} submitted successfully`,
38588
- _meta: INVOICE_META
38768
+ _meta: INVOICE_META,
38769
+ ...warnings.length > 0 ? { warnings } : {}
38589
38770
  };
38590
38771
  }
38591
38772
  },
@@ -42642,6 +42823,94 @@ var assetsTools = [
42642
42823
 
42643
42824
  // src/tools/operations.ts
42644
42825
  var operationsTools = [
42826
+ // ── File Attachments ───────────────────────────────────────────────────────
42827
+ {
42828
+ name: "erpnext_file_upload",
42829
+ annotations: { destructiveHint: true },
42830
+ description: "Upload base64-encoded file content and attach it to any ERPNext document. Files are private by default.",
42831
+ category: "operations",
42832
+ inputSchema: {
42833
+ type: "object",
42834
+ properties: {
42835
+ file_name: {
42836
+ type: "string",
42837
+ description: "Filename only, without a path.",
42838
+ minLength: 1
42839
+ },
42840
+ content_base64: {
42841
+ type: "string",
42842
+ description: "File content as standard base64 (not a data URL).",
42843
+ minLength: 1
42844
+ },
42845
+ attached_to_doctype: {
42846
+ type: "string",
42847
+ description: "DocType of the document to attach the file to.",
42848
+ minLength: 1
42849
+ },
42850
+ attached_to_name: {
42851
+ type: "string",
42852
+ description: "Name/ID of the document to attach the file to.",
42853
+ minLength: 1
42854
+ },
42855
+ attached_to_field: {
42856
+ type: "string",
42857
+ description: "Optional Attach or Attach Image field to populate with the uploaded file."
42858
+ },
42859
+ is_private: {
42860
+ type: "boolean",
42861
+ description: "Whether the attachment is private. Defaults to true.",
42862
+ default: true
42863
+ }
42864
+ },
42865
+ required: [
42866
+ "file_name",
42867
+ "content_base64",
42868
+ "attached_to_doctype",
42869
+ "attached_to_name"
42870
+ ]
42871
+ },
42872
+ handler: async (input, ctx) => {
42873
+ const requiredStrings = [
42874
+ "file_name",
42875
+ "content_base64",
42876
+ "attached_to_doctype",
42877
+ "attached_to_name"
42878
+ ];
42879
+ for (const field of requiredStrings) {
42880
+ if (typeof input[field] !== "string" || !input[field].trim()) {
42881
+ throw new Error(
42882
+ `[erpnext_file_upload] '${field}' must be a non-empty string`
42883
+ );
42884
+ }
42885
+ }
42886
+ const fileName = input.file_name;
42887
+ if (/[\\/\0]/.test(fileName)) {
42888
+ throw new Error(
42889
+ "[erpnext_file_upload] 'file_name' must be a filename without a path"
42890
+ );
42891
+ }
42892
+ if (input.is_private !== void 0 && typeof input.is_private !== "boolean") {
42893
+ throw new Error("[erpnext_file_upload] 'is_private' must be a boolean");
42894
+ }
42895
+ if (input.attached_to_field !== void 0 && (typeof input.attached_to_field !== "string" || !input.attached_to_field.trim())) {
42896
+ throw new Error(
42897
+ "[erpnext_file_upload] 'attached_to_field' must be a non-empty string"
42898
+ );
42899
+ }
42900
+ const file = await ctx.client.uploadFile({
42901
+ fileName,
42902
+ contentBase64: input.content_base64,
42903
+ attachedToDoctype: input.attached_to_doctype,
42904
+ attachedToName: input.attached_to_name,
42905
+ ...input.attached_to_field !== void 0 ? { attachedToField: input.attached_to_field.trim() } : {},
42906
+ isPrivate: input.is_private === void 0 ? true : input.is_private
42907
+ });
42908
+ return {
42909
+ data: file,
42910
+ message: `${fileName} attached to ${input.attached_to_doctype} ${input.attached_to_name}`
42911
+ };
42912
+ }
42913
+ },
42645
42914
  // ── Generic Create ──────────────────────────────────────────────────────────
42646
42915
  {
42647
42916
  name: "erpnext_doc_create",
@@ -42796,15 +43065,19 @@ var operationsTools = [
42796
43065
  input.name,
42797
43066
  { skipCache: true }
42798
43067
  );
43068
+ const docWithDoctype = { ...doc, doctype: input.doctype };
43069
+ const patchedDoc = withRoundedTotalFallback(docWithDoctype);
42799
43070
  const result = await ctx.client.callMethod("frappe.client.submit", {
42800
- doc: { ...doc, doctype: input.doctype }
43071
+ doc: patchedDoc
42801
43072
  });
42802
43073
  ctx.client.invalidate(input.doctype, input.name);
43074
+ const warnings = roundedTotalFallbackWarning(docWithDoctype, patchedDoc);
42803
43075
  return {
42804
43076
  data: result,
42805
43077
  message: `${input.doctype} ${input.name} submitted successfully`,
42806
43078
  doctype: input.doctype,
42807
- name: input.name
43079
+ name: input.name,
43080
+ ...warnings.length > 0 ? { warnings } : {}
42808
43081
  };
42809
43082
  }
42810
43083
  },
@@ -45832,6 +46105,121 @@ function resolveViewerDistPath2(moduleUrl, viewerName, exists) {
45832
46105
  return null;
45833
46106
  }
45834
46107
 
46108
+ // src/auth/composite-provider.ts
46109
+ var CompositeAuthProvider = class extends AuthProvider {
46110
+ constructor(providers) {
46111
+ super();
46112
+ this.providers = providers;
46113
+ if (providers.length === 0) {
46114
+ throw new Error(
46115
+ "[CompositeAuthProvider] at least one provider is required"
46116
+ );
46117
+ }
46118
+ }
46119
+ async verifyToken(token) {
46120
+ for (const provider of this.providers) {
46121
+ const info = await provider.verifyToken(token);
46122
+ if (info) return info;
46123
+ }
46124
+ return null;
46125
+ }
46126
+ getResourceMetadata() {
46127
+ const metadata = this.providers.map(
46128
+ (provider) => provider.getResourceMetadata()
46129
+ );
46130
+ const primary = metadata[0];
46131
+ const scopesSupported = [
46132
+ ...new Set(metadata.flatMap((item) => item.scopes_supported ?? []))
46133
+ ];
46134
+ return {
46135
+ ...primary,
46136
+ authorization_servers: [
46137
+ ...new Set(metadata.flatMap((item) => item.authorization_servers))
46138
+ ],
46139
+ ...scopesSupported.length > 0 ? { scopes_supported: scopesSupported } : {},
46140
+ bearer_methods_supported: [
46141
+ ...new Set(
46142
+ metadata.flatMap((item) => item.bearer_methods_supported)
46143
+ )
46144
+ ]
46145
+ };
46146
+ }
46147
+ };
46148
+
46149
+ // src/auth/config.ts
46150
+ function unquote(value) {
46151
+ if (value.length >= 2 && (value.startsWith('"') && value.endsWith('"') || value.startsWith("'") && value.endsWith("'"))) {
46152
+ return value.slice(1, -1);
46153
+ }
46154
+ return value;
46155
+ }
46156
+ function optionalEnvValue(value) {
46157
+ const trimmed = value?.trim();
46158
+ return trimmed ? unquote(trimmed) : void 0;
46159
+ }
46160
+ function loadAuthConfig2() {
46161
+ const single = optionalEnvValue(env6("MCP_AUTH_TOKEN"));
46162
+ const multi = env6("MCP_AUTH_TOKENS");
46163
+ const jwksUrl = optionalEnvValue(env6("MCP_OAUTH_JWKS_URL"));
46164
+ const tokens = /* @__PURE__ */ new Set();
46165
+ if (single) tokens.add(single);
46166
+ if (multi) {
46167
+ for (const t of multi.split(",")) {
46168
+ const token = optionalEnvValue(t);
46169
+ if (token) tokens.add(token);
46170
+ }
46171
+ }
46172
+ if (tokens.size === 0 && !jwksUrl) return null;
46173
+ return {
46174
+ tokens,
46175
+ resource: optionalEnvValue(env6("MCP_AUTH_RESOURCE")),
46176
+ jwksUrl,
46177
+ audience: optionalEnvValue(env6("MCP_OAUTH_AUDIENCE")),
46178
+ issuer: optionalEnvValue(env6("MCP_OAUTH_ISSUER"))
46179
+ };
46180
+ }
46181
+ function buildAuthProvider(config2) {
46182
+ const providers = [];
46183
+ if (config2.tokens.size > 0) {
46184
+ if (!config2.resource) {
46185
+ throw new Error(
46186
+ "[mcp-erpnext] MCP_AUTH_RESOURCE is required alongside MCP_AUTH_TOKEN(S) \u2014 set it to this server's public URL, e.g. https://mcp.example.com"
46187
+ );
46188
+ }
46189
+ providers.push(
46190
+ createStaticTokenAuthProvider([...config2.tokens], {
46191
+ resource: config2.resource
46192
+ })
46193
+ );
46194
+ }
46195
+ if (config2.jwksUrl) {
46196
+ if (!config2.issuer) {
46197
+ throw new Error(
46198
+ "[mcp-erpnext] MCP_OAUTH_ISSUER is required alongside MCP_OAUTH_JWKS_URL"
46199
+ );
46200
+ }
46201
+ if (!config2.audience) {
46202
+ throw new Error(
46203
+ "[mcp-erpnext] MCP_OAUTH_AUDIENCE is required alongside MCP_OAUTH_JWKS_URL"
46204
+ );
46205
+ }
46206
+ if (!config2.resource) {
46207
+ throw new Error(
46208
+ "[mcp-erpnext] MCP_AUTH_RESOURCE is required alongside MCP_OAUTH_JWKS_URL"
46209
+ );
46210
+ }
46211
+ providers.push(
46212
+ createOIDCAuthProvider({
46213
+ issuer: config2.issuer,
46214
+ audience: config2.audience,
46215
+ jwksUri: config2.jwksUrl,
46216
+ resource: config2.resource
46217
+ })
46218
+ );
46219
+ }
46220
+ return providers.length === 1 ? providers[0] : new CompositeAuthProvider(providers);
46221
+ }
46222
+
45835
46223
  // src/cache/warm.ts
45836
46224
  async function warmCache() {
45837
46225
  const configured = env6("MCP_CACHE_WARM_TOOLS");
@@ -45863,6 +46251,22 @@ async function warmCache() {
45863
46251
  }
45864
46252
  }
45865
46253
 
46254
+ // src/auth/resource-metadata-route.ts
46255
+ var ROOT_METADATA_PATH = "/.well-known/oauth-protected-resource";
46256
+ function resourceMetadataRoute(provider) {
46257
+ const metadata = provider.getResourceMetadata();
46258
+ const path = new URL(metadata.resource_metadata_url).pathname;
46259
+ if (path === ROOT_METADATA_PATH) return void 0;
46260
+ return {
46261
+ method: "get",
46262
+ path,
46263
+ handler: () => jsonMetadata(metadata)
46264
+ };
46265
+ }
46266
+ function jsonMetadata(metadata) {
46267
+ return Response.json(metadata);
46268
+ }
46269
+
45866
46270
  // server.ts
45867
46271
  var DEFAULT_HTTP_PORT = 3012;
45868
46272
  async function main() {
@@ -45882,15 +46286,19 @@ async function main() {
45882
46286
  const httpPort = portArg ? parseInt(portArg.split("=")[1], 10) : DEFAULT_HTTP_PORT;
45883
46287
  const hostnameArg = args.find((arg) => arg.startsWith("--hostname="));
45884
46288
  const hostname = hostnameArg ? hostnameArg.split("=")[1] : "127.0.0.1";
46289
+ const authConfig = httpFlag ? loadAuthConfig2() : null;
46290
+ const authProvider = authConfig ? buildAuthProvider(authConfig) : void 0;
46291
+ const authMetadataRoute = authProvider ? resourceMetadataRoute(authProvider) : void 0;
45885
46292
  const toolsClient = new ErpNextToolsClient(
45886
46293
  categories ? { categories } : void 0
45887
46294
  );
45888
46295
  const server = new McpApp({
45889
46296
  name: "mcp-erpnext",
45890
- version: "2.5.0",
46297
+ version: "2.6.0",
45891
46298
  maxConcurrent: 10,
45892
46299
  backpressureStrategy: "queue",
45893
46300
  validateSchema: true,
46301
+ auth: authProvider ? { provider: authProvider } : void 0,
45894
46302
  logger: (msg) => console.error(`[mcp-erpnext] ${msg}`),
45895
46303
  toolErrorMapper: (error2) => {
45896
46304
  if (error2 instanceof FrappeAPIError) return error2.message;
@@ -45939,23 +46347,39 @@ async function main() {
45939
46347
  const isLoopback = hostname === "127.0.0.1" || hostname === "::1" || hostname === "localhost";
45940
46348
  if (!isLoopback) {
45941
46349
  console.error(
45942
- `[mcp-erpnext] WARNING: binding to ${hostname} exposes the HTTP server to the network. Every tool acts with the server's ERPNext API key, so restrict access (firewall, private network, or an authenticating reverse proxy).`
46350
+ `[mcp-erpnext] WARNING: binding to ${hostname} exposes the HTTP server to the network. Every tool acts with the server's ERPNext API key, so restrict access (firewall, private network) or configure auth via MCP_AUTH_TOKEN(S)/MCP_OAUTH_JWKS_URL.`
46351
+ );
46352
+ }
46353
+ onSignal3("SIGINT", () => {
46354
+ console.error("[mcp-erpnext] Shutting down...");
46355
+ exit3(0);
46356
+ });
46357
+ if (!authConfig) {
46358
+ console.error(
46359
+ "[mcp-erpnext] WARNING: No auth configured for HTTP mode. Set MCP_AUTH_TOKEN, MCP_AUTH_TOKENS, or MCP_OAUTH_JWKS_URL to restrict access."
45943
46360
  );
45944
46361
  }
45945
46362
  await server.startHttp({
45946
46363
  port: httpPort,
45947
46364
  hostname,
45948
46365
  cors: true,
46366
+ customRoutes: authMetadataRoute ? [authMetadataRoute] : void 0,
45949
46367
  onListen: (info) => {
45950
46368
  console.error(
45951
- `[mcp-erpnext] HTTP server listening on http://${info.hostname}:${info.port}`
46369
+ `[mcp-erpnext] HTTP server listening${authConfig ? "" : " (unauthenticated)"} on http://${info.hostname}:${info.port}`
45952
46370
  );
46371
+ if (authConfig) {
46372
+ const authMethods = [];
46373
+ if (authConfig.tokens.size > 0) {
46374
+ authMethods.push(`static tokens (${authConfig.tokens.size})`);
46375
+ }
46376
+ if (authConfig.jwksUrl) {
46377
+ authMethods.push(`OAuth JWT JWKS (${authConfig.jwksUrl})`);
46378
+ }
46379
+ console.error(`[mcp-erpnext] Auth: ${authMethods.join(", ")}`);
46380
+ }
45953
46381
  }
45954
46382
  });
45955
- onSignal3("SIGINT", () => {
45956
- console.error("[mcp-erpnext] Shutting down...");
45957
- exit3(0);
45958
- });
45959
46383
  } else {
45960
46384
  await server.start();
45961
46385
  console.error("[mcp-erpnext] stdio mode ready");
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@casys/mcp-erpnext",
3
- "version": "2.5.0",
3
+ "version": "2.6.0",
4
4
  "description": "MCP server for ERPNext with interactive UI viewers",
5
5
  "type": "module",
6
6
  "bin": {