lambda-erp 0.6.11__tar.gz → 0.6.12__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.
- {lambda_erp-0.6.11 → lambda_erp-0.6.12}/PKG-INFO +1 -1
- {lambda_erp-0.6.11 → lambda_erp-0.6.12}/api/main.py +2 -1
- lambda_erp-0.6.12/api/routers/mcp.py +164 -0
- {lambda_erp-0.6.11 → lambda_erp-0.6.12}/pyproject.toml +1 -1
- {lambda_erp-0.6.11 → lambda_erp-0.6.12}/.gitignore +0 -0
- {lambda_erp-0.6.11 → lambda_erp-0.6.12}/LICENSE +0 -0
- {lambda_erp-0.6.11 → lambda_erp-0.6.12}/README.md +0 -0
- {lambda_erp-0.6.11 → lambda_erp-0.6.12}/api/__init__.py +0 -0
- {lambda_erp-0.6.11 → lambda_erp-0.6.12}/api/attachments.py +0 -0
- {lambda_erp-0.6.11 → lambda_erp-0.6.12}/api/auth.py +0 -0
- {lambda_erp-0.6.11 → lambda_erp-0.6.12}/api/bootstrap.py +0 -0
- {lambda_erp-0.6.11 → lambda_erp-0.6.12}/api/chat.py +0 -0
- {lambda_erp-0.6.11 → lambda_erp-0.6.12}/api/demo_limits.py +0 -0
- {lambda_erp-0.6.11 → lambda_erp-0.6.12}/api/deps.py +0 -0
- {lambda_erp-0.6.11 → lambda_erp-0.6.12}/api/errors.py +0 -0
- {lambda_erp-0.6.11 → lambda_erp-0.6.12}/api/oauth.py +0 -0
- {lambda_erp-0.6.11 → lambda_erp-0.6.12}/api/pdf.py +0 -0
- {lambda_erp-0.6.11 → lambda_erp-0.6.12}/api/providers.py +0 -0
- {lambda_erp-0.6.11 → lambda_erp-0.6.12}/api/remarks_md.py +0 -0
- {lambda_erp-0.6.11 → lambda_erp-0.6.12}/api/routers/__init__.py +0 -0
- {lambda_erp-0.6.11 → lambda_erp-0.6.12}/api/routers/accounting.py +0 -0
- {lambda_erp-0.6.11 → lambda_erp-0.6.12}/api/routers/admin.py +0 -0
- {lambda_erp-0.6.11 → lambda_erp-0.6.12}/api/routers/analytics.py +0 -0
- {lambda_erp-0.6.11 → lambda_erp-0.6.12}/api/routers/bank_reconciliation.py +0 -0
- {lambda_erp-0.6.11 → lambda_erp-0.6.12}/api/routers/chat_api.py +0 -0
- {lambda_erp-0.6.11 → lambda_erp-0.6.12}/api/routers/documents.py +0 -0
- {lambda_erp-0.6.11 → lambda_erp-0.6.12}/api/routers/masters.py +0 -0
- {lambda_erp-0.6.11 → lambda_erp-0.6.12}/api/routers/proposals.py +0 -0
- {lambda_erp-0.6.11 → lambda_erp-0.6.12}/api/routers/reports.py +0 -0
- {lambda_erp-0.6.11 → lambda_erp-0.6.12}/api/routers/setup.py +0 -0
- {lambda_erp-0.6.11 → lambda_erp-0.6.12}/api/services.py +0 -0
- {lambda_erp-0.6.11 → lambda_erp-0.6.12}/api/templates/document.html +0 -0
- {lambda_erp-0.6.11 → lambda_erp-0.6.12}/api/templates/proposal.html +0 -0
- {lambda_erp-0.6.11 → lambda_erp-0.6.12}/docs/agents/README.md +0 -0
- {lambda_erp-0.6.11 → lambda_erp-0.6.12}/frontend/LICENSE +0 -0
- {lambda_erp-0.6.11 → lambda_erp-0.6.12}/frontend/README.md +0 -0
- {lambda_erp-0.6.11 → lambda_erp-0.6.12}/frontend/src/api/client.ts +0 -0
- {lambda_erp-0.6.11 → lambda_erp-0.6.12}/lambda_erp/__init__.py +0 -0
- {lambda_erp-0.6.11 → lambda_erp-0.6.12}/lambda_erp/accounting/__init__.py +0 -0
- {lambda_erp-0.6.11 → lambda_erp-0.6.12}/lambda_erp/accounting/bank_transaction.py +0 -0
- {lambda_erp-0.6.11 → lambda_erp-0.6.12}/lambda_erp/accounting/budget.py +0 -0
- {lambda_erp-0.6.11 → lambda_erp-0.6.12}/lambda_erp/accounting/chart_of_accounts.py +0 -0
- {lambda_erp-0.6.11 → lambda_erp-0.6.12}/lambda_erp/accounting/general_ledger.py +0 -0
- {lambda_erp-0.6.11 → lambda_erp-0.6.12}/lambda_erp/accounting/journal_entry.py +0 -0
- {lambda_erp-0.6.11 → lambda_erp-0.6.12}/lambda_erp/accounting/payment_entry.py +0 -0
- {lambda_erp-0.6.11 → lambda_erp-0.6.12}/lambda_erp/accounting/pos_invoice.py +0 -0
- {lambda_erp-0.6.11 → lambda_erp-0.6.12}/lambda_erp/accounting/purchase_invoice.py +0 -0
- {lambda_erp-0.6.11 → lambda_erp-0.6.12}/lambda_erp/accounting/revaluation.py +0 -0
- {lambda_erp-0.6.11 → lambda_erp-0.6.12}/lambda_erp/accounting/sales_invoice.py +0 -0
- {lambda_erp-0.6.11 → lambda_erp-0.6.12}/lambda_erp/accounting/setup/__init__.py +0 -0
- {lambda_erp-0.6.11 → lambda_erp-0.6.12}/lambda_erp/accounting/setup/engine.py +0 -0
- {lambda_erp-0.6.11 → lambda_erp-0.6.12}/lambda_erp/accounting/setup/pack.py +0 -0
- {lambda_erp-0.6.11 → lambda_erp-0.6.12}/lambda_erp/accounting/setup/packs/__init__.py +0 -0
- {lambda_erp-0.6.11 → lambda_erp-0.6.12}/lambda_erp/accounting/setup/packs/ch.py +0 -0
- {lambda_erp-0.6.11 → lambda_erp-0.6.12}/lambda_erp/accounting/setup/packs/de_common.py +0 -0
- {lambda_erp-0.6.11 → lambda_erp-0.6.12}/lambda_erp/accounting/setup/packs/de_skr03.py +0 -0
- {lambda_erp-0.6.11 → lambda_erp-0.6.12}/lambda_erp/accounting/setup/packs/de_skr04.py +0 -0
- {lambda_erp-0.6.11 → lambda_erp-0.6.12}/lambda_erp/accounting/setup/packs/generic.py +0 -0
- {lambda_erp-0.6.11 → lambda_erp-0.6.12}/lambda_erp/accounting/setup/profiles.py +0 -0
- {lambda_erp-0.6.11 → lambda_erp-0.6.12}/lambda_erp/accounting/setup/spine.py +0 -0
- {lambda_erp-0.6.11 → lambda_erp-0.6.12}/lambda_erp/accounting/subscription.py +0 -0
- {lambda_erp-0.6.11 → lambda_erp-0.6.12}/lambda_erp/buying/__init__.py +0 -0
- {lambda_erp-0.6.11 → lambda_erp-0.6.12}/lambda_erp/buying/purchase_order.py +0 -0
- {lambda_erp-0.6.11 → lambda_erp-0.6.12}/lambda_erp/controllers/__init__.py +0 -0
- {lambda_erp-0.6.11 → lambda_erp-0.6.12}/lambda_erp/controllers/currency.py +0 -0
- {lambda_erp-0.6.11 → lambda_erp-0.6.12}/lambda_erp/controllers/defaults.py +0 -0
- {lambda_erp-0.6.11 → lambda_erp-0.6.12}/lambda_erp/controllers/pricing_rule.py +0 -0
- {lambda_erp-0.6.11 → lambda_erp-0.6.12}/lambda_erp/controllers/taxes_and_totals.py +0 -0
- {lambda_erp-0.6.11 → lambda_erp-0.6.12}/lambda_erp/database.py +0 -0
- {lambda_erp-0.6.11 → lambda_erp-0.6.12}/lambda_erp/exceptions.py +0 -0
- {lambda_erp-0.6.11 → lambda_erp-0.6.12}/lambda_erp/hooks.py +0 -0
- {lambda_erp-0.6.11 → lambda_erp-0.6.12}/lambda_erp/model.py +0 -0
- {lambda_erp-0.6.11 → lambda_erp-0.6.12}/lambda_erp/selling/__init__.py +0 -0
- {lambda_erp-0.6.11 → lambda_erp-0.6.12}/lambda_erp/selling/proposal.py +0 -0
- {lambda_erp-0.6.11 → lambda_erp-0.6.12}/lambda_erp/selling/quotation.py +0 -0
- {lambda_erp-0.6.11 → lambda_erp-0.6.12}/lambda_erp/selling/sales_order.py +0 -0
- {lambda_erp-0.6.11 → lambda_erp-0.6.12}/lambda_erp/simulation.py +0 -0
- {lambda_erp-0.6.11 → lambda_erp-0.6.12}/lambda_erp/stock/__init__.py +0 -0
- {lambda_erp-0.6.11 → lambda_erp-0.6.12}/lambda_erp/stock/delivery_note.py +0 -0
- {lambda_erp-0.6.11 → lambda_erp-0.6.12}/lambda_erp/stock/purchase_receipt.py +0 -0
- {lambda_erp-0.6.11 → lambda_erp-0.6.12}/lambda_erp/stock/stock_entry.py +0 -0
- {lambda_erp-0.6.11 → lambda_erp-0.6.12}/lambda_erp/stock/stock_ledger.py +0 -0
- {lambda_erp-0.6.11 → lambda_erp-0.6.12}/lambda_erp/utils.py +0 -0
- {lambda_erp-0.6.11 → lambda_erp-0.6.12}/terraform/README.md +0 -0
|
@@ -19,7 +19,7 @@ from api.auth import router as auth_router, COOKIE_NAME, decode_token
|
|
|
19
19
|
from api.oauth import router as oauth_router
|
|
20
20
|
from api.attachments import router as attachments_router
|
|
21
21
|
from api.chat import chat_websocket, router as chat_router
|
|
22
|
-
from api.routers import admin, documents, masters, reports, setup as setup_router, bank_reconciliation, analytics, accounting, proposals, chat_api
|
|
22
|
+
from api.routers import admin, documents, masters, reports, setup as setup_router, bank_reconciliation, analytics, accounting, proposals, chat_api, mcp
|
|
23
23
|
|
|
24
24
|
|
|
25
25
|
def load_plugins() -> None:
|
|
@@ -114,6 +114,7 @@ app.include_router(accounting.router, prefix="/api")
|
|
|
114
114
|
app.include_router(admin.router, prefix="/api")
|
|
115
115
|
app.include_router(chat_router, prefix="/api")
|
|
116
116
|
app.include_router(chat_api.router, prefix="/api")
|
|
117
|
+
app.include_router(mcp.router, prefix="/api")
|
|
117
118
|
|
|
118
119
|
|
|
119
120
|
@app.get("/api/health")
|
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
"""MCP (Model Context Protocol) endpoint — the fine-grained ERP tool surface for
|
|
2
|
+
LLM agents (Claude, Codex, …) over MCP's Streamable HTTP transport.
|
|
3
|
+
|
|
4
|
+
Reuses everything the chat already has, so there's almost no new logic:
|
|
5
|
+
* schemas — build_tools() (the live, plugin-widened tool list).
|
|
6
|
+
* execution — the same TOOL_HANDLERS (which run validate()).
|
|
7
|
+
* auth — get_current_user: a Bearer API key acts AS its user at the key's
|
|
8
|
+
role, exactly like the REST API, gated by the same
|
|
9
|
+
`rest_api_enabled` Settings flag. No separate MCP credential.
|
|
10
|
+
|
|
11
|
+
Because the tool schemas come from the live registries, a plugin that registers
|
|
12
|
+
doctypes/masters (e.g. the internal CRM's lead/contact/activity) is exposed here
|
|
13
|
+
automatically — the MCP surface is modular by construction.
|
|
14
|
+
|
|
15
|
+
Transport: a single POST /api/mcp speaking JSON-RPC 2.0 (request → JSON result;
|
|
16
|
+
notifications → 202, no body). That's the minimum a tool-only server needs.
|
|
17
|
+
"""
|
|
18
|
+
import json
|
|
19
|
+
|
|
20
|
+
from fastapi import APIRouter, HTTPException, Request
|
|
21
|
+
from fastapi.responses import JSONResponse, Response
|
|
22
|
+
|
|
23
|
+
from lambda_erp import get_app_version
|
|
24
|
+
from api.auth import get_current_user
|
|
25
|
+
from api import chat as chat_mod
|
|
26
|
+
from api.chat import TOOL_HANDLERS, build_tools
|
|
27
|
+
|
|
28
|
+
router = APIRouter(tags=["mcp"])
|
|
29
|
+
|
|
30
|
+
PROTOCOL_VERSION = "2025-06-18"
|
|
31
|
+
|
|
32
|
+
# Chat-session-only tools have no meaning without a chat session — keep them out
|
|
33
|
+
# of the MCP surface (an MCP client owns its own context).
|
|
34
|
+
_EXCLUDE = {
|
|
35
|
+
"retrieve_chat_history",
|
|
36
|
+
"list_chat_attachments",
|
|
37
|
+
"retrieve_chat_attachment",
|
|
38
|
+
"create_custom_analytics_report",
|
|
39
|
+
"get_custom_analytics_report",
|
|
40
|
+
"update_custom_analytics_report",
|
|
41
|
+
"plan_company_setup",
|
|
42
|
+
"apply_company_setup",
|
|
43
|
+
}
|
|
44
|
+
# Mirror the REST permission model: reads are viewer+, writes are manager+,
|
|
45
|
+
# delete_master is admin-only (the handler also re-checks).
|
|
46
|
+
_WRITE = {
|
|
47
|
+
"create_document", "update_document", "submit_document", "cancel_document",
|
|
48
|
+
"discard_document", "convert_document", "create_master", "update_master",
|
|
49
|
+
}
|
|
50
|
+
_ADMIN = {"delete_master"}
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def _can_write(role) -> bool:
|
|
54
|
+
return role in ("manager", "admin", "public_manager")
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def _allowed(name: str, role) -> bool:
|
|
58
|
+
if name in _EXCLUDE:
|
|
59
|
+
return False
|
|
60
|
+
if name in _ADMIN:
|
|
61
|
+
return role == "admin"
|
|
62
|
+
if name in _WRITE:
|
|
63
|
+
return _can_write(role)
|
|
64
|
+
return True
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def _require_caller(request: Request) -> dict:
|
|
68
|
+
"""A valid Bearer API key is mandatory for MCP — no cookie/public fallback.
|
|
69
|
+
get_current_user then validates the key and applies `rest_api_enabled`."""
|
|
70
|
+
auth = request.headers.get("authorization", "")
|
|
71
|
+
if not auth.lower().startswith("bearer "):
|
|
72
|
+
raise HTTPException(status_code=401, detail="MCP requires a Bearer API key")
|
|
73
|
+
return get_current_user(request)
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def _tools(role) -> list:
|
|
77
|
+
out = []
|
|
78
|
+
for tool in build_tools():
|
|
79
|
+
fn = tool["function"]
|
|
80
|
+
if not _allowed(fn["name"], role):
|
|
81
|
+
continue
|
|
82
|
+
out.append({
|
|
83
|
+
"name": fn["name"],
|
|
84
|
+
"description": fn.get("description", ""),
|
|
85
|
+
"inputSchema": fn.get("parameters") or {"type": "object", "properties": {}},
|
|
86
|
+
})
|
|
87
|
+
return out
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def _call(name: str, args: dict, user: dict):
|
|
91
|
+
role = user.get("role")
|
|
92
|
+
if not _allowed(name, role):
|
|
93
|
+
return {"error": f"'{name}' is not available to a {role or 'viewer'} key."}
|
|
94
|
+
handlers = dict(TOOL_HANDLERS)
|
|
95
|
+
# delete_master needs the caller's role (admin-only); handled by the chat's
|
|
96
|
+
# role-aware variant.
|
|
97
|
+
handlers["delete_master"] = lambda a: chat_mod._handle_delete_master(a, user)
|
|
98
|
+
handler = handlers.get(name)
|
|
99
|
+
if handler is None:
|
|
100
|
+
raise KeyError(name)
|
|
101
|
+
return handler(args or {})
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def _rpc_error(mid, code: int, message: str) -> dict:
|
|
105
|
+
return {"jsonrpc": "2.0", "id": mid, "error": {"code": code, "message": message}}
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def _handle(msg: dict, user: dict):
|
|
109
|
+
"""Handle one JSON-RPC message. Returns a response dict, or None for a
|
|
110
|
+
notification (no `id`)."""
|
|
111
|
+
method = msg.get("method")
|
|
112
|
+
mid = msg.get("id")
|
|
113
|
+
is_notification = "id" not in msg
|
|
114
|
+
|
|
115
|
+
def result(payload):
|
|
116
|
+
return None if is_notification else {"jsonrpc": "2.0", "id": mid, "result": payload}
|
|
117
|
+
|
|
118
|
+
if method == "initialize":
|
|
119
|
+
return result({
|
|
120
|
+
"protocolVersion": PROTOCOL_VERSION,
|
|
121
|
+
"capabilities": {"tools": {"listChanged": False}},
|
|
122
|
+
"serverInfo": {"name": "lambda-erp", "version": get_app_version()},
|
|
123
|
+
})
|
|
124
|
+
if method in ("notifications/initialized", "notifications/cancelled"):
|
|
125
|
+
return None
|
|
126
|
+
if method == "ping":
|
|
127
|
+
return result({})
|
|
128
|
+
if method == "tools/list":
|
|
129
|
+
return result({"tools": _tools(user.get("role"))})
|
|
130
|
+
if method == "tools/call":
|
|
131
|
+
params = msg.get("params") or {}
|
|
132
|
+
name = params.get("name")
|
|
133
|
+
try:
|
|
134
|
+
out = _call(name, params.get("arguments") or {}, user)
|
|
135
|
+
except KeyError:
|
|
136
|
+
return _rpc_error(mid, -32602, f"Unknown tool: {name}")
|
|
137
|
+
except Exception as e: # noqa: BLE001 — surface as an MCP tool error, not a 500
|
|
138
|
+
out = {"error": str(e)}
|
|
139
|
+
is_error = isinstance(out, dict) and "error" in out
|
|
140
|
+
return result({
|
|
141
|
+
"content": [{"type": "text", "text": json.dumps(out, default=str, ensure_ascii=False)}],
|
|
142
|
+
"isError": is_error,
|
|
143
|
+
})
|
|
144
|
+
if is_notification:
|
|
145
|
+
return None
|
|
146
|
+
return _rpc_error(mid, -32601, f"Method not found: {method}")
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
@router.post("/mcp")
|
|
150
|
+
async def mcp_endpoint(request: Request):
|
|
151
|
+
user = _require_caller(request)
|
|
152
|
+
try:
|
|
153
|
+
body = await request.json()
|
|
154
|
+
except Exception:
|
|
155
|
+
return JSONResponse(_rpc_error(None, -32700, "Parse error"), status_code=400)
|
|
156
|
+
|
|
157
|
+
# JSON-RPC batch (a list) or a single message.
|
|
158
|
+
if isinstance(body, list):
|
|
159
|
+
responses = [r for r in (_handle(m, user) for m in body) if r is not None]
|
|
160
|
+
return JSONResponse(responses) if responses else Response(status_code=202)
|
|
161
|
+
resp = _handle(body, user)
|
|
162
|
+
if resp is None:
|
|
163
|
+
return Response(status_code=202)
|
|
164
|
+
return JSONResponse(resp)
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
File without changes
|
|
File without changes
|