@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.
- package/README.md +14 -11
- package/mcp-erpnext.mjs +450 -26
- 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
|
[](https://jsr.io/@casys/mcp-erpnext)
|
|
@@ -6,7 +8,7 @@
|
|
|
6
8
|
[](https://modelcontextprotocol.io)
|
|
7
9
|
[](LICENSE)
|
|
8
10
|
|
|
9
|
-
MCP server for [ERPNext](https://erpnext.com) / Frappe ERP — **
|
|
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 (
|
|
244
|
+
## Tools (124)
|
|
243
245
|
|
|
244
|
-
|
|
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** (
|
|
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
|
|
274
|
-
|
|
|
275
|
-
| `ERPNEXT_URL`
|
|
276
|
-
| `ERPNEXT_API_KEY`
|
|
277
|
-
| `ERPNEXT_API_SECRET`
|
|
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 #
|
|
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.
|
|
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
|
-
|
|
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
|
|
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:
|
|
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:
|
|
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:
|
|
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.
|
|
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
|
|
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");
|