erpnext-mcp 0.2.0__tar.gz → 0.4.0__tar.gz

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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: erpnext-mcp
3
- Version: 0.2.0
3
+ Version: 0.4.0
4
4
  Summary: MCP Server for ERPNext REST API
5
5
  Project-URL: Homepage, https://github.com/ching-tech/erpnext-mcp
6
6
  Project-URL: Repository, https://github.com/ching-tech/erpnext-mcp
@@ -57,6 +57,8 @@ MCP (Model Context Protocol) server for ERPNext REST API, built with [FastMCP](h
57
57
  - **Schema** — Inspect DocType field definitions, list all DocTypes
58
58
  - **Inventory** — Stock balance, stock ledger, item prices
59
59
  - **Trading** — Document conversion (e.g. Quotation → Sales Order), party balance
60
+ - **Supplier/Customer** — Get complete details with address, phone, contacts; supports alias search
61
+ - **Files** — Upload, list, download files
60
62
  - **Helpers** — Link search (autocomplete), document count, generic method calls
61
63
 
62
64
  ## Requirements
@@ -111,6 +113,13 @@ set -a && source .env && set +a && uv run erpnext-mcp
111
113
  | `get_item_price` | Item prices from price lists |
112
114
  | `make_mapped_doc` | Document conversion (e.g. SO → DN) |
113
115
  | `get_party_balance` | Outstanding balance for Customer/Supplier |
116
+ | `get_supplier_details` | Get supplier with address, phone, contacts (supports alias search) |
117
+ | `get_customer_details` | Get customer with address, phone, contacts (supports alias search) |
118
+ | `upload_file` | Upload a local file to ERPNext (by file path) |
119
+ | `upload_file_from_url` | Upload a file from URL |
120
+ | `list_files` | List files attached to a document |
121
+ | `download_file` | Download a file by URL |
122
+ | `get_file_url` | Get download URL for a file |
114
123
 
115
124
  ## MCP Client Configuration
116
125
 
@@ -10,6 +10,8 @@ MCP (Model Context Protocol) server for ERPNext REST API, built with [FastMCP](h
10
10
  - **Schema** — Inspect DocType field definitions, list all DocTypes
11
11
  - **Inventory** — Stock balance, stock ledger, item prices
12
12
  - **Trading** — Document conversion (e.g. Quotation → Sales Order), party balance
13
+ - **Supplier/Customer** — Get complete details with address, phone, contacts; supports alias search
14
+ - **Files** — Upload, list, download files
13
15
  - **Helpers** — Link search (autocomplete), document count, generic method calls
14
16
 
15
17
  ## Requirements
@@ -64,6 +66,13 @@ set -a && source .env && set +a && uv run erpnext-mcp
64
66
  | `get_item_price` | Item prices from price lists |
65
67
  | `make_mapped_doc` | Document conversion (e.g. SO → DN) |
66
68
  | `get_party_balance` | Outstanding balance for Customer/Supplier |
69
+ | `get_supplier_details` | Get supplier with address, phone, contacts (supports alias search) |
70
+ | `get_customer_details` | Get customer with address, phone, contacts (supports alias search) |
71
+ | `upload_file` | Upload a local file to ERPNext (by file path) |
72
+ | `upload_file_from_url` | Upload a file from URL |
73
+ | `list_files` | List files attached to a document |
74
+ | `download_file` | Download a file by URL |
75
+ | `get_file_url` | Get download URL for a file |
67
76
 
68
77
  ## MCP Client Configuration
69
78
 
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "erpnext-mcp"
3
- version = "0.2.0"
3
+ version = "0.4.0"
4
4
  description = "MCP Server for ERPNext REST API"
5
5
  readme = "README.md"
6
6
  license = {file = "LICENSE"}
@@ -314,29 +314,27 @@ async def get_stock_ledger(item_code: str | None = None, warehouse: str | None =
314
314
 
315
315
 
316
316
  @mcp.tool()
317
- async def upload_file(
318
- file_content_base64: str,
319
- filename: str,
317
+ async def upload_file_from_url(
318
+ file_url: str,
319
+ filename: str | None = None,
320
320
  attached_to_doctype: str | None = None,
321
321
  attached_to_name: str | None = None,
322
322
  is_private: bool = True,
323
323
  ) -> dict:
324
- """Upload a file to ERPNext.
324
+ """Upload a file to ERPNext from a URL.
325
325
 
326
326
  Args:
327
- file_content_base64: File content encoded as base64 string
328
- filename: Name for the uploaded file (e.g. "report.pdf")
329
- attached_to_doctype: Optional DocType to attach file to (e.g. "Project", "Item")
330
- attached_to_name: Optional document name to attach file to (e.g. "PROJ-0001")
327
+ file_url: Source URL to fetch the file from
328
+ filename: Optional name for the file (will be inferred from URL if not provided)
329
+ attached_to_doctype: Optional DocType to attach file to
330
+ attached_to_name: Optional document name to attach file to
331
331
  is_private: Whether file should be private (default True)
332
332
 
333
333
  Returns:
334
334
  File document with file_url and other metadata
335
335
  """
336
- import base64
337
- file_content = base64.b64decode(file_content_base64)
338
- return await get_client().upload_file(
339
- file_content=file_content,
336
+ return await get_client().upload_file_from_url(
337
+ file_url=file_url,
340
338
  filename=filename,
341
339
  attached_to_doctype=attached_to_doctype,
342
340
  attached_to_name=attached_to_name,
@@ -345,28 +343,40 @@ async def upload_file(
345
343
 
346
344
 
347
345
  @mcp.tool()
348
- async def upload_file_from_url(
349
- file_url: str,
346
+ async def upload_file(
347
+ file_path: str,
350
348
  filename: str | None = None,
351
349
  attached_to_doctype: str | None = None,
352
350
  attached_to_name: str | None = None,
353
351
  is_private: bool = True,
354
352
  ) -> dict:
355
- """Upload a file to ERPNext from a URL.
353
+ """Upload a local file to ERPNext.
356
354
 
357
355
  Args:
358
- file_url: Source URL to fetch the file from
359
- filename: Optional name for the file (will be inferred from URL if not provided)
360
- attached_to_doctype: Optional DocType to attach file to
361
- attached_to_name: Optional document name to attach file to
356
+ file_path: Local file path to upload (e.g. "/mnt/nas/files/report.pdf")
357
+ filename: Optional name for the uploaded file (defaults to original filename)
358
+ attached_to_doctype: Optional DocType to attach file to (e.g. "Project", "Item")
359
+ attached_to_name: Optional document name to attach file to (e.g. "PROJ-0001")
362
360
  is_private: Whether file should be private (default True)
363
361
 
364
362
  Returns:
365
363
  File document with file_url and other metadata
366
364
  """
367
- return await get_client().upload_file_from_url(
368
- file_url=file_url,
369
- filename=filename,
365
+ from pathlib import Path
366
+
367
+ path = Path(file_path)
368
+ if not path.exists():
369
+ raise FileNotFoundError(f"File not found: {file_path}")
370
+
371
+ # 讀取檔案內容
372
+ file_content = path.read_bytes()
373
+
374
+ # 使用原始檔名或指定的檔名
375
+ upload_filename = filename or path.name
376
+
377
+ return await get_client().upload_file(
378
+ file_content=file_content,
379
+ filename=upload_filename,
370
380
  attached_to_doctype=attached_to_doctype,
371
381
  attached_to_name=attached_to_name,
372
382
  is_private=is_private,
@@ -430,6 +440,184 @@ async def download_file(file_name: str) -> dict:
430
440
  }
431
441
 
432
442
 
443
+ # ── Supplier/Customer Details ──────────────────────────
444
+
445
+
446
+ @mcp.tool()
447
+ async def get_supplier_details(name: str | None = None, keyword: str | None = None) -> dict:
448
+ """Get complete supplier details including address, phone, and contacts.
449
+
450
+ Args:
451
+ name: Exact supplier name (e.g. "SF0009-2 - 永心企業社")
452
+ keyword: Search keyword to find supplier (e.g. "永心", "健保局")
453
+
454
+ Returns:
455
+ Dict with supplier info, address (phone/fax), and contacts (our purchaser + their contacts)
456
+ """
457
+ client = get_client()
458
+
459
+ # Find supplier
460
+ if name:
461
+ supplier = await client.get_doc("Supplier", name)
462
+ elif keyword:
463
+ # 先搜尋 name 欄位
464
+ suppliers = await client.get_list(
465
+ "Supplier",
466
+ fields=["name", "supplier_name", "supplier_group", "country", "custom_alias"],
467
+ filters={"name": ["like", f"%{keyword}%"]},
468
+ limit_page_length=1,
469
+ )
470
+ # 找不到則搜尋 custom_alias 欄位(別名)
471
+ if not suppliers:
472
+ suppliers = await client.get_list(
473
+ "Supplier",
474
+ fields=["name", "supplier_name", "supplier_group", "country", "custom_alias"],
475
+ filters={"custom_alias": ["like", f"%{keyword}%"]},
476
+ limit_page_length=1,
477
+ )
478
+ if not suppliers:
479
+ return {"error": f"找不到關鍵字「{keyword}」的供應商"}
480
+ supplier = await client.get_doc("Supplier", suppliers[0]["name"])
481
+ else:
482
+ return {"error": "請提供 name 或 keyword"}
483
+
484
+ supplier_name = supplier.get("name")
485
+
486
+ # Get address (phone/fax)
487
+ # Address title format: "代碼 地址", e.g. "SF0009-2 地址"
488
+ code = supplier_name.split(" - ")[0] if " - " in supplier_name else supplier_name
489
+ addresses = await client.get_list(
490
+ "Address",
491
+ fields=["address_title", "address_line1", "city", "pincode", "phone", "fax"],
492
+ filters={"address_title": ["like", f"%{code}%"]},
493
+ limit_page_length=5,
494
+ )
495
+
496
+ # Get contacts via Dynamic Link
497
+ contacts = await client.get_list(
498
+ "Contact",
499
+ fields=["name", "first_name", "designation", "phone", "mobile_no", "email_id"],
500
+ filters=[["Dynamic Link", "link_name", "=", supplier_name]],
501
+ limit_page_length=50,
502
+ )
503
+
504
+ # Categorize contacts
505
+ # 有 designation 的是我們的人(採購人員/業務人員),沒有的是對方的聯絡人
506
+ our_contacts = []
507
+ their_contacts = []
508
+ for c in contacts:
509
+ contact_info = {
510
+ "name": c.get("first_name") or c.get("name"),
511
+ "designation": c.get("designation") or "",
512
+ "phone": c.get("phone") or c.get("mobile_no") or "",
513
+ "email": c.get("email_id") or "",
514
+ }
515
+ if c.get("designation"):
516
+ our_contacts.append(contact_info)
517
+ else:
518
+ their_contacts.append(contact_info)
519
+
520
+ return {
521
+ "supplier": {
522
+ "name": supplier_name,
523
+ "alias": supplier.get("custom_alias") or "",
524
+ "group": supplier.get("supplier_group"),
525
+ "country": supplier.get("country"),
526
+ "currency": supplier.get("default_currency"),
527
+ },
528
+ "address": addresses[0] if addresses else None,
529
+ "our_contacts": our_contacts,
530
+ "their_contacts": their_contacts,
531
+ }
532
+
533
+
534
+ @mcp.tool()
535
+ async def get_customer_details(name: str | None = None, keyword: str | None = None) -> dict:
536
+ """Get complete customer details including address, phone, and contacts.
537
+
538
+ Args:
539
+ name: Exact customer name (e.g. "CM0001 - 正達工程股份有限公司")
540
+ keyword: Search keyword to find customer (e.g. "正達")
541
+
542
+ Returns:
543
+ Dict with customer info, address (phone/fax), and contacts (our sales + their contacts)
544
+ """
545
+ client = get_client()
546
+
547
+ # Find customer
548
+ if name:
549
+ customer = await client.get_doc("Customer", name)
550
+ elif keyword:
551
+ # 先搜尋 name 欄位
552
+ customers = await client.get_list(
553
+ "Customer",
554
+ fields=["name", "customer_name", "customer_group", "territory", "custom_alias"],
555
+ filters={"name": ["like", f"%{keyword}%"]},
556
+ limit_page_length=1,
557
+ )
558
+ # 找不到則搜尋 custom_alias 欄位(別名)
559
+ if not customers:
560
+ customers = await client.get_list(
561
+ "Customer",
562
+ fields=["name", "customer_name", "customer_group", "territory", "custom_alias"],
563
+ filters={"custom_alias": ["like", f"%{keyword}%"]},
564
+ limit_page_length=1,
565
+ )
566
+ if not customers:
567
+ return {"error": f"找不到關鍵字「{keyword}」的客戶"}
568
+ customer = await client.get_doc("Customer", customers[0]["name"])
569
+ else:
570
+ return {"error": "請提供 name 或 keyword"}
571
+
572
+ customer_name = customer.get("name")
573
+
574
+ # Get address (phone/fax)
575
+ code = customer_name.split(" - ")[0] if " - " in customer_name else customer_name
576
+ addresses = await client.get_list(
577
+ "Address",
578
+ fields=["address_title", "address_line1", "city", "pincode", "phone", "fax"],
579
+ filters={"address_title": ["like", f"%{code}%"]},
580
+ limit_page_length=5,
581
+ )
582
+
583
+ # Get contacts via Dynamic Link
584
+ contacts = await client.get_list(
585
+ "Contact",
586
+ fields=["name", "first_name", "designation", "phone", "mobile_no", "email_id"],
587
+ filters=[["Dynamic Link", "link_name", "=", customer_name]],
588
+ limit_page_length=50,
589
+ )
590
+
591
+ # Categorize contacts
592
+ # 有 designation 的是我們的人(採購人員/業務人員),沒有的是對方的聯絡人
593
+ our_contacts = []
594
+ their_contacts = []
595
+ for c in contacts:
596
+ contact_info = {
597
+ "name": c.get("first_name") or c.get("name"),
598
+ "designation": c.get("designation") or "",
599
+ "phone": c.get("phone") or c.get("mobile_no") or "",
600
+ "email": c.get("email_id") or "",
601
+ }
602
+ if c.get("designation"):
603
+ our_contacts.append(contact_info)
604
+ else:
605
+ their_contacts.append(contact_info)
606
+
607
+ return {
608
+ "customer": {
609
+ "name": customer_name,
610
+ "alias": customer.get("custom_alias") or "",
611
+ "group": customer.get("customer_group"),
612
+ "territory": customer.get("territory"),
613
+ "currency": customer.get("default_currency"),
614
+ },
615
+ "address": addresses[0] if addresses else None,
616
+ "our_contacts": our_contacts,
617
+ "their_contacts": their_contacts,
618
+ }
619
+
620
+
433
621
  def main():
434
622
  mcp.run()
435
623
 
File without changes
File without changes
File without changes
File without changes
File without changes