Skip to main content
Glama
josephkamau32

ERP-lite MCP Server

README.md
# ERP-lite MCP Server

[![M8ven Score](https://m8ven.ai/badge/mcp/josephkamau32-erp-lite-mcp-k0xj5k?v=89fd5966925626c8b1691a7fa1f997f1)](https://m8ven.ai/mcp/josephkamau32-erp-lite-mcp-k0xj5k)

An enterprise-ready Model Context Protocol (MCP) server that exposes ERP functionalities to AI agents. Built as a portfolio project to demonstrate AI/ML engineering maturity, this project features a realistic data schema, a genuinely enforced human-in-the-loop approval workflow for write actions, and a full compliance-style audit trail.

## Overview

As enterprise AI adoption accelerates, providing LLMs with direct read/write access to ERPs is becoming essential. However, autonomous agents should not execute consequential operations (like creating purchase orders or altering system configurations) without human oversight.

This server demonstrates a robust "human-in-the-loop" pattern:
- The AI agent can query **open sales orders**, check **inventory levels**, and identify **low-stock items** using its read-only tools.
- When an agent decides to replenish stock, it can only propose a **purchase requisition** in a `pending_approval` state.
- **The agent cannot approve its own requisition.** When it creates the requisition, a secure `approval_token` is generated and saved to the database, but is *never* returned to the agent.
- A human administrator can view pending requisitions and their tokens via a dedicated, authenticated REST endpoint (`GET /admin/pending-requisitions`) that sits entirely outside the MCP tool surface — no agent can reach it.
- The human retrieves the token through that endpoint and supplies it back to approve the requisition, closing the loop with a real access-control check (constant-time token comparison), not just a naming convention.
- Every tool call — successful or failed — is written to an **append-only audit log**, with sensitive values like `approval_token` redacted before persistence.

## Architecture

```mermaid
graph TD
    Client[Claude Desktop / Custom Client] -- "MCP (stdio or Streamable HTTP with Bearer Token)" --> FastMCP[FastMCP Server]
    FastMCP -- "SQLAlchemy" --> DB[(PostgreSQL Database)]
    DB --> Seed[Seed Data]
    Admin[Human Admin] -- "X-Admin-Key (REST, outside MCP surface)" --> FastMCP
```

## Demo

https://github.com/user-attachments/assets/8584d882-ecb2-43f2-bd31-7a095bedd25c

*Checking low-stock inventory → agent proposing a purchase requisition → retrieving the approval token via the admin endpoint → approving the requisition → resulting audit log entry.*

## Quick Start (Docker)

First, create your `.env` file and set secure keys for both the MCP transport and the Admin API:

```bash
cp .env.example .env
# Edit .env and set MCP_API_KEY and ADMIN_API_KEY to secure, independent values
```

Then start the containers:

```bash
docker compose up
```

This spins up:
- A PostgreSQL database, health-checked, pre-seeded with realistic enterprise data for sales orders, inventory, requisitions, and an empty audit log table.
- The MCP server, exposing the Streamable HTTP transport on port 8000, protected with `MCP_API_KEY`.

> **⚠️ Upgrading from a previous version?** `seed_data.sql` only runs via `docker-entrypoint-initdb.d` on a **fresh, empty** Postgres volume. If you already have a `pgdata` volume from an earlier run, new tables (like `audit_log`) won't be created automatically. To pick up schema changes:
> ```bash
> docker compose down -v
> docker compose up --build
> ```

### Network Binding & Docker vs. Host Execution

- **Local Host Execution (Safe Default):** By default, `server.py` binds to loopback (`127.0.0.1`), ensuring the server is not reachable from other machines on your local network unless explicitly configured.
- **Docker Execution (`MCP_HOST=0.0.0.0`):** `docker-compose.yml` explicitly sets `MCP_HOST=0.0.0.0` in the container environment. This is **required** inside Docker because binding to `127.0.0.1` inside a container isolates it entirely within the container's private network namespace, making it unreachable via Docker's port mapping (`8000:8000`).
- **⚠️ Warning:** Never expose `MCP_HOST=0.0.0.0` directly to public networks or untrusted LANs without verifying that transport authentication (`MCP_API_KEY`) or an authenticating reverse proxy is active.

## Tools Exposed (MCP)

| Tool | Type | Description |
|---|---|---|
| `get_open_orders(status="open", limit=20)` | Read | Retrieves sales orders by status. |
| `check_inventory(material_id)` | Read | Checks inventory level and computes whether it's below the reorder point. |
| `get_low_stock_items()` | Read | Identifies all inventory items below their reorder threshold. |
| `create_requisition(material_id, quantity, requested_by)` | **Write** | Creates a purchase requisition in `pending_approval` state; silently generates and stores an `approval_token`. |
| `approve_pending_requisition(requisition_id, approved_by, approval_token)` | **Write** | Approves a pending requisition — only succeeds with the correct token, sourced from the human-only admin endpoint below. |

All tool calls, successful or failed, are recorded in the append-only audit log. Sensitive arguments (e.g. `approval_token`) are redacted before being persisted.

## Authentication & Access Boundaries

The server enforces strict, independent access controls across surfaces:

1. **MCP Streamable HTTP Transport (`/mcp`):**
   - Protected by `MCP_API_KEY` via pure ASGI middleware.
   - Accepts either `Authorization: Bearer <MCP_API_KEY>` or `X-MCP-API-Key: <MCP_API_KEY>`.
   - Rejects unauthenticated or invalid requests with HTTP 401.
   - **Fail-closed:** If `MCP_API_KEY` is unset in the server environment, all requests to `/mcp` are rejected with HTTP 500.

2. **Admin REST Endpoints (outside MCP tool surface):**
   - `GET /admin/pending-requisitions` — lists pending requisitions with approval tokens.
   - `GET /admin/audit-log?limit=50` — returns the most recent audit log entries, newest first (`limit` default 50, max 500).
   - Protected by `ADMIN_API_KEY` via `X-Admin-Key` header.
   - **Fail-closed:** HTTP 500 if `ADMIN_API_KEY` is not set.

3. **Claude Desktop / Local Stdio Transport:**
   - When run in default stdio mode (`python -m src.server`), communication occurs over a local operating system pipe. No `MCP_API_KEY` is required because stdio is not exposed to the network.

## Testing Locally

### Custom Python client (Streamable HTTP with Bearer Auth)

Set your `MCP_API_KEY` environment variable or pass `--api-key`:

```bash
# Using environment variable
export MCP_API_KEY="your_secret_mcp_api_key_here"
python client.py

# Or via CLI argument
python client.py --api-key "your_secret_mcp_api_key_here"
```

### Claude Desktop (stdio transport)

Because `stdio` is a direct local process pipe, it does not require network credentials:

```json
{
  "mcpServers": {
    "erp-lite": {
      "command": "/absolute/path/to/erp-lite-mcp/.venv/Scripts/python.exe",
      "args": ["-m", "src.server"],
      "env": {
        "PYTHONUNBUFFERED": "1",
        "PYTHONIOENCODING": "utf-8",
        "PYTHONPATH": "/absolute/path/to/erp-lite-mcp"
      }
    }
  }
}
```

> **Windows note:** Claude Desktop's sandboxing frequently fails to resolve `uv run` relative module paths correctly. Using the absolute path to `.venv\Scripts\python.exe`, with `PYTHONPATH` set explicitly, is the reliable configuration.

## Running Unit Tests

```bash
uv run pytest
```

Covers tool logic, the full requisition lifecycle (create → pending → wrong-token rejection → correct-token approval), and audit logging (including token redaction and failed-attempt capture).

> All tests run against an **in-memory SQLite database** (`SessionLocal` monkeypatched in fixtures) — no Postgres or Docker required. CI (GitHub Actions) uses the same approach, so no database service is configured in the workflow.

## CI

Every push and pull request to `main` runs the full `pytest` suite via GitHub Actions.

## Design Decisions Worth Knowing

- **The approval gate is an access-control mechanism, not a naming convention.** `create_requisition` never returns the token to the caller; `approve_pending_requisition` performs a constant-time comparison (`secrets.compare_digest`) against the stored value, so there's no timing side-channel and no path by which the same agent session can complete both halves of the workflow on its own.
- **The admin surface is intentionally separate from the MCP tool surface.** Tokens and audit history are retrievable only via authenticated REST routes an agent has no tool access to — the trust boundary is structural, not just a prompt-level instruction telling the agent not to self-approve.
- **Audit logging is fire-and-forget but not silent.** A logging failure never blocks a real tool response, but is written to server stderr, so an audit pipeline failure is observable in ops rather than invisible.

## Future Enhancements

- **Token security:** the `approval_token` is currently stored as plaintext so the admin endpoint can serve it directly. A production version would deliver it via a side channel (email/Slack) at creation time and store only a salted hash, never exposing plaintext through any API.
- **Authentication & RBAC:** the admin routes currently use a shared-secret `ADMIN_API_KEY`, while the MCP transport uses `MCP_API_KEY`. Production use would expand this to identity-based OAuth/OIDC and granular role checks.
- ~~**Transport-level authentication**~~ ✅ **Implemented.** Streamable HTTP transport at `/mcp` requires `MCP_API_KEY` (fail-closed, constant-time compare). Default bind changed to `127.0.0.1`. (Reported by Shiqiang Chen, see [SECURITY.md](SECURITY.md)).
- ~~**Audit logging**~~ ✅ **Implemented.** Every tool call is recorded in an append-only `audit_log` table with tool name, redacted arguments, result (including failures), and timestamp — accessible via `GET /admin/audit-log`.
- **Policy search resource:** expose procurement policy documents to the agent as an MCP Resource with semantic search, so the agent can check policy context before proposing a requisition.

## Security

Please see [SECURITY.md](SECURITY.md) for vulnerability reporting guidelines and details on past security advisories (including GH-001 responsible disclosure credit to Shiqiang Chen).

## License

This project is licensed under the MIT License - see the [LICENSE](LICENSE) file for details.

TDQS

A3.6/5.0

Scored across 5 tools

Disambiguation5/5

Each tool targets a distinct resource and action: reading sales orders, checking inventory, listing low stock, creating requisitions, and approving requisitions. There is no overlap or ambiguity between tool purposes.

Naming Consistency5/5

All names follow a clear verb_noun pattern in snake_case (get_open_orders, check_inventory, get_low_stock_items, create_requisition, approve_pending_requisition). The mix of 'get' and 'check' is minor and still consistent as retrieval verbs.

Tool Count5/5

Five tools is well-scoped for an ERP-lite server covering sales, inventory, and purchasing. Each tool fills a distinct role, and the count is neither too thin nor bloated.

Completeness2/5

The tool surface has significant gaps: there is no way to view order details, update or close orders, adjust inventory, list pending requisitions, or create purchase orders after approval. The workflow ends abruptly after requisition approval.

Maintenance

ActivityMaintained
ResponsivenessWithin a week