erpnext-mcp 0.1.0__py3-none-any.whl → 0.2.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,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
 
@@ -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
@@ -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=VMUZHv5dqnnvVqf3TON-Bxp-YpsFsN0hW42as2T0bHk,14081
4
+ erpnext_mcp/types.py,sha256=MC7H3f1BgpvIbWDwXSv1uFwH8nTD0BMiiqnHypsutk8,643
5
+ erpnext_mcp-0.2.0.dist-info/METADATA,sha256=fzQIIru7dn_k8kmvkBVS-CaSB2oAgyvAx-REXvHiNVs,5119
6
+ erpnext_mcp-0.2.0.dist-info/WHEEL,sha256=WLgqFyCfm_KASv4WHyYy0P3pM_m7J5L9k2skdKLirC8,87
7
+ erpnext_mcp-0.2.0.dist-info/entry_points.txt,sha256=AbRXtSJhFq6PLmqKjNYkFjcKFjQwvkiTI8QAhqqAsps,56
8
+ erpnext_mcp-0.2.0.dist-info/licenses/LICENSE,sha256=M_cHb60t6PxhBL25Y_hbvWe8WaUxG1y5xh5qoWNIlYU,1067
9
+ erpnext_mcp-0.2.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,,