Skip to main content
Glama
noelaliaga

mcp-logistica

by noelaliaga

mcp-logistica: a WMS MCP server where the agent reads freely and writes narrowly

An MCP server in Python that lets an AI agent query and operate a warehouse management system (WMS). The agent can find stalled orders, check stock per location, read an order, change its status with a reason and add a note. The repo also includes a minimal tool-calling agent loop and an offline evaluation harness, so the rules can be checked end to end without calling a model API.

The interesting part is what the agent cannot do:

  • Writes are off by default and limited to two fields.

  • A value outside a closed vocabulary is rejected, not corrected.

  • A name never selects the order to write: the agent needs an order id.

  • Every write is recorded by database triggers, which also catch writes made by scripts that never go through this server.

Python 3.11+, the official mcp SDK (FastMCP), SQLite, mypy --strict, ruff and pytest. GitHub Actions is configured for Python 3.11–3.13. All data is synthetic.


The problem

A WMS is the system of record for physical goods. When it says "3 units at A-02-01", a picker walks to A-02-01. When it says an order is shipped, customer service tells the customer the parcel is on its way.

An LLM agent on top of that system is useful for questions like these:

  • "Which orders have been sitting for more than 48 hours without leaving?"

  • "How many units of SKU TDW-HLM-M are left, and where?"

  • "Mark order 10432 as a stock issue and note the reason."

What makes the agent useful also makes it dangerous: it produces fluent, plausible values. Three mistakes look reasonable in a chat and are wrong on the warehouse floor:

  • a plausible status that doesn't exist;

  • the "most likely" order when three customers share a surname;

  • a quantity "fixed" so that an order fits the stock.

Related MCP server: ParcelPilot MCP Server

What it does

Tool

Kind

What it returns

find_orders

read

Orders matching an id or a customer/recipient name (accent-insensitive)

get_order

read

Full order: status, lines, address, notes (notes wrapped as untrusted data)

list_stalled_orders

read

Orders that are not shipped, delivered or cancelled and are older than min_hours (default 48). Age is measured from creation by default, or from the last status change with since="status_change"

get_stock

read

On-hand units per location, units awaiting pick and a shortage flag

get_audit_log

read

Trail written by the triggers: actor (agent/script/ui), before and after values

set_order_status

write

Changes the status of an order id. A new reason is required. Cannot set shipped, delivered or cancelled

add_order_note

write

Appends a note to an order id (notes are append-only)

Data model: three business tables (orders, order_lines, stock_movements) plus audit_log. Stock is a ledger: on-hand per (sku, location) is SUM(qty_delta).

Architecture

flowchart LR
    subgraph Clients
        RAW["pytest raw JSON-RPC client<br/>(tested)"]
        LOOP["agent loop: mcp ClientSession<br/>+ ScriptedModel (tested)<br/>+ LiteLLMModel (not run)"]
        ANY["any stdio MCP client,<br/>e.g. Claude Code<br/>(config shipped, not tested)"]
    end
    subgraph Server["wms-mcp (stdio)"]
        FM["server.py<br/>FastMCP adapter<br/>strict JSON Schemas · extra args rejected<br/>error mapping · tool-call log"]
        SVC["service.py<br/>disambiguation · transitions<br/>write mode off / dry_run / on<br/>untrusted-text envelope"]
    end
    subgraph DB["SQLite file"]
        T[("orders · order_lines<br/>stock_movements")]
        TR{{"triggers<br/>CHECK enum · column allowlist<br/>new reason · actor required · append-only"}}
        A[("audit_log<br/>actor · before · after")]
    end
    SCRIPT["batch script / SQL console<br/>(examples/script_writer.py)"]

    RAW -- JSON-RPC --> FM
    LOOP -- JSON-RPC --> FM
    ANY -- JSON-RPC --> FM
    FM --> SVC
    SVC -- "reads: mode=ro connection" --> T
    SVC -- "writes: one BEGIN IMMEDIATE txn<br/>(resolve, check, conditional UPDATE),<br/>write_actor='agent'" --> TR
    SCRIPT -- "write_actor='script'" --> TR
    TR --> T
    TR -- AFTER triggers --> A

The two layers enforce the same rules on purpose:

Rule

Python (domain.py, service.py)

SQLite (schema.sql)

Closed status enum

parse_status raises on anything that is not an exact match

CHECK (status IN (...))

Writable columns: status, reason, timestamp, notes

WRITABLE_ORDER_FIELDS

A BEFORE UPDATE trigger aborts on any other column

Quantities and prices never change

No code path writes them

order_lines and stock_movements reject UPDATE/DELETE

A status change needs its own reason

_clean_text, plus a check that the reason is not the current one

The trigger aborts if status_reason is empty or unchanged

Notes are append-only

Append-only code path

The trigger aborts if the old prefix changes

Every write declares an actor

The service always sets it

The trigger aborts if the actor is missing or not agent/script/ui

Nothing is deleted

No code path deletes

BEFORE DELETE triggers abort

Check and write see the same row

Read, checks and UPDATE ... WHERE status = <checked> run in one BEGIN IMMEDIATE transaction

— (the transition graph is not in SQL; see Limitations)

The Python layer gives the agent a useful error message. The database layer protects against every writer that is not the agent.

Engineering notes

The design question is not "what can the agent do?" but "what happens when it is confidently wrong?". In a warehouse the answer is physical: a parcel held, a customer told the wrong thing, a picker sent to an empty bin.

Five years supervising production at a manufacturing plant, and warehouse work before that, taught me that a wrong value in the system costs more than a missing one. That's why this agent would rather ask than assume.

Where this comes from. I supervise production at a manufacturing plant: quality checks on assembly and packaging, labelling, stock counts and stock-outs, dispatching packaged goods with their delivery notes, receiving carriers, moving pallets, boxes and reels between buildings by forklift and electric pallet truck, and training new staff. Before that I worked in warehouses, unloading trucks by hand and picking orders. Every one of those steps is a place where the status in a system and the state on the floor can disagree. That gap is what this server is designed around.

  • Reject, don't repair. The server returns an error for all of these:

    • an invented status (lost_in_transit);

    • a status with the wrong case (Stock_Issue);

    • an empty, over-long or reused reason;

    • an unknown WMS_WRITE_MODE;

    • an extra tool argument.

    Nothing is lower-cased, truncated or fuzzy-matched into validity, because a "corrected" value is a guess written into the system of record. The error goes back to the agent, which can ask the user. A partial SKU (TDW-GLV) returns not_found with suggestions, never the stock of the closest match.

  • Never pick between candidates, and never write to a name. "Martínez" matches four orders in the seed, one of them spelled without the accent. Accent folding widens the match on purpose so the ambiguity comes to the surface instead of staying hidden. Write tools go one step further. Even when a name matches one order, they return needs_confirmation with that order and write nothing. A single substring match is not proof of intent: "Soler" might be a customer who isn't in the WMS yet. The agent has to confirm the id with the user and call again with it.

  • The allowlist lives in the database, not only in the server. A WMS has many writers: integrations, nightly jobs, a back-office UI, someone with a SQL console. A rule that only the agent's server enforces protects only against the agent. The column allowlist is a BEFORE UPDATE trigger. A test reads PRAGMA table_info(orders) and checks that every column outside the allowlist aborts, so a column added later without protection fails CI.

  • Check-then-write is atomic. Resolving the order, validating the transition and running the UPDATE happen inside one BEGIN IMMEDIATE transaction. The UPDATE only matches the status and notes that were checked. An earlier version read the row before opening the transaction. A script could cancel an order in between, and the agent then moved a cancelled (final) order to stock_issue. A test now simulates that concurrent writer.

  • The trail is written by triggers, so it covers scripts too. examples/script_writer.py uses only sqlite3 and never imports this project. Its write still appears in audit_log with actor = script and before/after values.

  • Read by default, write opt-in, and a dry run that runs the real thing. WMS_WRITE_MODE defaults to off. dry_run executes the actual UPDATE inside a transaction, so triggers and constraints run, then returns the diff and rolls back. A dry run cannot pass something a real run would reject. Read tools open SQLite with mode=ro, so "read-only" is a property of the connection, not a promise in the code.

  • Physical and commercial events are not the model's to declare. The agent cannot set shipped or delivered, which come from a dock scan or a person, and it cannot set cancelled, a final commercial decision. A script actor can record all three.

  • Text written by people is data. Notes and reasons come back as {"trust": "untrusted", "content": "<untrusted-data>…</untrusted-data>"}, and a note that tries to close the envelope early is defused. The seed includes a note that tells the assistant to mark every order as delivered. The tests check two things: the note comes back as marked data, and reading it changes nothing. This reduces prompt-injection risk. The real control is structural: two narrow write tools, off by default, with no shipping, cancelling, pricing or deletion capability.

  • SDK defaults were not trusted blindly. In mcp 1.30, FastMCP registers its tool handler with validate_input=False. It validates through Pydantic argument models that ignore unknown fields, so extra arguments are silently dropped, and a call carrying "total_price_cents": 0 would have "succeeded". The server sets additionalProperties: false and extra="forbid", and a raw-JSON-RPC test guards it.

  • Errors meant for the agent are separated from internal ones. Domain errors (WmsError) are returned verbatim. Anything else, such as a SQLite error or a missing file with its absolute path, is logged on stderr with an incident id, and the client only gets a generic message.

Why this matters to me. While auditing my own project history, I found that an automatic memory tool had recorded a topic as discussed in some sessions, fluently and in detail, yet a plain grep over the source transcripts showed the topic never came up. That was a read-side error. On the write side of a WMS, the same kind of error becomes a status nobody entered. That is why nothing lands here unless it is exact, unambiguous and on the allowlist.

Actor attribution in SQLite, and its honest limit

SQLite has no users or roles, so the actor is a statement-scoped declaration:

  1. Every INSERT/UPDATE on orders must set write_actor to agent, script or ui. Otherwise a BEFORE trigger aborts.

  2. An AFTER trigger writes the actor and the before/after values into audit_log, then resets write_actor to NULL. The next UPDATE therefore cannot silently inherit the previous writer's identity. A test covers this.

  3. audit_log rejects UPDATE and DELETE.

This catches mistakes and forgetful scripts. It is not a security boundary. The actor is self-declared (a script can claim to be ui), and anyone who can open the file can DROP TRIGGER. In Postgres, the agent would connect as its own role with GRANT UPDATE (status, status_reason, status_changed_at, notes), and the audit trigger would record session_user, an identity the writer cannot choose without that role's credentials. The pattern comes from a private CRM tool I built in August 2026, whose audit trigger recorded the Postgres role behind every change. That tool kept its column allowlist only in Python. Moving the allowlist into the database is the main change here; its cleaned-up public version, agent-crm-mcp, later took the same step with column grants. Details are in docs/decisions.md.

Agent harness and offline evaluation

src/wms_mcp/agent/ contains a small, provider-neutral agent loop. It connects to the server through the official MCP client (ClientSession over stdio), turns the server's tool schemas into OpenAI-style function specs, and loops model → tool calls → results until the model answers. The trace records every tool, its arguments, the outcome and the latency.

  • prompts.py: the system prompt, versioned (wms-assistant/v1) so every trace records which prompt produced it.

  • models.py: two models behind one protocol.

    • ScriptedModel replays hand-written turns.

    • LiteLLMModel calls any provider LiteLLM supports. LiteLLM is optional and never imported by the tests.

  • evals.py + evals/scenarios.toml: 13 scenarios. Each one is a user request plus model-agnostic checks:

    • which tools were or weren't called;

    • which outcomes came back;

    • whether the database changed (full orders snapshot and audit_log actors);

    • what the final answer mentions.

    Each scenario runs against a freshly seeded database and a real server subprocess.

The scenarios cover: an ambiguous name, a unique name that still needs confirmation, an invented status, an attempt to ship, an attempt to cancel, a smuggled price argument, the injected note, a partial SKU, writes off and dry run.

make eval                               # offline: scripted trajectories, no API calls
make report                             # per-tool outcomes, error rate, latency from the run
pip install -e '.[live]'                # optional: LiteLLM, only for live runs
make eval-live MODEL=<litellm model id> # live: YOUR keys, paid API calls, not run here

What the offline run proves, and what it does not. The scripted trajectories are synthetic, and several deliberately misbehave: they invent a status, try to ship, or smuggle an argument. They prove three things:

  1. the server stops those trajectories;

  2. the loop and the MCP client work end to end;

  3. the graders catch failures. Two negative-control tests feed the graders a model that picks a Martínez order on its own and one that never answers, and both runs must fail.

They do not say how well any real model follows the prompt. That is what make eval-live is for, and no live results are included in this repository.

Observability. Set WMS_TOOL_LOG=<file> and the server appends one JSON line per tool call with the tool, outcome (ok, needs_clarification, dry_run, applied, error…), error kind, latency, write mode and argument names. Argument values are not logged because they can contain customer names. Calls that FastMCP rejects before they reach the tool (invalid arguments, unknown tool) are recorded too. wms-report <file> aggregates the log.

Example: what the agent sees

set_order_status("Martínez", "stock_issue", "short") with WMS_WRITE_MODE=dry_run or on (trimmed). With off, the call is rejected before the lookup:

{
  "outcome": "needs_clarification",
  "write_performed": false,
  "message": "'Martínez' matches 4 orders. No changes were made. Ask the user which order they mean, using the order id; do not choose one yourself.",
  "candidates": [
    {"order_id": 10432, "customer_name": "Lucía Martínez", "ship_city": "Valencia", "status": "picking", "age_hours": 26.0},
    {"order_id": 10412, "customer_name": "Javier Martínez Soler", "ship_city": "Zaragoza", "status": "allocated", "age_hours": 52.0}
  ]
}

add_order_note("Navarro", "Customer confirmed postcode.") (one match, still no write; trimmed):

{
  "outcome": "needs_confirmation",
  "write_performed": false,
  "message": "'Navarro' matches one order (10423), but a write needs the order id. No changes were made. Confirm with the user that this is the order they mean, then repeat the call with order_ref set to the id.",
  "candidates": [{"order_id": 10423, "customer_name": "Sofía Navarro", "ship_city": "Bilbao", "status": "address_issue"}]
}

set_order_status("10432", "stock_issue", "Only 1 unit of TDW-GLV-L at A-02-01; order needs 3") with WMS_WRITE_MODE=dry_run (timestamps shortened):

{
  "outcome": "dry_run",
  "write_performed": false,
  "order_id": 10432,
  "actor": "agent",
  "changes": {
    "status": {"before": "picking", "after": "stock_issue"},
    "status_changed_at": {"before": "…T16:53:21Z", "after": "…T18:53:21Z"},
    "status_reason": {"before": null, "after": {"trust": "untrusted", "source": "status_reason",
      "content": "<untrusted-data>Only 1 unit of TDW-GLV-L at A-02-01; order needs 3</untrusted-data>"}}
  },
  "message": "Dry run: the update passed every check and was rolled back. Nothing was written."
}

These are real outputs of the service against the seed database (trimmed), not mock-ups.

Quickstart

make install                 # .venv + editable install with dev tools, using python3
make install PYTHON=python3.12   # any Python >= 3.11; install stops early on an older one
make seed                    # data/wms.sqlite with synthetic data (timestamps relative to now)
make test                    # pytest, including the stdio protocol test and the offline eval
make lint                    # ruff check + ruff format --check + mypy --strict
make eval && make report     # scripted agent scenarios + tool-call summary
make serve                   # MCP server on stdio, writes off
make serve WRITE_MODE=dry_run

Try the out-of-band audit:

.venv/bin/python examples/script_writer.py --db data/wms.sqlite --order 10440 \
    --reason "Nightly address check: postcode missing"
# prints the audit rows for order 10440; the last one has actor = script

Connect a client

Claude Code: copy examples/claude-code/.mcp.json to your project root and replace /ABSOLUTE/PATH/TO. Alternatively:

claude mcp add wms --env WMS_DB_PATH=/abs/path/data/wms.sqlite --env WMS_WRITE_MODE=off \
    -- /abs/path/mcp-logistica/.venv/bin/wms-mcp

Other stdio MCP clients: see examples/generic/mcp_servers.json. The command is python -m wms_mcp.server, configured through environment variables:

Variable

Default

Values

WMS_DB_PATH

data/wms.sqlite

Path to an existing file. A missing file is an error, never an empty warehouse

WMS_WRITE_MODE

off

off, dry_run or on. Anything else aborts startup with exit code 2

WMS_TOOL_LOG

unset

Optional JSON-lines file for the tool-call log

The only clients exercised by the tests are the raw JSON-RPC client and the SDK's ClientSession in the eval harness. Other clients that launch stdio servers (Claude Desktop, Cursor, Google ADK's MCPToolset, LiteLLM's MCP gateway) should work, but none of them was run against this server.

Tests

tests/ is organised by what could go wrong:

File

Guards

test_schema_constraints.py

Raw sqlite3, no project code: an invalid status is rejected by CHECK; every column outside the allowlist aborts; lines and the stock ledger are immutable; nothing is deleted; the actor is required and not inherited; a status change cannot reuse the old reason; script writes are audited with before/after; the rules hold with recursive_triggers on

test_service_writes.py

Write modes off / dry_run / on; the dry run still fires triggers; an invented status is rejected; fields outside the allowlist are rejected in Python; transitions; the agent cannot ship, deliver or cancel; a reused reason is rejected; ambiguous and unique names never write; a concurrent writer is locked out between check and write; a stale row is refused

test_service_reads.py

Stalled orders by age and by time in status; stock per location and shortage; a partial SKU is not guessed; accent-insensitive search; a read connection cannot write; a missing DB is not created

test_untrusted.py

The prompt-injection note comes back inside the envelope; marker variants are defused; note metadata is returned as claimed_at/claimed_by, values outside the vocabulary become null/unknown, and audit_log shows the real actor

test_protocol_stdio.py

Spawns the server and speaks raw JSON-RPC: initialize, tools/list (schemas, enums, additionalProperties: false, no quantity/price/address parameters), tools/call; unknown arguments are rejected; internal errors don't leak paths; the tool-call log records outcomes without argument values; a bad config exits with code 2

test_agent_eval.py

All 13 scripted scenarios pass through the real server; negative controls (an agent that picks a candidate on its own, an agent that never answers) fail; LiteLLM response parsing is tested with a fake completion function

test_calllog.py

The report aggregates outcomes, error rate and latency

test_seed_and_script.py

The seed is synthetic (@example.com, all statuses, multi-location SKUs); the example script's write is audited as script

test_domain.py

Enum and write-mode parsing reject rather than correct

Status

Tested locally (macOS, 2026-09-16), with mcp 1.30.0, ruff 0.16.7, mypy 2.3.1 and pytest 9.1.1:

  • ruff check, ruff format --check and mypy --strict are clean;

  • pytest: 122 passed on each of Python 3.11.15, 3.12.14 and 3.13.15;

  • make eval passes 13/13 scripted scenarios.

Configured but not yet run: the GitHub Actions workflow (.github/workflows/ci.yml, matrix 3.11–3.13, including the offline eval). It will run on the first push.

Not included:

  • Live runs against real models. No test and no CI step calls an LLM. How well a given model uses these tools (asking instead of guessing, respecting the untrusted envelope, reporting a dry run honestly) is not measured here. Run make eval-live MODEL=<id> with your own keys. The LiteLLMModel adapter has only been tested with a fake completion function.

  • A Google ADK example. ADK and LiteLLM both speak MCP over stdio, but neither was run against this server.

  • Postgres with real roles, an HTTP transport with authentication, a UI and a per-session write budget.

Limitations

  • Actor attribution is self-declared (see above). It is traceability, not security. The same applies to note metadata (claimed_by).

  • Status transitions are enforced in Python only. The database enforces the enum and a new reason, not the transition graph. A raw-SQL script can therefore make an invalid transition, although it will still be audited with its actor.

  • Concurrency: writes hold BEGIN IMMEDIATE from lookup to commit and use a conditional UPDATE. This is tested with a simulated concurrent writer, not under load. SQLite serialises writers, so this is not a design for high write volume.

  • Offline evals measure the harness, not a model (see above).

  • "Stalled" has two definitions (order age, time in status). The tool defaults to order age and labels which one it used in every response.

  • Notes are a JSON-lines column, not a table, to keep the specified three-table scope. A production schema would use an order_notes table.

  • Customer and recipient names are not wrapped as untrusted text. They are short structured fields, but still user-supplied.

  • The untrusted envelope is a mitigation, not a guarantee. A model can still be persuaded by text inside it.

  • stdio only, single SQLite file.

Credits

  • Model Context Protocol and its official Python SDK (MIT), which provides FastMCP and the client used by the agent loop.

  • LiteLLM (MIT), an optional dependency used only by make eval-live.

  • Pydantic, AnyIO, pytest, Ruff and mypy.

  • All companies, people, addresses and SKUs in seed.py are invented, and emails use example.com.

  • Written with heavy use of AI coding assistants (Claude Code). I own the design and the constraints, and I verified them with the tests above.

MIT licensed.

Related MCP Connectors

Related MCP Servers

  • F
    license
    Not graded
    quality
    C
    maintenance
    Enables stock assessment and replenishment by exposing three deterministic tools: inspect stock positions, raise replenishment orders, and check order status. Includes an auditable local client and follows a security-first design with limited API surface.
    -
  • F
    license
    Not graded
    quality
    C
    maintenance
    Enables AI agents to retrieve customer, order, ticket, policy, and agreement information, and to prepare or execute state-changing support actions like escalations and follow-ups with confirmation and access control.
    -
  • F
    license
    Not graded
    quality
    B
    maintenance
    Enables AI-powered warehouse inventory management through MCP, with tools to search products, check stock levels, receive and issue stock, and add products under human-in-the-loop confirmation.
    -