mcp-ops-server
by LaysonDilson
README.md
# mcp-ops-server
A reference implementation of an **MCP server that gives an LLM agent real,
safe write access to a business database** — orders arriving from sales
channels, production state per item, and FIFO material stock.
It is deliberately small (about 1,000 lines including tests) and deliberately
opinionated. Every design decision below came from running a system shaped
like this one in a real operation, where a wrong tool call costs money rather
than a red test.
```bash
pip install -r requirements.txt
python -m ops # runnable demo, no MCP client needed
pytest # 29 tests
```
---
## What this demonstrates
Most "AI integration" projects wire a chatbot on top of a REST API and stop.
The hard part is not calling the model — it is designing a tool surface that
an autonomous agent cannot corrupt, over data that stays correct when the
same file gets imported for the fourth time.
Four rules carry most of that weight:
### 1. State is derived, never assigned
An order's status is computed from its items every time an item moves:
| Derived | meaning |
|---|---|
| `open` | every item still `todo` |
| `in_progress` | some item started |
| `ready` | every item `done` |
Only genuine human decisions — `packing`, `shipped`, `cancelled` — are
written directly, and derivation **refuses to overwrite them**. There is no
code path where an agent sets `ready` on an order whose items are untouched,
because there is no code path where anything sets `ready` at all.
### 2. Re-importing a file must never destroy work
Channel exports overlap: today's file contains yesterday's orders. Ingestion
upserts on `(channel, external_id)`, and item rows are replaced wholesale —
the export is the source of truth for *what* was ordered. But production
state is snapshotted per SKU first and re-applied afterwards.
Without that snapshot, the 6am import silently resets everything finished the
day before. When quantity *drops* between exports, the most advanced units are
the ones kept: throwing away finished work is worse than throwing away pending
work.
```python
def test_reimport_preserves_production_state(conn):
... # tests/test_ops.py
```
### 3. Header data is not row data
Marketplace exports repeat the order total and the fee on **every item line**.
Summing them multiplies revenue by the item count — the most common and most
expensive bug in this category of importer. Fees are read once per order
group, and there is a test that fails if that ever regresses.
An *actual* fee, once known, never regresses to an estimate.
### 4. Balances are sums, not counters
Stock has no stored balance. Receipts create FIFO lots; consumption walks them
oldest-first and returns what the material really cost. The balance is
`SUM(in) - SUM(out)` over an append-only movement log, so it cannot drift from
its own history. Insufficient stock raises rather than going negative or
consuming partially.
---
## The tool surface
Eleven tools. The interesting part is not the count — it is that the
docstrings are longer than the functions.
| Tool | Purpose |
|---|---|
| `board_list` | open orders with items, most urgent deadline first |
| `item_set_state` | move one item `todo → doing → done`, optionally drawing material |
| `order_advance` | apply a manual status, refusing transitions that skip production |
| `ingest_csv` | idempotent import of a channel export |
| `stock_status` / `stock_receive` / `stock_consume` | derived balance, FIFO lots, real cost |
| `material_upsert` / `sku_upsert` | catalogue |
| `pending_recipes` | SKUs seen in orders with no recipe yet, most-sold first |
| `audit_tail` | who changed what, when |
### Why the docstrings are long
A tool's docstring is not a comment. It is the **entire interface contract the
model sees at call time**. The model cannot read the source, cannot inspect the
schema, and cannot ask a clarifying question before choosing arguments. Every
ambiguity left in a docstring becomes a wrong tool call in production.
What actually works:
- say what the tool is **for**, not only what it does;
- name the units and the exact allowed values;
- state what it **refuses** and why;
- point at the tool to call instead when this one is the wrong choice.
Compare a typical generated docstring with the one in `mcp_server.py`:
```python
# Typical — technically accurate, operationally useless
def stock_receive(material_code: str, qty: float, unit_cost: float) -> dict:
"""Receive stock for a material."""
# What the model actually needs
def stock_receive(material_code: str, qty: float, unit_cost: float) -> dict:
"""Register a purchase as a new FIFO lot.
Args:
material_code: must already exist — create it with `material_upsert`.
qty: amount received, in the material's own unit (g, ml, unit).
unit_cost: price per that same unit, not per package. If you bought a
1kg spool for 90.00 and the unit is grams, unit_cost is 0.09.
"""
```
The second version is why an agent stops recording a 1kg spool as costing
90.00 per gram.
### Safety rails
- Destructive and terminal transitions are validated, not trusted:
`ready → packing → shipped` is the only path to shipped.
- Every mutation is written to `audit_log` with its **actor** (`agent`,
`human`, `ingest`), so agent actions can always be separated from human ones
after the fact.
- A missing recipe never blocks production; it only leaves costing incomplete
and puts the SKU at the top of `pending_recipes`. Catalogue maintenance is a
queue you drain by frequency, not a hundred forms you fill before starting.
---
## Architecture
```
ops/
├── db.py schema + forward-only idempotent migrations
├── ingest.py CSV parsing: dirty money, tabs, locale dates, grouping
├── services.py derived state, idempotent upsert, FIFO, audit
├── mcp_server.py the tool surface (docstrings are the contract)
└── __main__.py runnable demo
tests/test_ops.py 29 tests, one per way this goes wrong
```
Dependencies: `mcp` and `pytest`. Storage is SQLite through the standard
library — no ORM, because at this size the schema *is* the documentation.
`migrate()` runs on every connection and is safe to call repeatedly: a fresh
database and one from three versions ago converge to the same shape. Table
reshaping happens in a `_pre_migrate` hook that runs *before* the schema
script, because creating an index on a column that does not exist yet is the
classic way to brick a startup on real data.
### Registering it with an MCP client
```bash
claude mcp add ops -- python -m ops.mcp_server
```
Built against `mcp >= 2.0`, where the server class is `MCPServer`. On `mcp 1.x`
the equivalent import is `from mcp.server.fastmcp import FastMCP`.
---
## What is deliberately absent
- **No web UI.** The tools are the product; a UI would be the least
interesting 400 lines here.
- **No auth or multi-tenancy.** Single operator by design. Adding tenancy
would mean a tenant filter on every query — a real concern, but a different
demonstration.
- **No LLM calls anywhere in this codebase.** Arithmetic is deterministic and
belongs in code. The model's job is to decide *what* to do and to explain the
result — not to add up a stock balance.
---
## Provenance
Extracted from the design of a private system I built and run daily for my own
manufacturing operation — a 35-tool MCP server that ingests marketplace order
exports, schedules production against shipping deadlines, and tracks material
consumption. This repository reimplements that architecture on a neutral
domain with synthetic data; it shares no code, data or business logic with the
original.
Built by [Layson Santos](https://github.com/LaysonDilson) — 13 years of Java
and distributed systems, now building systems where the LLM is part of the
architecture rather than a chatbot bolted on top.
MIT licensed.
This server cannot be deployed
Maintenance
ActivitySlowing
ResponsivenessNo issues