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.
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,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.28.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ erpnext-mcp = erpnext_mcp.server:main
@@ -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.