erpnext-mcp 0.1.0__tar.gz → 0.2.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.1.0
3
+ Version: 0.2.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
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "erpnext-mcp"
3
- version = "0.1.0"
3
+ version = "0.2.0"
4
4
  description = "MCP Server for ERPNext REST API"
5
5
  readme = "README.md"
6
6
  license = {file = "LICENSE"}
@@ -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
@@ -310,6 +310,126 @@ 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
+
313
433
  def main():
314
434
  mcp.run()
315
435
 
@@ -59,6 +59,10 @@ class State:
59
59
  so_name: str = ""
60
60
  dn_name: str = "" # Delivery Note
61
61
  si_name: str = "" # Sales Invoice
62
+ # File operations
63
+ test_file_name: str = ""
64
+ attached_file_name: str = ""
65
+ server_test_file_name: str = ""
62
66
 
63
67
 
64
68
  state = State()
@@ -457,6 +461,107 @@ class TestPhase5ServerTools:
457
461
  assert isinstance(result, list)
458
462
 
459
463
 
464
+ # ── Phase 5.5: File Operations ──────────────────────────
465
+
466
+ @pytest.mark.asyncio(loop_scope="module")
467
+ class TestPhase5_5FileOperations:
468
+ """Test file upload, list, download operations."""
469
+
470
+ async def test_01_upload_file(self, client: ERPNextClient):
471
+ """上傳測試檔案"""
472
+ test_content = b"Hello from MCP test!"
473
+ result = await client.upload_file(
474
+ file_content=test_content,
475
+ filename=f"{PREFIX}test_file.txt",
476
+ is_private=True,
477
+ )
478
+ state.test_file_name = result.get("name", "")
479
+ assert result.get("file_name") == f"{PREFIX}test_file.txt"
480
+ assert result.get("file_url")
481
+
482
+ async def test_02_upload_file_attached(self, client: ERPNextClient):
483
+ """上傳附加到 Item 的檔案(需要先執行 Phase 1 建立 Item)"""
484
+ if not state.item_code:
485
+ pytest.skip("Requires item_code from Phase 1")
486
+ test_content = b"Attached file content"
487
+ result = await client.upload_file(
488
+ file_content=test_content,
489
+ filename=f"{PREFIX}attached_file.txt",
490
+ attached_to_doctype="Item",
491
+ attached_to_name=state.item_code,
492
+ is_private=True,
493
+ )
494
+ state.attached_file_name = result.get("name", "")
495
+ assert result.get("attached_to_doctype") == "Item"
496
+ assert result.get("attached_to_name") == state.item_code
497
+
498
+ async def test_03_list_files(self, client: ERPNextClient):
499
+ """列出檔案"""
500
+ files = await client.list_files(limit=10)
501
+ assert isinstance(files, list)
502
+ # 應該能找到我們上傳的檔案
503
+ file_names = [f.get("file_name", "") for f in files]
504
+ assert any(PREFIX in name for name in file_names)
505
+
506
+ async def test_04_list_files_attached(self, client: ERPNextClient):
507
+ """列出附加到 Item 的檔案(需要先執行 test_02)"""
508
+ if not state.attached_file_name:
509
+ pytest.skip("Requires attached_file from test_02")
510
+ files = await client.list_files(
511
+ attached_to_doctype="Item",
512
+ attached_to_name=state.item_code,
513
+ )
514
+ assert len(files) >= 1
515
+ assert any(f.get("file_name", "").startswith(PREFIX) for f in files)
516
+
517
+ async def test_05_get_file_url(self, client: ERPNextClient):
518
+ """取得檔案 URL"""
519
+ url = await client.get_file_url(state.test_file_name)
520
+ assert url
521
+ assert "http" in url or url.startswith("/")
522
+
523
+ async def test_06_download_file(self, client: ERPNextClient):
524
+ """下載檔案"""
525
+ content, filename = await client.download_file(state.test_file_name)
526
+ assert content == b"Hello from MCP test!"
527
+ assert PREFIX in filename
528
+
529
+ async def test_07_server_upload_file_tool(self):
530
+ """測試 server 層的 upload_file 工具"""
531
+ import base64
532
+ test_content = base64.b64encode(b"Server tool test").decode()
533
+ result = await srv.upload_file.fn(
534
+ file_content_base64=test_content,
535
+ filename=f"{PREFIX}server_test.txt",
536
+ )
537
+ state.server_test_file_name = result.get("name", "")
538
+ assert result.get("file_name") == f"{PREFIX}server_test.txt"
539
+
540
+ async def test_08_server_list_files_tool(self):
541
+ """測試 server 層的 list_files 工具"""
542
+ result = await srv.list_files.fn(limit=10)
543
+ assert isinstance(result, list)
544
+
545
+ async def test_09_server_download_file_tool(self):
546
+ """測試 server 層的 download_file 工具"""
547
+ result = await srv.download_file.fn(state.server_test_file_name)
548
+ assert result.get("content_base64")
549
+ assert result.get("filename")
550
+
551
+ async def test_10_cleanup_files(self, client: ERPNextClient):
552
+ """清理測試檔案"""
553
+ for file_name in [
554
+ getattr(state, "test_file_name", ""),
555
+ getattr(state, "attached_file_name", ""),
556
+ getattr(state, "server_test_file_name", ""),
557
+ ]:
558
+ if file_name:
559
+ try:
560
+ await client.delete_doc("File", file_name)
561
+ except Exception:
562
+ pass
563
+
564
+
460
565
  # ── Phase 6: Cleanup ────────────────────────────────────
461
566
 
462
567
  @pytest.mark.asyncio(loop_scope="module")
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes