diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e8d4026..bc7e1f2 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -142,6 +142,14 @@ jobs: LAMBDA_ERP_TEST_DB: postgresql://postgres:postgres@localhost:5432/lambda_test run: python -m tests.test_adjacent + - name: MCP endpoint — SQLite (temp file) + run: python -m tests.test_mcp + + - name: MCP endpoint — PostgreSQL + env: + LAMBDA_ERP_TEST_DB: postgresql://postgres:postgres@localhost:5432/lambda_test + run: python -m tests.test_mcp + - name: List search — SQLite (temp file) run: python -m tests.test_search diff --git a/CHANGELOG.md b/CHANGELOG.md index 3271b4f..e26539d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,22 @@ semver-governed public surface — a breaking change to a seam is a major bump. ## [Unreleased] +## [0.6.12] - 2026-07-31 + +### Added +- **MCP endpoint (`POST /api/mcp`)** — the ERP's fine-grained tool surface for + LLM agents (Claude, Codex, …) over MCP's Streamable HTTP (JSON-RPC 2.0). It + reuses the chat's `build_tools()` schemas and `TOOL_HANDLERS` (so every write + runs `validate()`), and authenticates with the **same Bearer API keys as REST** + via `get_current_user` — the key acts as its user at the key's role (viewer = + read-only, manager = writes, admin = deletes), gated by the same + `rest_api_enabled` flag. No separate credential. Chat-session-only tools are + excluded. Because the tool list is built from the live registries, a plugin's + doctypes/masters (e.g. the internal CRM) are exposed automatically — the MCP + surface is modular by construction. The API-keys settings page shows the MCP + URL and ready-to-paste Claude/Codex config right after a key is created. + `tests/test_mcp.py` covers it (SQLite + Postgres in CI). + ## [0.6.11] - 2026-07-30 ### Added diff --git a/api/main.py b/api/main.py index 834323b..11f2174 100644 --- a/api/main.py +++ b/api/main.py @@ -19,7 +19,7 @@ from api.oauth import router as oauth_router from api.attachments import router as attachments_router from api.chat import chat_websocket, router as chat_router -from api.routers import admin, documents, masters, reports, setup as setup_router, bank_reconciliation, analytics, accounting, proposals, chat_api +from api.routers import admin, documents, masters, reports, setup as setup_router, bank_reconciliation, analytics, accounting, proposals, chat_api, mcp def load_plugins() -> None: @@ -114,6 +114,7 @@ async def lifespan(app: FastAPI): app.include_router(admin.router, prefix="/api") app.include_router(chat_router, prefix="/api") app.include_router(chat_api.router, prefix="/api") +app.include_router(mcp.router, prefix="/api") @app.get("/api/health") diff --git a/api/routers/mcp.py b/api/routers/mcp.py new file mode 100644 index 0000000..bcaad00 --- /dev/null +++ b/api/routers/mcp.py @@ -0,0 +1,164 @@ +"""MCP (Model Context Protocol) endpoint — the fine-grained ERP tool surface for +LLM agents (Claude, Codex, …) over MCP's Streamable HTTP transport. + +Reuses everything the chat already has, so there's almost no new logic: + * schemas — build_tools() (the live, plugin-widened tool list). + * execution — the same TOOL_HANDLERS (which run validate()). + * auth — get_current_user: a Bearer API key acts AS its user at the key's + role, exactly like the REST API, gated by the same + `rest_api_enabled` Settings flag. No separate MCP credential. + +Because the tool schemas come from the live registries, a plugin that registers +doctypes/masters (e.g. the internal CRM's lead/contact/activity) is exposed here +automatically — the MCP surface is modular by construction. + +Transport: a single POST /api/mcp speaking JSON-RPC 2.0 (request → JSON result; +notifications → 202, no body). That's the minimum a tool-only server needs. +""" +import json + +from fastapi import APIRouter, HTTPException, Request +from fastapi.responses import JSONResponse, Response + +from lambda_erp import get_app_version +from api.auth import get_current_user +from api import chat as chat_mod +from api.chat import TOOL_HANDLERS, build_tools + +router = APIRouter(tags=["mcp"]) + +PROTOCOL_VERSION = "2025-06-18" + +# Chat-session-only tools have no meaning without a chat session — keep them out +# of the MCP surface (an MCP client owns its own context). +_EXCLUDE = { + "retrieve_chat_history", + "list_chat_attachments", + "retrieve_chat_attachment", + "create_custom_analytics_report", + "get_custom_analytics_report", + "update_custom_analytics_report", + "plan_company_setup", + "apply_company_setup", +} +# Mirror the REST permission model: reads are viewer+, writes are manager+, +# delete_master is admin-only (the handler also re-checks). +_WRITE = { + "create_document", "update_document", "submit_document", "cancel_document", + "discard_document", "convert_document", "create_master", "update_master", +} +_ADMIN = {"delete_master"} + + +def _can_write(role) -> bool: + return role in ("manager", "admin", "public_manager") + + +def _allowed(name: str, role) -> bool: + if name in _EXCLUDE: + return False + if name in _ADMIN: + return role == "admin" + if name in _WRITE: + return _can_write(role) + return True + + +def _require_caller(request: Request) -> dict: + """A valid Bearer API key is mandatory for MCP — no cookie/public fallback. + get_current_user then validates the key and applies `rest_api_enabled`.""" + auth = request.headers.get("authorization", "") + if not auth.lower().startswith("bearer "): + raise HTTPException(status_code=401, detail="MCP requires a Bearer API key") + return get_current_user(request) + + +def _tools(role) -> list: + out = [] + for tool in build_tools(): + fn = tool["function"] + if not _allowed(fn["name"], role): + continue + out.append({ + "name": fn["name"], + "description": fn.get("description", ""), + "inputSchema": fn.get("parameters") or {"type": "object", "properties": {}}, + }) + return out + + +def _call(name: str, args: dict, user: dict): + role = user.get("role") + if not _allowed(name, role): + return {"error": f"'{name}' is not available to a {role or 'viewer'} key."} + handlers = dict(TOOL_HANDLERS) + # delete_master needs the caller's role (admin-only); handled by the chat's + # role-aware variant. + handlers["delete_master"] = lambda a: chat_mod._handle_delete_master(a, user) + handler = handlers.get(name) + if handler is None: + raise KeyError(name) + return handler(args or {}) + + +def _rpc_error(mid, code: int, message: str) -> dict: + return {"jsonrpc": "2.0", "id": mid, "error": {"code": code, "message": message}} + + +def _handle(msg: dict, user: dict): + """Handle one JSON-RPC message. Returns a response dict, or None for a + notification (no `id`).""" + method = msg.get("method") + mid = msg.get("id") + is_notification = "id" not in msg + + def result(payload): + return None if is_notification else {"jsonrpc": "2.0", "id": mid, "result": payload} + + if method == "initialize": + return result({ + "protocolVersion": PROTOCOL_VERSION, + "capabilities": {"tools": {"listChanged": False}}, + "serverInfo": {"name": "lambda-erp", "version": get_app_version()}, + }) + if method in ("notifications/initialized", "notifications/cancelled"): + return None + if method == "ping": + return result({}) + if method == "tools/list": + return result({"tools": _tools(user.get("role"))}) + if method == "tools/call": + params = msg.get("params") or {} + name = params.get("name") + try: + out = _call(name, params.get("arguments") or {}, user) + except KeyError: + return _rpc_error(mid, -32602, f"Unknown tool: {name}") + except Exception as e: # noqa: BLE001 — surface as an MCP tool error, not a 500 + out = {"error": str(e)} + is_error = isinstance(out, dict) and "error" in out + return result({ + "content": [{"type": "text", "text": json.dumps(out, default=str, ensure_ascii=False)}], + "isError": is_error, + }) + if is_notification: + return None + return _rpc_error(mid, -32601, f"Method not found: {method}") + + +@router.post("/mcp") +async def mcp_endpoint(request: Request): + user = _require_caller(request) + try: + body = await request.json() + except Exception: + return JSONResponse(_rpc_error(None, -32700, "Parse error"), status_code=400) + + # JSON-RPC batch (a list) or a single message. + if isinstance(body, list): + responses = [r for r in (_handle(m, user) for m in body) if r is not None] + return JSONResponse(responses) if responses else Response(status_code=202) + resp = _handle(body, user) + if resp is None: + return Response(status_code=202) + return JSONResponse(resp) diff --git a/frontend/package-lock.json b/frontend/package-lock.json index d8560a8..766f503 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -1,12 +1,12 @@ { "name": "@lambda-development/erp-core", - "version": "0.6.11", + "version": "0.6.12", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@lambda-development/erp-core", - "version": "0.6.11", + "version": "0.6.12", "license": "Apache-2.0", "dependencies": { "@fontsource/inter": "^5.2.8" diff --git a/frontend/package.json b/frontend/package.json index 40dbf88..6fd965e 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,6 +1,6 @@ { "name": "@lambda-development/erp-core", - "version": "0.6.11", + "version": "0.6.12", "description": "Frontend core of Lambda ERP — app shell, document/master pages, chat UI, reports, and extension registries.", "license": "Apache-2.0", "repository": { diff --git a/frontend/src/pages/admin/settings.tsx b/frontend/src/pages/admin/settings.tsx index 2900450..a8e7726 100644 --- a/frontend/src/pages/admin/settings.tsx +++ b/frontend/src/pages/admin/settings.tsx @@ -499,6 +499,16 @@ function ApiKeysSection({ ownRole }: { ownRole: string }) { (r) => ROLE_RANK[r] <= (ROLE_RANK[ownRole] ?? 1), ); + // The same key doubles as MCP auth (POST /api/mcp). Show ready-to-paste + // config for the common agents right after the token, while it's still visible. + const mcpUrl = `${window.location.origin}/api/mcp`; + const claudeSnippet = newToken + ? `claude mcp add --transport http lambda-erp ${mcpUrl} \\\n --header "Authorization: Bearer ${newToken}"` + : ""; + const codexSnippet = newToken + ? `# ~/.codex/config.toml\n[mcp_servers.lambda-erp]\nurl = "${mcpUrl}"\nhttp_headers = { Authorization = "Bearer ${newToken}" }` + : ""; + const { data: keys } = useQuery({ queryKey: ["api-keys"], queryFn: () => api.getApiKeys(), @@ -588,6 +598,27 @@ function ApiKeysSection({ ownRole }: { ownRole: string }) { > {t("settings.chatApiDismiss")} + + {/* The same key is also an MCP endpoint — reuses this key's role. */} +
+

+ {t("settings.mcpNote", { + defaultValue: + "This key is also an MCP endpoint — connect an AI agent (Claude, Codex) to it. It reuses this key's role (a viewer key = read-only).", + })} +

+ + {mcpUrl} + +
+ Claude +
{claudeSnippet}
+
+
+ Codex +
{codexSnippet}
+
+
)} diff --git a/pyproject.toml b/pyproject.toml index 3c430ba..a4bb060 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "lambda-erp" -version = "0.6.11" +version = "0.6.12" description = "Core ERP logic - accounting, sales, purchasing, inventory" readme = "README.md" license = "Apache-2.0" diff --git a/tests/test_mcp.py b/tests/test_mcp.py new file mode 100644 index 0000000..4debf72 --- /dev/null +++ b/tests/test_mcp.py @@ -0,0 +1,131 @@ +#!/usr/bin/env python3 +"""Tests for the MCP endpoint (POST /api/mcp) — the fine-grained ERP tool surface +for LLM agents, over JSON-RPC 2.0. + +Reuses the chat's tools + handlers and the Bearer-key auth: a key acts as its +user at the key's role, gated by `rest_api_enabled`. Covers initialize, +tools/list (role-scoped), tools/call (read + a write round-trip), viewer +write-denial, notifications, and the no-key rejection. + +Run: python -m tests.test_mcp + LAMBDA_ERP_TEST_DB=postgresql://... python -m tests.test_mcp +""" +import os +import sys + + +def _reset_db(): + url = os.environ.get("LAMBDA_ERP_TEST_DB") + if not url: + import tempfile + fd, path = tempfile.mkstemp(suffix=".db", prefix="lambda_mcp_test_") + os.close(fd) + return path + import psycopg + with psycopg.connect(url, autocommit=True) as conn: + conn.execute("DROP SCHEMA public CASCADE") + conn.execute("CREATE SCHEMA public") + return url + + +def check_mcp(): + db_path = _reset_db() + backend = "postgres" if db_path.startswith("postgres") else "sqlite (temp file)" + os.environ["LAMBDA_ERP_DB"] = db_path + os.environ["LAMBDA_ERP_AUTO_DEMO"] = "0" + os.environ.setdefault("LAMBDA_ERP_PLUGINS", "") + os.environ.setdefault("JWT_SECRET_KEY", "test-secret-not-for-prod") + os.environ.setdefault("OPENAI_API_KEY", "sk-test-not-used") + + from fastapi.testclient import TestClient + from api.main import app + + with TestClient(app) as client: + r = client.post("/api/auth/register", + json={"email": "admin@example.com", "full_name": "Admin", + "password": "test-password-123"}) + assert r.status_code == 200 and r.json()["role"] == "admin", r.text[:300] + mgr = client.post("/api/auth/api-keys", json={"name": "agent", "role": "manager"}).json() + vwr = client.post("/api/auth/api-keys", json={"name": "ro", "role": "viewer"}).json() + client.put("/api/auth/settings", json={"rest_api_enabled": "1"}) + + mgr_h = {"Authorization": f"Bearer {mgr['token']}"} + vwr_h = {"Authorization": f"Bearer {vwr['token']}"} + + def rpc(client, body, headers): + return client.post("/api/mcp", json=body, headers=headers) + + with TestClient(app) as api: # no cookie — Bearer is the only credential + # No key -> 401 (never the public fallback). + assert api.post("/api/mcp", json={"jsonrpc": "2.0", "id": 1, "method": "ping"}).status_code == 401 + + # initialize + r = rpc(api, {"jsonrpc": "2.0", "id": 1, "method": "initialize"}, mgr_h) + assert r.status_code == 200, r.text[:200] + init = r.json()["result"] + assert init["protocolVersion"] and init["serverInfo"]["name"] == "lambda-erp", init + assert init["capabilities"].get("tools") is not None, init + + # notification (no id) -> 202, no body + assert rpc(api, {"jsonrpc": "2.0", "method": "notifications/initialized"}, mgr_h).status_code == 202 + + # tools/list — manager sees writes; viewer does not. + mgr_tools = {t["name"] for t in rpc(api, {"jsonrpc": "2.0", "id": 2, "method": "tools/list"}, mgr_h).json()["result"]["tools"]} + vwr_tools = {t["name"] for t in rpc(api, {"jsonrpc": "2.0", "id": 2, "method": "tools/list"}, vwr_h).json()["result"]["tools"]} + assert {"list_documents", "get_document", "create_document"} <= mgr_tools, mgr_tools + assert "list_documents" in vwr_tools and "create_document" not in vwr_tools, vwr_tools + assert "delete_master" not in mgr_tools, "delete_master is admin-only" + # Chat-session tools are excluded from MCP. + assert "retrieve_chat_history" not in mgr_tools + + # Each tool carries an MCP inputSchema. + one = next(t for t in rpc(api, {"jsonrpc": "2.0", "id": 3, "method": "tools/list"}, mgr_h).json()["result"]["tools"]) + assert one["inputSchema"]["type"] == "object", one + + # tools/call — a read. + call = {"jsonrpc": "2.0", "id": 4, "method": "tools/call", + "params": {"name": "list_documents", "arguments": {"doctype": "quotation"}}} + res = rpc(api, call, mgr_h).json()["result"] + assert res["isError"] is False and res["content"][0]["type"] == "text", res + + # tools/call — a write round-trip (manager creates a customer master). + create = {"jsonrpc": "2.0", "id": 5, "method": "tools/call", + "params": {"name": "create_master", + "arguments": {"master_type": "customer", "data": {"customer_name": "MCP Test AG"}}}} + out = rpc(api, create, mgr_h).json()["result"] + assert out["isError"] is False, out + + # Viewer is denied writes at call time too (defence in depth). + denied = rpc(api, {"jsonrpc": "2.0", "id": 6, "method": "tools/call", + "params": {"name": "create_master", "arguments": {"master_type": "customer", "data": {"customer_name": "x"}}}}, vwr_h).json()["result"] + assert denied["isError"] is True, denied + + # Unknown tool -> JSON-RPC error. + err = rpc(api, {"jsonrpc": "2.0", "id": 7, "method": "tools/call", "params": {"name": "nope"}}, mgr_h).json() + assert err.get("error", {}).get("code") == -32602, err + # Unknown method -> method-not-found. + err2 = rpc(api, {"jsonrpc": "2.0", "id": 8, "method": "bogus/method"}, mgr_h).json() + assert err2.get("error", {}).get("code") == -32601, err2 + + print(f" [mcp] initialize/tools-list/tools-call + role scoping OK on {backend}") + + if not db_path.startswith("postgres"): + for suffix in ("", "-wal", "-shm"): + try: + os.unlink(db_path + suffix) + except OSError: + pass + + +def main(): + print("MCP endpoint checks") + check_mcp() + print("All MCP checks passed.") + + +if __name__ == "__main__": + try: + main() + except AssertionError as e: + print(f"\nFAILED: {e}") + sys.exit(1)