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 +166 -0
- erpnext_mcp/server.py +120 -0
- {erpnext_mcp-0.1.0.dist-info → erpnext_mcp-0.2.0.dist-info}/METADATA +1 -1
- erpnext_mcp-0.2.0.dist-info/RECORD +9 -0
- erpnext_mcp-0.1.0.dist-info/RECORD +0 -9
- {erpnext_mcp-0.1.0.dist-info → erpnext_mcp-0.2.0.dist-info}/WHEEL +0 -0
- {erpnext_mcp-0.1.0.dist-info → erpnext_mcp-0.2.0.dist-info}/entry_points.txt +0 -0
- {erpnext_mcp-0.1.0.dist-info → erpnext_mcp-0.2.0.dist-info}/licenses/LICENSE +0 -0
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
|
|
|
@@ -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,,
|
|
File without changes
|
|
File without changes
|
|
File without changes
|