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.
- {erpnext_mcp-0.1.0 → erpnext_mcp-0.2.0}/PKG-INFO +1 -1
- {erpnext_mcp-0.1.0 → erpnext_mcp-0.2.0}/pyproject.toml +1 -1
- {erpnext_mcp-0.1.0 → erpnext_mcp-0.2.0}/src/erpnext_mcp/client.py +166 -0
- {erpnext_mcp-0.1.0 → erpnext_mcp-0.2.0}/src/erpnext_mcp/server.py +120 -0
- {erpnext_mcp-0.1.0 → erpnext_mcp-0.2.0}/tests/test_integration.py +105 -0
- {erpnext_mcp-0.1.0 → erpnext_mcp-0.2.0}/.env.example +0 -0
- {erpnext_mcp-0.1.0 → erpnext_mcp-0.2.0}/.github/workflows/publish.yml +0 -0
- {erpnext_mcp-0.1.0 → erpnext_mcp-0.2.0}/.gitignore +0 -0
- {erpnext_mcp-0.1.0 → erpnext_mcp-0.2.0}/CLAUDE.md +0 -0
- {erpnext_mcp-0.1.0 → erpnext_mcp-0.2.0}/LICENSE +0 -0
- {erpnext_mcp-0.1.0 → erpnext_mcp-0.2.0}/README.md +0 -0
- {erpnext_mcp-0.1.0 → erpnext_mcp-0.2.0}/docs/api-reference.md +0 -0
- {erpnext_mcp-0.1.0 → erpnext_mcp-0.2.0}/docs/development-notes.md +0 -0
- {erpnext_mcp-0.1.0 → erpnext_mcp-0.2.0}/docs/testing.md +0 -0
- {erpnext_mcp-0.1.0 → erpnext_mcp-0.2.0}/mcp.json.example +0 -0
- {erpnext_mcp-0.1.0 → erpnext_mcp-0.2.0}/src/erpnext_mcp/__init__.py +0 -0
- {erpnext_mcp-0.1.0 → erpnext_mcp-0.2.0}/src/erpnext_mcp/types.py +0 -0
- {erpnext_mcp-0.1.0 → erpnext_mcp-0.2.0}/tests/__init__.py +0 -0
|
@@ -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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|