Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
16 changes: 16 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
3 changes: 2 additions & 1 deletion api/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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")
Expand Down
164 changes: 164 additions & 0 deletions api/routers/mcp.py
Original file line number Diff line number Diff line change
@@ -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)
4 changes: 2 additions & 2 deletions frontend/package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion frontend/package.json
Original file line number Diff line number Diff line change
@@ -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": {
Expand Down
31 changes: 31 additions & 0 deletions frontend/src/pages/admin/settings.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 <origin>/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(),
Expand Down Expand Up @@ -588,6 +598,27 @@ function ApiKeysSection({ ownRole }: { ownRole: string }) {
>
{t("settings.chatApiDismiss")}
</button>

{/* The same key is also an MCP endpoint — reuses this key's role. */}
<div className="mt-3 border-t border-amber-200 pt-3">
<p className="text-xs text-amber-800">
{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).",
})}
</p>
<code className="mt-1 block break-all rounded bg-surface px-2 py-1 font-mono text-xs text-fg">
{mcpUrl}
</code>
<details className="mt-2 text-xs text-amber-900">
<summary className="cursor-pointer font-medium">Claude</summary>
<pre className="mt-1 overflow-x-auto rounded bg-surface p-2 font-mono text-[11px] leading-relaxed text-fg">{claudeSnippet}</pre>
</details>
<details className="mt-1 text-xs text-amber-900">
<summary className="cursor-pointer font-medium">Codex</summary>
<pre className="mt-1 overflow-x-auto rounded bg-surface p-2 font-mono text-[11px] leading-relaxed text-fg">{codexSnippet}</pre>
</details>
</div>
</div>
)}

Expand Down
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
@@ -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"
Expand Down
Loading