erpnext-mcp 0.1.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/__init__.py +0 -0
- erpnext_mcp/client.py +209 -0
- erpnext_mcp/server.py +318 -0
- erpnext_mcp/types.py +25 -0
- erpnext_mcp-0.1.0.dist-info/METADATA +146 -0
- erpnext_mcp-0.1.0.dist-info/RECORD +9 -0
- erpnext_mcp-0.1.0.dist-info/WHEEL +4 -0
- erpnext_mcp-0.1.0.dist-info/entry_points.txt +2 -0
- erpnext_mcp-0.1.0.dist-info/licenses/LICENSE +21 -0
erpnext_mcp/__init__.py
ADDED
|
File without changes
|
erpnext_mcp/client.py
ADDED
|
@@ -0,0 +1,209 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
import json
|
|
3
|
+
from typing import Any
|
|
4
|
+
|
|
5
|
+
import httpx
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class ERPNextClient:
|
|
9
|
+
def __init__(self, url: str, api_key: str, api_secret: str):
|
|
10
|
+
self.base_url = url.rstrip("/")
|
|
11
|
+
self.headers = {
|
|
12
|
+
"Authorization": f"token {api_key}:{api_secret}",
|
|
13
|
+
"Content-Type": "application/json",
|
|
14
|
+
"Accept": "application/json",
|
|
15
|
+
}
|
|
16
|
+
self._client: httpx.AsyncClient | None = None
|
|
17
|
+
|
|
18
|
+
async def _get_client(self) -> httpx.AsyncClient:
|
|
19
|
+
if self._client is None or self._client.is_closed:
|
|
20
|
+
self._client = httpx.AsyncClient(
|
|
21
|
+
base_url=self.base_url,
|
|
22
|
+
headers=self.headers,
|
|
23
|
+
timeout=30.0,
|
|
24
|
+
)
|
|
25
|
+
return self._client
|
|
26
|
+
|
|
27
|
+
async def close(self):
|
|
28
|
+
if self._client and not self._client.is_closed:
|
|
29
|
+
await self._client.aclose()
|
|
30
|
+
|
|
31
|
+
async def _request(self, method: str, path: str, **kwargs) -> Any:
|
|
32
|
+
client = await self._get_client()
|
|
33
|
+
resp = await client.request(method, path, **kwargs)
|
|
34
|
+
resp.raise_for_status()
|
|
35
|
+
return resp.json()
|
|
36
|
+
|
|
37
|
+
# --- CRUD ---
|
|
38
|
+
|
|
39
|
+
async def get_list(
|
|
40
|
+
self,
|
|
41
|
+
doctype: str,
|
|
42
|
+
fields: list[str] | None = None,
|
|
43
|
+
filters: Any = None,
|
|
44
|
+
or_filters: Any = None,
|
|
45
|
+
order_by: str | None = None,
|
|
46
|
+
limit_start: int = 0,
|
|
47
|
+
limit_page_length: int = 20,
|
|
48
|
+
) -> list[dict]:
|
|
49
|
+
params: dict[str, Any] = {
|
|
50
|
+
"limit_start": limit_start,
|
|
51
|
+
"limit_page_length": limit_page_length,
|
|
52
|
+
}
|
|
53
|
+
if fields:
|
|
54
|
+
params["fields"] = json.dumps(fields)
|
|
55
|
+
if filters:
|
|
56
|
+
params["filters"] = json.dumps(filters)
|
|
57
|
+
if or_filters:
|
|
58
|
+
params["or_filters"] = json.dumps(or_filters)
|
|
59
|
+
if order_by:
|
|
60
|
+
params["order_by"] = order_by
|
|
61
|
+
|
|
62
|
+
result = await self._request("GET", f"/api/resource/{doctype}", params=params)
|
|
63
|
+
return result.get("data", [])
|
|
64
|
+
|
|
65
|
+
async def get_doc(self, doctype: str, name: str, fields: list[str] | None = None) -> dict:
|
|
66
|
+
params = {}
|
|
67
|
+
if fields:
|
|
68
|
+
params["fields"] = json.dumps(fields)
|
|
69
|
+
result = await self._request("GET", f"/api/resource/{doctype}/{name}", params=params)
|
|
70
|
+
return result.get("data", {})
|
|
71
|
+
|
|
72
|
+
async def create_doc(self, doctype: str, data: dict) -> dict:
|
|
73
|
+
result = await self._request("POST", f"/api/resource/{doctype}", json={"data": json.dumps(data)})
|
|
74
|
+
return result.get("data", {})
|
|
75
|
+
|
|
76
|
+
async def update_doc(self, doctype: str, name: str, data: dict) -> dict:
|
|
77
|
+
result = await self._request("PUT", f"/api/resource/{doctype}/{name}", json={"data": json.dumps(data)})
|
|
78
|
+
return result.get("data", {})
|
|
79
|
+
|
|
80
|
+
async def delete_doc(self, doctype: str, name: str) -> dict:
|
|
81
|
+
result = await self._request("DELETE", f"/api/resource/{doctype}/{name}")
|
|
82
|
+
return result
|
|
83
|
+
|
|
84
|
+
# --- Methods ---
|
|
85
|
+
|
|
86
|
+
async def call_method(self, method: str, http_method: str = "GET", **kwargs) -> Any:
|
|
87
|
+
if http_method.upper() == "POST":
|
|
88
|
+
result = await self._request("POST", f"/api/method/{method}", json=kwargs)
|
|
89
|
+
else:
|
|
90
|
+
result = await self._request("GET", f"/api/method/{method}", params=kwargs)
|
|
91
|
+
return result
|
|
92
|
+
|
|
93
|
+
# --- Document workflow ---
|
|
94
|
+
|
|
95
|
+
async def submit_doc(self, doctype: str, name: str) -> dict:
|
|
96
|
+
doc = await self.get_doc(doctype, name)
|
|
97
|
+
doc["docstatus"] = 1
|
|
98
|
+
return await self.call_method(
|
|
99
|
+
"frappe.client.submit",
|
|
100
|
+
http_method="POST",
|
|
101
|
+
doc=json.dumps(doc),
|
|
102
|
+
)
|
|
103
|
+
|
|
104
|
+
async def cancel_doc(self, doctype: str, name: str) -> dict:
|
|
105
|
+
return await self.call_method(
|
|
106
|
+
"frappe.client.cancel",
|
|
107
|
+
http_method="POST",
|
|
108
|
+
doctype=doctype,
|
|
109
|
+
name=name,
|
|
110
|
+
)
|
|
111
|
+
|
|
112
|
+
async def get_count(self, doctype: str, filters: Any = None) -> int:
|
|
113
|
+
params: dict[str, Any] = {"doctype": doctype}
|
|
114
|
+
if filters:
|
|
115
|
+
params["filters"] = json.dumps(filters)
|
|
116
|
+
result = await self.call_method("frappe.client.get_count", **params)
|
|
117
|
+
return result.get("message", 0)
|
|
118
|
+
|
|
119
|
+
async def get_report(self, report_name: str, filters: Any = None) -> Any:
|
|
120
|
+
params: dict[str, Any] = {"report_name": report_name}
|
|
121
|
+
if filters:
|
|
122
|
+
params["filters"] = json.dumps(filters)
|
|
123
|
+
return await self.call_method("frappe.desk.query_report.run", **params)
|
|
124
|
+
|
|
125
|
+
async def search_link(self, doctype: str, txt: str, filters: Any = None, page_length: int = 20) -> list:
|
|
126
|
+
params: dict[str, Any] = {
|
|
127
|
+
"doctype": doctype,
|
|
128
|
+
"txt": txt,
|
|
129
|
+
"page_length": page_length,
|
|
130
|
+
}
|
|
131
|
+
if filters:
|
|
132
|
+
params["filters"] = json.dumps(filters)
|
|
133
|
+
result = await self.call_method("frappe.desk.search.search_link", **params)
|
|
134
|
+
return result.get("message", result.get("results", []))
|
|
135
|
+
|
|
136
|
+
async def get_doctype_meta(self, doctype: str) -> dict:
|
|
137
|
+
result = await self.call_method("frappe.client.get_list", doctype="DocField", filters=json.dumps({"parent": doctype}), fields=json.dumps(["fieldname", "fieldtype", "label", "reqd", "options"]), limit_page_length="0")
|
|
138
|
+
return result.get("message", [])
|
|
139
|
+
|
|
140
|
+
# --- Inventory & Trading helpers ---
|
|
141
|
+
|
|
142
|
+
async def get_stock_balance(
|
|
143
|
+
self, item_code: str | None = None, warehouse: str | None = None,
|
|
144
|
+
) -> list[dict]:
|
|
145
|
+
filters: dict[str, Any] = {}
|
|
146
|
+
if item_code:
|
|
147
|
+
filters["item_code"] = item_code
|
|
148
|
+
if warehouse:
|
|
149
|
+
filters["warehouse"] = warehouse
|
|
150
|
+
result = await self._request(
|
|
151
|
+
"GET", "/api/resource/Bin",
|
|
152
|
+
params={
|
|
153
|
+
"fields": json.dumps(["item_code", "warehouse", "actual_qty", "reserved_qty", "ordered_qty", "projected_qty"]),
|
|
154
|
+
"filters": json.dumps(filters),
|
|
155
|
+
"limit_page_length": 0,
|
|
156
|
+
},
|
|
157
|
+
)
|
|
158
|
+
return result.get("data", [])
|
|
159
|
+
|
|
160
|
+
async def get_item_price(
|
|
161
|
+
self, item_code: str, price_list: str | None = None,
|
|
162
|
+
) -> list[dict]:
|
|
163
|
+
filters: dict[str, Any] = {"item_code": item_code}
|
|
164
|
+
if price_list:
|
|
165
|
+
filters["price_list"] = price_list
|
|
166
|
+
result = await self._request(
|
|
167
|
+
"GET", "/api/resource/Item Price",
|
|
168
|
+
params={
|
|
169
|
+
"fields": json.dumps(["item_code", "price_list", "price_list_rate", "currency", "uom"]),
|
|
170
|
+
"filters": json.dumps(filters),
|
|
171
|
+
"limit_page_length": 0,
|
|
172
|
+
},
|
|
173
|
+
)
|
|
174
|
+
return result.get("data", [])
|
|
175
|
+
|
|
176
|
+
async def make_mapped_doc(self, method: str, source_name: str) -> dict:
|
|
177
|
+
result = await self.call_method(
|
|
178
|
+
method, http_method="POST", source_name=source_name,
|
|
179
|
+
)
|
|
180
|
+
return result.get("message", result)
|
|
181
|
+
|
|
182
|
+
async def get_party_balance(self, party_type: str, party: str) -> Any:
|
|
183
|
+
result = await self.call_method(
|
|
184
|
+
"erpnext.accounts.utils.get_balance_on",
|
|
185
|
+
http_method="GET",
|
|
186
|
+
party_type=party_type,
|
|
187
|
+
party=party,
|
|
188
|
+
)
|
|
189
|
+
return result.get("message", 0)
|
|
190
|
+
|
|
191
|
+
async def get_stock_ledger(
|
|
192
|
+
self, item_code: str | None = None, warehouse: str | None = None,
|
|
193
|
+
limit: int = 50,
|
|
194
|
+
) -> list[dict]:
|
|
195
|
+
filters: dict[str, Any] = {}
|
|
196
|
+
if item_code:
|
|
197
|
+
filters["item_code"] = item_code
|
|
198
|
+
if warehouse:
|
|
199
|
+
filters["warehouse"] = warehouse
|
|
200
|
+
result = await self._request(
|
|
201
|
+
"GET", "/api/resource/Stock Ledger Entry",
|
|
202
|
+
params={
|
|
203
|
+
"fields": json.dumps(["item_code", "warehouse", "posting_date", "qty_after_transaction", "actual_qty", "voucher_type", "voucher_no"]),
|
|
204
|
+
"filters": json.dumps(filters),
|
|
205
|
+
"order_by": "posting_date desc, posting_time desc",
|
|
206
|
+
"limit_page_length": limit,
|
|
207
|
+
},
|
|
208
|
+
)
|
|
209
|
+
return result.get("data", [])
|
erpnext_mcp/server.py
ADDED
|
@@ -0,0 +1,318 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
import json
|
|
3
|
+
import os
|
|
4
|
+
from typing import Any
|
|
5
|
+
|
|
6
|
+
from dotenv import load_dotenv
|
|
7
|
+
from fastmcp import FastMCP
|
|
8
|
+
|
|
9
|
+
from .client import ERPNextClient
|
|
10
|
+
|
|
11
|
+
load_dotenv()
|
|
12
|
+
|
|
13
|
+
mcp = FastMCP(
|
|
14
|
+
"ERPNext",
|
|
15
|
+
instructions="MCP Server for ERPNext REST API - CRUD, reports, workflow operations",
|
|
16
|
+
)
|
|
17
|
+
|
|
18
|
+
_client: ERPNextClient | None = None
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def get_client() -> ERPNextClient:
|
|
22
|
+
global _client
|
|
23
|
+
if _client is None:
|
|
24
|
+
url = os.environ.get("ERPNEXT_URL", "http://ct.erp")
|
|
25
|
+
api_key = os.environ["ERPNEXT_API_KEY"]
|
|
26
|
+
api_secret = os.environ["ERPNEXT_API_SECRET"]
|
|
27
|
+
_client = ERPNextClient(url, api_key, api_secret)
|
|
28
|
+
return _client
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
# ── CRUD ──────────────────────────────────────────────
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
@mcp.tool()
|
|
35
|
+
async def list_documents(
|
|
36
|
+
doctype: str,
|
|
37
|
+
fields: list[str] | None = None,
|
|
38
|
+
filters: str | None = None,
|
|
39
|
+
or_filters: str | None = None,
|
|
40
|
+
order_by: str | None = None,
|
|
41
|
+
limit_start: int = 0,
|
|
42
|
+
limit_page_length: int = 20,
|
|
43
|
+
) -> list[dict]:
|
|
44
|
+
"""List documents of a given DocType with optional filtering, sorting and pagination.
|
|
45
|
+
|
|
46
|
+
Args:
|
|
47
|
+
doctype: ERPNext DocType name (e.g. "Sales Order", "Customer")
|
|
48
|
+
fields: List of field names to return. Defaults to ["name"].
|
|
49
|
+
filters: JSON string of filters, e.g. '{"status": "Open"}' or '[["status","=","Open"]]'
|
|
50
|
+
or_filters: JSON string of OR filters
|
|
51
|
+
order_by: Sort expression, e.g. "creation desc"
|
|
52
|
+
limit_start: Pagination offset
|
|
53
|
+
limit_page_length: Number of records to return (max 100)
|
|
54
|
+
"""
|
|
55
|
+
f = json.loads(filters) if filters else None
|
|
56
|
+
of = json.loads(or_filters) if or_filters else None
|
|
57
|
+
return await get_client().get_list(
|
|
58
|
+
doctype, fields=fields, filters=f, or_filters=of,
|
|
59
|
+
order_by=order_by, limit_start=limit_start, limit_page_length=limit_page_length,
|
|
60
|
+
)
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
@mcp.tool()
|
|
64
|
+
async def get_document(doctype: str, name: str, fields: list[str] | None = None) -> dict:
|
|
65
|
+
"""Get a single document by DocType and name.
|
|
66
|
+
|
|
67
|
+
Args:
|
|
68
|
+
doctype: ERPNext DocType name
|
|
69
|
+
name: Document name/ID
|
|
70
|
+
fields: Optional list of fields to return
|
|
71
|
+
"""
|
|
72
|
+
return await get_client().get_doc(doctype, name, fields=fields)
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
@mcp.tool()
|
|
76
|
+
async def create_document(doctype: str, data: str) -> dict:
|
|
77
|
+
"""Create a new document.
|
|
78
|
+
|
|
79
|
+
Args:
|
|
80
|
+
doctype: ERPNext DocType name
|
|
81
|
+
data: JSON string of field values, e.g. '{"customer_name": "Test", "customer_type": "Individual"}'
|
|
82
|
+
"""
|
|
83
|
+
return await get_client().create_doc(doctype, json.loads(data))
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
@mcp.tool()
|
|
87
|
+
async def update_document(doctype: str, name: str, data: str) -> dict:
|
|
88
|
+
"""Update an existing document.
|
|
89
|
+
|
|
90
|
+
Args:
|
|
91
|
+
doctype: ERPNext DocType name
|
|
92
|
+
name: Document name/ID
|
|
93
|
+
data: JSON string of fields to update
|
|
94
|
+
"""
|
|
95
|
+
return await get_client().update_doc(doctype, name, json.loads(data))
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
@mcp.tool()
|
|
99
|
+
async def delete_document(doctype: str, name: str) -> dict:
|
|
100
|
+
"""Delete a document.
|
|
101
|
+
|
|
102
|
+
Args:
|
|
103
|
+
doctype: ERPNext DocType name
|
|
104
|
+
name: Document name/ID
|
|
105
|
+
"""
|
|
106
|
+
return await get_client().delete_doc(doctype, name)
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
# ── Reports ───────────────────────────────────────────
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
@mcp.tool()
|
|
113
|
+
async def run_report(report_name: str, filters: str | None = None) -> Any:
|
|
114
|
+
"""Execute an ERPNext report.
|
|
115
|
+
|
|
116
|
+
Args:
|
|
117
|
+
report_name: Name of the report
|
|
118
|
+
filters: Optional JSON string of report filters
|
|
119
|
+
"""
|
|
120
|
+
f = json.loads(filters) if filters else None
|
|
121
|
+
return await get_client().get_report(report_name, filters=f)
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
@mcp.tool()
|
|
125
|
+
async def get_count(doctype: str, filters: str | None = None) -> int:
|
|
126
|
+
"""Get document count for a DocType with optional filters.
|
|
127
|
+
|
|
128
|
+
Args:
|
|
129
|
+
doctype: ERPNext DocType name
|
|
130
|
+
filters: Optional JSON string of filters
|
|
131
|
+
"""
|
|
132
|
+
f = json.loads(filters) if filters else None
|
|
133
|
+
return await get_client().get_count(doctype, filters=f)
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
@mcp.tool()
|
|
137
|
+
async def get_list_with_summary(
|
|
138
|
+
doctype: str,
|
|
139
|
+
fields: list[str] | None = None,
|
|
140
|
+
filters: str | None = None,
|
|
141
|
+
order_by: str | None = None,
|
|
142
|
+
limit_page_length: int = 20,
|
|
143
|
+
) -> dict:
|
|
144
|
+
"""Get a list of documents along with total count.
|
|
145
|
+
|
|
146
|
+
Args:
|
|
147
|
+
doctype: ERPNext DocType name
|
|
148
|
+
fields: Fields to return
|
|
149
|
+
filters: Optional JSON string of filters
|
|
150
|
+
order_by: Sort expression
|
|
151
|
+
limit_page_length: Number of records
|
|
152
|
+
"""
|
|
153
|
+
f = json.loads(filters) if filters else None
|
|
154
|
+
client = get_client()
|
|
155
|
+
docs = await client.get_list(doctype, fields=fields, filters=f, order_by=order_by, limit_page_length=limit_page_length)
|
|
156
|
+
count = await client.get_count(doctype, filters=f)
|
|
157
|
+
return {"data": docs, "total_count": count}
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
# ── Workflow ──────────────────────────────────────────
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
@mcp.tool()
|
|
164
|
+
async def submit_document(doctype: str, name: str) -> dict:
|
|
165
|
+
"""Submit a submittable document (e.g. Sales Invoice).
|
|
166
|
+
|
|
167
|
+
Args:
|
|
168
|
+
doctype: ERPNext DocType name
|
|
169
|
+
name: Document name/ID
|
|
170
|
+
"""
|
|
171
|
+
return await get_client().submit_doc(doctype, name)
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
@mcp.tool()
|
|
175
|
+
async def cancel_document(doctype: str, name: str) -> dict:
|
|
176
|
+
"""Cancel a submitted document.
|
|
177
|
+
|
|
178
|
+
Args:
|
|
179
|
+
doctype: ERPNext DocType name
|
|
180
|
+
name: Document name/ID
|
|
181
|
+
"""
|
|
182
|
+
return await get_client().cancel_doc(doctype, name)
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
@mcp.tool()
|
|
186
|
+
async def run_method(method: str, http_method: str = "POST", args: str | None = None) -> Any:
|
|
187
|
+
"""Call a server-side method (whitelisted API).
|
|
188
|
+
|
|
189
|
+
Args:
|
|
190
|
+
method: Dotted method path, e.g. "frappe.client.get_list" or "erpnext.selling.doctype.sales_order.sales_order.make_delivery_note"
|
|
191
|
+
http_method: GET or POST (default POST)
|
|
192
|
+
args: Optional JSON string of keyword arguments
|
|
193
|
+
"""
|
|
194
|
+
kwargs = json.loads(args) if args else {}
|
|
195
|
+
return await get_client().call_method(method, http_method=http_method, **kwargs)
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
# ── Helpers ───────────────────────────────────────────
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
@mcp.tool()
|
|
202
|
+
async def list_doctypes(module: str | None = None, is_submittable: bool | None = None, limit: int = 100) -> list[str]:
|
|
203
|
+
"""List all available DocType names.
|
|
204
|
+
|
|
205
|
+
Args:
|
|
206
|
+
module: Optional module filter (e.g. "Selling", "Stock", "Accounts")
|
|
207
|
+
is_submittable: Optional filter for submittable doctypes only
|
|
208
|
+
limit: Max results (default 100)
|
|
209
|
+
"""
|
|
210
|
+
filters: dict[str, Any] = {}
|
|
211
|
+
if module:
|
|
212
|
+
filters["module"] = module
|
|
213
|
+
if is_submittable is not None:
|
|
214
|
+
filters["is_submittable"] = int(is_submittable)
|
|
215
|
+
docs = await get_client().get_list(
|
|
216
|
+
"DocType", fields=["name"], filters=filters or None,
|
|
217
|
+
order_by="name asc", limit_page_length=limit,
|
|
218
|
+
)
|
|
219
|
+
return [d["name"] for d in docs]
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
@mcp.tool()
|
|
223
|
+
async def search_link(doctype: str, txt: str, filters: str | None = None, page_length: int = 20) -> list:
|
|
224
|
+
"""Search for link field values (autocomplete).
|
|
225
|
+
|
|
226
|
+
Args:
|
|
227
|
+
doctype: DocType to search in
|
|
228
|
+
txt: Search text
|
|
229
|
+
filters: Optional JSON string of filters
|
|
230
|
+
page_length: Max results
|
|
231
|
+
"""
|
|
232
|
+
f = json.loads(filters) if filters else None
|
|
233
|
+
return await get_client().search_link(doctype, txt, filters=f, page_length=page_length)
|
|
234
|
+
|
|
235
|
+
|
|
236
|
+
@mcp.tool()
|
|
237
|
+
async def get_doctype_meta(doctype: str) -> list:
|
|
238
|
+
"""Get field definitions for a DocType.
|
|
239
|
+
|
|
240
|
+
Args:
|
|
241
|
+
doctype: ERPNext DocType name
|
|
242
|
+
"""
|
|
243
|
+
return await get_client().get_doctype_meta(doctype)
|
|
244
|
+
|
|
245
|
+
|
|
246
|
+
# ── Inventory & Trading ──────────────────────────────
|
|
247
|
+
|
|
248
|
+
|
|
249
|
+
@mcp.tool()
|
|
250
|
+
async def get_stock_balance(item_code: str | None = None, warehouse: str | None = None) -> list[dict]:
|
|
251
|
+
"""Get real-time stock balance from Bin.
|
|
252
|
+
|
|
253
|
+
Args:
|
|
254
|
+
item_code: Optional item code to filter
|
|
255
|
+
warehouse: Optional warehouse to filter
|
|
256
|
+
"""
|
|
257
|
+
return await get_client().get_stock_balance(item_code=item_code, warehouse=warehouse)
|
|
258
|
+
|
|
259
|
+
|
|
260
|
+
@mcp.tool()
|
|
261
|
+
async def get_item_price(item_code: str, price_list: str | None = None) -> list[dict]:
|
|
262
|
+
"""Get item prices from Item Price records.
|
|
263
|
+
|
|
264
|
+
Args:
|
|
265
|
+
item_code: Item code to look up
|
|
266
|
+
price_list: Optional price list name to filter (e.g. "Standard Selling")
|
|
267
|
+
"""
|
|
268
|
+
return await get_client().get_item_price(item_code, price_list=price_list)
|
|
269
|
+
|
|
270
|
+
|
|
271
|
+
@mcp.tool()
|
|
272
|
+
async def make_mapped_doc(method: str, source_name: str) -> dict:
|
|
273
|
+
"""Create a new document mapped from an existing one (document conversion).
|
|
274
|
+
|
|
275
|
+
Common methods:
|
|
276
|
+
- erpnext.selling.doctype.quotation.quotation.make_sales_order (Quotation → Sales Order)
|
|
277
|
+
- erpnext.selling.doctype.sales_order.sales_order.make_delivery_note (Sales Order → Delivery Note)
|
|
278
|
+
- erpnext.selling.doctype.sales_order.sales_order.make_sales_invoice (Sales Order → Sales Invoice)
|
|
279
|
+
- erpnext.stock.doctype.delivery_note.delivery_note.make_sales_invoice (Delivery Note → Sales Invoice)
|
|
280
|
+
- erpnext.buying.doctype.purchase_order.purchase_order.make_purchase_receipt (PO → Purchase Receipt)
|
|
281
|
+
- erpnext.buying.doctype.purchase_order.purchase_order.make_purchase_invoice (PO → Purchase Invoice)
|
|
282
|
+
|
|
283
|
+
Args:
|
|
284
|
+
method: Dotted path of the mapping method
|
|
285
|
+
source_name: Name/ID of the source document
|
|
286
|
+
"""
|
|
287
|
+
return await get_client().make_mapped_doc(method, source_name)
|
|
288
|
+
|
|
289
|
+
|
|
290
|
+
@mcp.tool()
|
|
291
|
+
async def get_party_balance(party_type: str, party: str) -> Any:
|
|
292
|
+
"""Get outstanding balance for a Customer or Supplier.
|
|
293
|
+
|
|
294
|
+
Args:
|
|
295
|
+
party_type: "Customer" or "Supplier"
|
|
296
|
+
party: Party name/ID
|
|
297
|
+
"""
|
|
298
|
+
return await get_client().get_party_balance(party_type, party)
|
|
299
|
+
|
|
300
|
+
|
|
301
|
+
@mcp.tool()
|
|
302
|
+
async def get_stock_ledger(item_code: str | None = None, warehouse: str | None = None, limit: int = 50) -> list[dict]:
|
|
303
|
+
"""Get stock ledger entries (inventory transaction history).
|
|
304
|
+
|
|
305
|
+
Args:
|
|
306
|
+
item_code: Optional item code filter
|
|
307
|
+
warehouse: Optional warehouse filter
|
|
308
|
+
limit: Max records to return (default 50)
|
|
309
|
+
"""
|
|
310
|
+
return await get_client().get_stock_ledger(item_code=item_code, warehouse=warehouse, limit=limit)
|
|
311
|
+
|
|
312
|
+
|
|
313
|
+
def main():
|
|
314
|
+
mcp.run()
|
|
315
|
+
|
|
316
|
+
|
|
317
|
+
if __name__ == "__main__":
|
|
318
|
+
main()
|
erpnext_mcp/types.py
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
from typing import Any
|
|
3
|
+
from pydantic import BaseModel, Field
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class ERPNextResponse(BaseModel):
|
|
7
|
+
data: Any = None
|
|
8
|
+
message: Any = None
|
|
9
|
+
exc: str | None = None
|
|
10
|
+
exc_type: str | None = None
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class ListFilters(BaseModel):
|
|
14
|
+
filters: dict[str, Any] | list[list] | None = None
|
|
15
|
+
fields: list[str] | None = None
|
|
16
|
+
order_by: str | None = None
|
|
17
|
+
limit_start: int = 0
|
|
18
|
+
limit_page_length: int = 20
|
|
19
|
+
or_filters: dict[str, Any] | list[list] | None = None
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class DocumentResponse(BaseModel):
|
|
23
|
+
name: str
|
|
24
|
+
doctype: str | None = None
|
|
25
|
+
data: dict[str, Any] = Field(default_factory=dict)
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: erpnext-mcp
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: MCP Server for ERPNext REST API
|
|
5
|
+
Project-URL: Homepage, https://github.com/ching-tech/erpnext-mcp
|
|
6
|
+
Project-URL: Repository, https://github.com/ching-tech/erpnext-mcp
|
|
7
|
+
Project-URL: Issues, https://github.com/ching-tech/erpnext-mcp/issues
|
|
8
|
+
Author: Ching-Tech
|
|
9
|
+
License: MIT License
|
|
10
|
+
|
|
11
|
+
Copyright (c) 2025 Ching-Tech
|
|
12
|
+
|
|
13
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
14
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
15
|
+
in the Software without restriction, including without limitation the rights
|
|
16
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
17
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
18
|
+
furnished to do so, subject to the following conditions:
|
|
19
|
+
|
|
20
|
+
The above copyright notice and this permission notice shall be included in all
|
|
21
|
+
copies or substantial portions of the Software.
|
|
22
|
+
|
|
23
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
24
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
25
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
26
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
27
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
28
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
29
|
+
SOFTWARE.
|
|
30
|
+
License-File: LICENSE
|
|
31
|
+
Keywords: api,erp,erpnext,frappe,mcp
|
|
32
|
+
Classifier: Development Status :: 4 - Beta
|
|
33
|
+
Classifier: Intended Audience :: Developers
|
|
34
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
35
|
+
Classifier: Programming Language :: Python :: 3
|
|
36
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
37
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
38
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
39
|
+
Classifier: Topic :: Office/Business
|
|
40
|
+
Classifier: Topic :: Software Development :: Libraries
|
|
41
|
+
Requires-Python: >=3.11
|
|
42
|
+
Requires-Dist: fastmcp>=2.0.0
|
|
43
|
+
Requires-Dist: httpx>=0.27.0
|
|
44
|
+
Requires-Dist: pydantic>=2.0.0
|
|
45
|
+
Requires-Dist: python-dotenv>=1.0.0
|
|
46
|
+
Description-Content-Type: text/markdown
|
|
47
|
+
|
|
48
|
+
# ERPNext MCP Server
|
|
49
|
+
|
|
50
|
+
MCP (Model Context Protocol) server for ERPNext REST API, built with [FastMCP](https://github.com/jlowin/fastmcp) and Python.
|
|
51
|
+
|
|
52
|
+
## Features
|
|
53
|
+
|
|
54
|
+
- **CRUD** — List, get, create, update, delete documents
|
|
55
|
+
- **Workflow** — Submit and cancel submittable documents
|
|
56
|
+
- **Reports** — Run ERPNext query reports
|
|
57
|
+
- **Schema** — Inspect DocType field definitions, list all DocTypes
|
|
58
|
+
- **Inventory** — Stock balance, stock ledger, item prices
|
|
59
|
+
- **Trading** — Document conversion (e.g. Quotation → Sales Order), party balance
|
|
60
|
+
- **Helpers** — Link search (autocomplete), document count, generic method calls
|
|
61
|
+
|
|
62
|
+
## Requirements
|
|
63
|
+
|
|
64
|
+
- Python >= 3.11
|
|
65
|
+
- [uv](https://docs.astral.sh/uv/) (recommended) or pip
|
|
66
|
+
- ERPNext instance with API key/secret
|
|
67
|
+
|
|
68
|
+
## Setup
|
|
69
|
+
|
|
70
|
+
```bash
|
|
71
|
+
# Clone the repo
|
|
72
|
+
git clone <repo-url> && cd erpnext-mcp
|
|
73
|
+
|
|
74
|
+
# Create .env file
|
|
75
|
+
cat > .env << 'EOF'
|
|
76
|
+
ERPNEXT_URL=https://your-erpnext-instance.com
|
|
77
|
+
ERPNEXT_API_KEY=your_api_key
|
|
78
|
+
ERPNEXT_API_SECRET=your_api_secret
|
|
79
|
+
EOF
|
|
80
|
+
|
|
81
|
+
# Install dependencies
|
|
82
|
+
uv sync
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
## Run
|
|
86
|
+
|
|
87
|
+
```bash
|
|
88
|
+
set -a && source .env && set +a && uv run erpnext-mcp
|
|
89
|
+
```
|
|
90
|
+
|
|
91
|
+
## Available Tools
|
|
92
|
+
|
|
93
|
+
| Tool | Description |
|
|
94
|
+
|---|---|
|
|
95
|
+
| `list_documents` | List documents with filters, sorting, pagination |
|
|
96
|
+
| `get_document` | Get a single document by name |
|
|
97
|
+
| `create_document` | Create a new document |
|
|
98
|
+
| `update_document` | Update an existing document |
|
|
99
|
+
| `delete_document` | Delete a document |
|
|
100
|
+
| `submit_document` | Submit a submittable document |
|
|
101
|
+
| `cancel_document` | Cancel a submitted document |
|
|
102
|
+
| `run_report` | Execute an ERPNext report |
|
|
103
|
+
| `get_count` | Get document count with optional filters |
|
|
104
|
+
| `get_list_with_summary` | List documents with total count |
|
|
105
|
+
| `run_method` | Call any whitelisted server-side method |
|
|
106
|
+
| `search_link` | Link field autocomplete search |
|
|
107
|
+
| `list_doctypes` | List all available DocType names |
|
|
108
|
+
| `get_doctype_meta` | Get field definitions for a DocType |
|
|
109
|
+
| `get_stock_balance` | Real-time stock balance from Bin |
|
|
110
|
+
| `get_stock_ledger` | Stock ledger entries (inventory history) |
|
|
111
|
+
| `get_item_price` | Item prices from price lists |
|
|
112
|
+
| `make_mapped_doc` | Document conversion (e.g. SO → DN) |
|
|
113
|
+
| `get_party_balance` | Outstanding balance for Customer/Supplier |
|
|
114
|
+
|
|
115
|
+
## MCP Client Configuration
|
|
116
|
+
|
|
117
|
+
Add to your MCP client config (e.g. Claude Desktop `claude_desktop_config.json`):
|
|
118
|
+
|
|
119
|
+
```json
|
|
120
|
+
{
|
|
121
|
+
"mcpServers": {
|
|
122
|
+
"erpnext": {
|
|
123
|
+
"command": "uv",
|
|
124
|
+
"args": ["--directory", "/path/to/erpnext-mcp", "run", "erpnext-mcp"],
|
|
125
|
+
"env": {
|
|
126
|
+
"ERPNEXT_URL": "https://your-erpnext-instance.com",
|
|
127
|
+
"ERPNEXT_API_KEY": "your_api_key",
|
|
128
|
+
"ERPNEXT_API_SECRET": "your_api_secret"
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
```
|
|
134
|
+
|
|
135
|
+
## Project Structure
|
|
136
|
+
|
|
137
|
+
```
|
|
138
|
+
src/erpnext_mcp/
|
|
139
|
+
├── server.py # MCP tool definitions (FastMCP)
|
|
140
|
+
├── client.py # ERPNext REST API client (httpx async)
|
|
141
|
+
└── types.py # Pydantic models
|
|
142
|
+
```
|
|
143
|
+
|
|
144
|
+
## License
|
|
145
|
+
|
|
146
|
+
MIT
|
|
@@ -0,0 +1,9 @@
|
|
|
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,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2025 Ching-Tech
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|