erpnext-mcp 0.1.0__py3-none-any.whl → 0.3.0__py3-none-any.whl

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.
erpnext_mcp/client.py CHANGED
@@ -207,3 +207,169 @@ class ERPNextClient:
207
207
  },
208
208
  )
209
209
  return result.get("data", [])
210
+
211
+ # --- File operations ---
212
+
213
+ async def upload_file(
214
+ self,
215
+ file_content: bytes,
216
+ filename: str,
217
+ attached_to_doctype: str | None = None,
218
+ attached_to_name: str | None = None,
219
+ is_private: bool = True,
220
+ ) -> dict:
221
+ """上傳檔案到 ERPNext。
222
+
223
+ Args:
224
+ file_content: 檔案內容(bytes)
225
+ filename: 檔案名稱
226
+ attached_to_doctype: 附加到的 DocType(如 "Project")
227
+ attached_to_name: 附加到的文件名稱(如 "PROJ-0001")
228
+ is_private: 是否為私有檔案(預設 True)
229
+
230
+ Returns:
231
+ File 文件資料,包含 file_url 等
232
+ """
233
+ # 使用獨立的 httpx client 避免 header 衝突
234
+ async with httpx.AsyncClient(base_url=self.base_url, timeout=60.0) as client:
235
+ # 準備 multipart form data
236
+ files = {
237
+ "file": (filename, file_content),
238
+ }
239
+ data: dict[str, str] = {
240
+ "is_private": "1" if is_private else "0",
241
+ }
242
+ if attached_to_doctype:
243
+ data["doctype"] = attached_to_doctype
244
+ if attached_to_name:
245
+ data["docname"] = attached_to_name
246
+
247
+ resp = await client.post(
248
+ "/api/method/upload_file",
249
+ files=files,
250
+ data=data,
251
+ headers={"Authorization": self.headers["Authorization"]},
252
+ )
253
+ resp.raise_for_status()
254
+ result = resp.json()
255
+ return result.get("message", result)
256
+
257
+ async def upload_file_from_url(
258
+ self,
259
+ file_url: str,
260
+ filename: str | None = None,
261
+ attached_to_doctype: str | None = None,
262
+ attached_to_name: str | None = None,
263
+ is_private: bool = True,
264
+ ) -> dict:
265
+ """從 URL 上傳檔案到 ERPNext。
266
+
267
+ Args:
268
+ file_url: 檔案來源 URL
269
+ filename: 檔案名稱(可選,會從 URL 推斷)
270
+ attached_to_doctype: 附加到的 DocType
271
+ attached_to_name: 附加到的文件名稱
272
+ is_private: 是否為私有檔案
273
+
274
+ Returns:
275
+ File 文件資料
276
+ """
277
+ # 使用獨立的 httpx client
278
+ async with httpx.AsyncClient(base_url=self.base_url, timeout=60.0) as client:
279
+ data: dict[str, str] = {
280
+ "file_url": file_url,
281
+ "is_private": "1" if is_private else "0",
282
+ }
283
+ if filename:
284
+ data["filename"] = filename
285
+ if attached_to_doctype:
286
+ data["doctype"] = attached_to_doctype
287
+ if attached_to_name:
288
+ data["docname"] = attached_to_name
289
+
290
+ resp = await client.post(
291
+ "/api/method/upload_file",
292
+ data=data,
293
+ headers={"Authorization": self.headers["Authorization"]},
294
+ )
295
+ resp.raise_for_status()
296
+ result = resp.json()
297
+ return result.get("message", result)
298
+
299
+ async def list_files(
300
+ self,
301
+ attached_to_doctype: str | None = None,
302
+ attached_to_name: str | None = None,
303
+ is_private: bool | None = None,
304
+ limit: int = 20,
305
+ ) -> list[dict]:
306
+ """列出檔案。
307
+
308
+ Args:
309
+ attached_to_doctype: 過濾附加到的 DocType
310
+ attached_to_name: 過濾附加到的文件名稱
311
+ is_private: 過濾私有/公開檔案
312
+ limit: 返回數量上限
313
+
314
+ Returns:
315
+ File 文件列表
316
+ """
317
+ filters: dict[str, Any] = {}
318
+ if attached_to_doctype:
319
+ filters["attached_to_doctype"] = attached_to_doctype
320
+ if attached_to_name:
321
+ filters["attached_to_name"] = attached_to_name
322
+ if is_private is not None:
323
+ filters["is_private"] = 1 if is_private else 0
324
+
325
+ result = await self._request(
326
+ "GET", "/api/resource/File",
327
+ params={
328
+ "fields": json.dumps([
329
+ "name", "file_name", "file_url", "file_size",
330
+ "attached_to_doctype", "attached_to_name",
331
+ "is_private", "creation", "modified",
332
+ ]),
333
+ "filters": json.dumps(filters) if filters else None,
334
+ "order_by": "creation desc",
335
+ "limit_page_length": limit,
336
+ },
337
+ )
338
+ return result.get("data", [])
339
+
340
+ async def get_file_url(self, file_name: str) -> str:
341
+ """取得檔案的完整下載 URL。
342
+
343
+ Args:
344
+ file_name: File 文件的 name(如 "abc123.pdf")
345
+
346
+ Returns:
347
+ 完整的檔案 URL
348
+ """
349
+ doc = await self.get_doc("File", file_name)
350
+ file_url = doc.get("file_url", "")
351
+ if file_url and not file_url.startswith("http"):
352
+ return f"{self.base_url}{file_url}"
353
+ return file_url
354
+
355
+ async def download_file(self, file_name: str) -> tuple[bytes, str]:
356
+ """下載檔案內容。
357
+
358
+ Args:
359
+ file_name: File 文件的 name
360
+
361
+ Returns:
362
+ (檔案內容 bytes, 檔案名稱)
363
+ """
364
+ doc = await self.get_doc("File", file_name)
365
+ file_url = doc.get("file_url", "")
366
+ original_filename = doc.get("file_name", file_name)
367
+
368
+ if not file_url:
369
+ raise ValueError(f"File {file_name} has no file_url")
370
+
371
+ client = await self._get_client()
372
+ # 下載時不需要 Content-Type: application/json
373
+ resp = await client.get(file_url)
374
+ resp.raise_for_status()
375
+ return resp.content, original_filename
erpnext_mcp/server.py CHANGED
@@ -310,6 +310,284 @@ async def get_stock_ledger(item_code: str | None = None, warehouse: str | None =
310
310
  return await get_client().get_stock_ledger(item_code=item_code, warehouse=warehouse, limit=limit)
311
311
 
312
312
 
313
+ # ── File Operations ─────────────────────────────────
314
+
315
+
316
+ @mcp.tool()
317
+ async def upload_file(
318
+ file_content_base64: str,
319
+ filename: str,
320
+ attached_to_doctype: str | None = None,
321
+ attached_to_name: str | None = None,
322
+ is_private: bool = True,
323
+ ) -> dict:
324
+ """Upload a file to ERPNext.
325
+
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")
331
+ is_private: Whether file should be private (default True)
332
+
333
+ Returns:
334
+ File document with file_url and other metadata
335
+ """
336
+ import base64
337
+ file_content = base64.b64decode(file_content_base64)
338
+ return await get_client().upload_file(
339
+ file_content=file_content,
340
+ filename=filename,
341
+ attached_to_doctype=attached_to_doctype,
342
+ attached_to_name=attached_to_name,
343
+ is_private=is_private,
344
+ )
345
+
346
+
347
+ @mcp.tool()
348
+ async def upload_file_from_url(
349
+ file_url: str,
350
+ filename: str | None = None,
351
+ attached_to_doctype: str | None = None,
352
+ attached_to_name: str | None = None,
353
+ is_private: bool = True,
354
+ ) -> dict:
355
+ """Upload a file to ERPNext from a URL.
356
+
357
+ 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
362
+ is_private: Whether file should be private (default True)
363
+
364
+ Returns:
365
+ File document with file_url and other metadata
366
+ """
367
+ return await get_client().upload_file_from_url(
368
+ file_url=file_url,
369
+ filename=filename,
370
+ attached_to_doctype=attached_to_doctype,
371
+ attached_to_name=attached_to_name,
372
+ is_private=is_private,
373
+ )
374
+
375
+
376
+ @mcp.tool()
377
+ async def list_files(
378
+ attached_to_doctype: str | None = None,
379
+ attached_to_name: str | None = None,
380
+ is_private: bool | None = None,
381
+ limit: int = 20,
382
+ ) -> list[dict]:
383
+ """List files in ERPNext, optionally filtered by attachment.
384
+
385
+ Args:
386
+ attached_to_doctype: Filter by DocType (e.g. "Project", "Item")
387
+ attached_to_name: Filter by document name (e.g. "PROJ-0001")
388
+ is_private: Filter by privacy (True=private, False=public, None=all)
389
+ limit: Max number of files to return (default 20)
390
+
391
+ Returns:
392
+ List of File documents with name, file_name, file_url, file_size, etc.
393
+ """
394
+ return await get_client().list_files(
395
+ attached_to_doctype=attached_to_doctype,
396
+ attached_to_name=attached_to_name,
397
+ is_private=is_private,
398
+ limit=limit,
399
+ )
400
+
401
+
402
+ @mcp.tool()
403
+ async def get_file_url(file_name: str) -> str:
404
+ """Get the full download URL for a file.
405
+
406
+ Args:
407
+ file_name: The File document name (e.g. "abc123.pdf" or the hash-based name)
408
+
409
+ Returns:
410
+ Full URL to download the file
411
+ """
412
+ return await get_client().get_file_url(file_name)
413
+
414
+
415
+ @mcp.tool()
416
+ async def download_file(file_name: str) -> dict:
417
+ """Download a file's content from ERPNext.
418
+
419
+ Args:
420
+ file_name: The File document name
421
+
422
+ Returns:
423
+ Dict with 'content_base64' (file content as base64) and 'filename' (original filename)
424
+ """
425
+ import base64
426
+ content, filename = await get_client().download_file(file_name)
427
+ return {
428
+ "content_base64": base64.b64encode(content).decode("utf-8"),
429
+ "filename": filename,
430
+ }
431
+
432
+
433
+ # ── Supplier/Customer Details ──────────────────────────
434
+
435
+
436
+ @mcp.tool()
437
+ async def get_supplier_details(name: str | None = None, keyword: str | None = None) -> dict:
438
+ """Get complete supplier details including address, phone, and contacts.
439
+
440
+ Args:
441
+ name: Exact supplier name (e.g. "SF0009-2 - 永心企業社")
442
+ keyword: Search keyword to find supplier (e.g. "永心")
443
+
444
+ Returns:
445
+ Dict with supplier info, address (phone/fax), and contacts (our purchaser + their contacts)
446
+ """
447
+ client = get_client()
448
+
449
+ # Find supplier
450
+ if name:
451
+ supplier = await client.get_doc("Supplier", name)
452
+ elif keyword:
453
+ suppliers = await client.get_list(
454
+ "Supplier",
455
+ fields=["name", "supplier_name", "supplier_group", "country"],
456
+ filters={"name": ["like", f"%{keyword}%"]},
457
+ limit_page_length=1,
458
+ )
459
+ if not suppliers:
460
+ return {"error": f"找不到關鍵字「{keyword}」的供應商"}
461
+ supplier = await client.get_doc("Supplier", suppliers[0]["name"])
462
+ else:
463
+ return {"error": "請提供 name 或 keyword"}
464
+
465
+ supplier_name = supplier.get("name")
466
+
467
+ # Get address (phone/fax)
468
+ # Address title format: "代碼 地址", e.g. "SF0009-2 地址"
469
+ code = supplier_name.split(" - ")[0] if " - " in supplier_name else supplier_name
470
+ addresses = await client.get_list(
471
+ "Address",
472
+ fields=["address_title", "address_line1", "city", "pincode", "phone", "fax"],
473
+ filters={"address_title": ["like", f"%{code}%"]},
474
+ limit_page_length=5,
475
+ )
476
+
477
+ # Get contacts via Dynamic Link
478
+ contacts = await client.get_list(
479
+ "Contact",
480
+ fields=["name", "first_name", "designation", "phone", "mobile_no", "email_id"],
481
+ filters=[["Dynamic Link", "link_name", "=", supplier_name]],
482
+ limit_page_length=50,
483
+ )
484
+
485
+ # Categorize contacts
486
+ # 有 designation 的是我們的人(採購人員/業務人員),沒有的是對方的聯絡人
487
+ our_contacts = []
488
+ their_contacts = []
489
+ for c in contacts:
490
+ contact_info = {
491
+ "name": c.get("first_name") or c.get("name"),
492
+ "designation": c.get("designation") or "",
493
+ "phone": c.get("phone") or c.get("mobile_no") or "",
494
+ "email": c.get("email_id") or "",
495
+ }
496
+ if c.get("designation"):
497
+ our_contacts.append(contact_info)
498
+ else:
499
+ their_contacts.append(contact_info)
500
+
501
+ return {
502
+ "supplier": {
503
+ "name": supplier_name,
504
+ "group": supplier.get("supplier_group"),
505
+ "country": supplier.get("country"),
506
+ "currency": supplier.get("default_currency"),
507
+ },
508
+ "address": addresses[0] if addresses else None,
509
+ "our_contacts": our_contacts,
510
+ "their_contacts": their_contacts,
511
+ }
512
+
513
+
514
+ @mcp.tool()
515
+ async def get_customer_details(name: str | None = None, keyword: str | None = None) -> dict:
516
+ """Get complete customer details including address, phone, and contacts.
517
+
518
+ Args:
519
+ name: Exact customer name (e.g. "CM0001 - 正達工程股份有限公司")
520
+ keyword: Search keyword to find customer (e.g. "正達")
521
+
522
+ Returns:
523
+ Dict with customer info, address (phone/fax), and contacts (our sales + their contacts)
524
+ """
525
+ client = get_client()
526
+
527
+ # Find customer
528
+ if name:
529
+ customer = await client.get_doc("Customer", name)
530
+ elif keyword:
531
+ customers = await client.get_list(
532
+ "Customer",
533
+ fields=["name", "customer_name", "customer_group", "territory"],
534
+ filters={"name": ["like", f"%{keyword}%"]},
535
+ limit_page_length=1,
536
+ )
537
+ if not customers:
538
+ return {"error": f"找不到關鍵字「{keyword}」的客戶"}
539
+ customer = await client.get_doc("Customer", customers[0]["name"])
540
+ else:
541
+ return {"error": "請提供 name 或 keyword"}
542
+
543
+ customer_name = customer.get("name")
544
+
545
+ # Get address (phone/fax)
546
+ code = customer_name.split(" - ")[0] if " - " in customer_name else customer_name
547
+ addresses = await client.get_list(
548
+ "Address",
549
+ fields=["address_title", "address_line1", "city", "pincode", "phone", "fax"],
550
+ filters={"address_title": ["like", f"%{code}%"]},
551
+ limit_page_length=5,
552
+ )
553
+
554
+ # Get contacts via Dynamic Link
555
+ contacts = await client.get_list(
556
+ "Contact",
557
+ fields=["name", "first_name", "designation", "phone", "mobile_no", "email_id"],
558
+ filters=[["Dynamic Link", "link_name", "=", customer_name]],
559
+ limit_page_length=50,
560
+ )
561
+
562
+ # Categorize contacts
563
+ # 有 designation 的是我們的人(採購人員/業務人員),沒有的是對方的聯絡人
564
+ our_contacts = []
565
+ their_contacts = []
566
+ for c in contacts:
567
+ contact_info = {
568
+ "name": c.get("first_name") or c.get("name"),
569
+ "designation": c.get("designation") or "",
570
+ "phone": c.get("phone") or c.get("mobile_no") or "",
571
+ "email": c.get("email_id") or "",
572
+ }
573
+ if c.get("designation"):
574
+ our_contacts.append(contact_info)
575
+ else:
576
+ their_contacts.append(contact_info)
577
+
578
+ return {
579
+ "customer": {
580
+ "name": customer_name,
581
+ "group": customer.get("customer_group"),
582
+ "territory": customer.get("territory"),
583
+ "currency": customer.get("default_currency"),
584
+ },
585
+ "address": addresses[0] if addresses else None,
586
+ "our_contacts": our_contacts,
587
+ "their_contacts": their_contacts,
588
+ }
589
+
590
+
313
591
  def main():
314
592
  mcp.run()
315
593
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: erpnext-mcp
3
- Version: 0.1.0
3
+ Version: 0.3.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
@@ -0,0 +1,9 @@
1
+ erpnext_mcp/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
2
+ erpnext_mcp/client.py,sha256=wX71XIi6DO2tVyKcP82NIiC50CE-BxqjglAraERPjsA,13582
3
+ erpnext_mcp/server.py,sha256=e7Zymn6kQ-Vd3Mtu6c1gRtKcsb_gLLl3DL_e1t1MArw,19757
4
+ erpnext_mcp/types.py,sha256=MC7H3f1BgpvIbWDwXSv1uFwH8nTD0BMiiqnHypsutk8,643
5
+ erpnext_mcp-0.3.0.dist-info/METADATA,sha256=xdLBhOwN7eVB5ngCexvN_OJISuisg_MgigCY_v_VxKg,5119
6
+ erpnext_mcp-0.3.0.dist-info/WHEEL,sha256=WLgqFyCfm_KASv4WHyYy0P3pM_m7J5L9k2skdKLirC8,87
7
+ erpnext_mcp-0.3.0.dist-info/entry_points.txt,sha256=AbRXtSJhFq6PLmqKjNYkFjcKFjQwvkiTI8QAhqqAsps,56
8
+ erpnext_mcp-0.3.0.dist-info/licenses/LICENSE,sha256=M_cHb60t6PxhBL25Y_hbvWe8WaUxG1y5xh5qoWNIlYU,1067
9
+ erpnext_mcp-0.3.0.dist-info/RECORD,,
@@ -1,9 +0,0 @@
1
- erpnext_mcp/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
2
- erpnext_mcp/client.py,sha256=TqkXXrTDiFXe6Py2E5BQNMsjlJ_I_ScmuCZjnE7nBkE,7923
3
- erpnext_mcp/server.py,sha256=G1kak-VkrevAKRvB5TmzdG8pE2oP_PfQZlXyPvcqVjQ,10372
4
- erpnext_mcp/types.py,sha256=MC7H3f1BgpvIbWDwXSv1uFwH8nTD0BMiiqnHypsutk8,643
5
- erpnext_mcp-0.1.0.dist-info/METADATA,sha256=mu7E_t0WIutfRRocWDyeeoJoeXBXFJteNUXRDc8-uyo,5119
6
- erpnext_mcp-0.1.0.dist-info/WHEEL,sha256=WLgqFyCfm_KASv4WHyYy0P3pM_m7J5L9k2skdKLirC8,87
7
- erpnext_mcp-0.1.0.dist-info/entry_points.txt,sha256=AbRXtSJhFq6PLmqKjNYkFjcKFjQwvkiTI8QAhqqAsps,56
8
- erpnext_mcp-0.1.0.dist-info/licenses/LICENSE,sha256=M_cHb60t6PxhBL25Y_hbvWe8WaUxG1y5xh5qoWNIlYU,1067
9
- erpnext_mcp-0.1.0.dist-info/RECORD,,