Load Planner MCP
# load-planner-mcp
[](https://github.com/bayraak/load-planner-mcp/actions/workflows/tests.yml)
A deterministic load-planning solver, plus an MCP stdio server that exposes it
as two tools. The solver decides freight mode for a cohort of orders — all-air,
consolidated sea-LCL, or full container — prices each option, and attaches a
risk read (ETA percentiles, deadline-miss probability, CVaR of the delay tail).
## What is in here
| Path | Contents |
|---|---|
| `load_planner/geometry.py` | 3D bin-packing (py3dbp): do the pallets fit a 20GP/40GP/40HC, and at what fill rate |
| `load_planner/solver.py` | Fixed-charge MILP over OR-Tools CP-SAT: cheapest feasible assignment per enumerated mode-pattern |
| `load_planner/risk.py` | Delay-distribution convolution: p50/p90 ETA, deadline-miss probability, CVaR(alpha) |
| `load_planner/scenarios.py` | Enumerates the cards, scores them on one identical vector, ranks them, picks a recommendation |
| `load_planner/cli.py` | A plain stdin/stdout JSON boundary, for hosts that prefer a child process to an import |
| `solver_mcp/server.py` | The MCP stdio server: JSON-RPC 2.0, two tools, and `verify_narration()` |
| `fixtures/cohort_urgent.json` | A synthetic 20-pallet cohort with a synthetic rate card, used by the tests |
The two sub-problems are layered rather than merged. Solving true 3D placement
inside the MILP explodes combinatorially, so the packer runs first and hands
CP-SAT a fill rate and a feasibility flag.
## The two-tool contract
`solve(problem)` runs the solver and returns its output verbatim — not
reformatted, not rounded, not summarised — together with a `result_id` and a
SHA-256 `content_hash` of the result. The result is kept in an in-process store.
`explain(result_id)` returns a narration built from a fixed template whose slots
are filled from that stored result.
The split is the design. A language model calling this server chooses what to
solve and puts the answer into words; it does not originate any figure. Three
properties make that structural rather than aspirational:
1. **`explain` takes one opaque id and nothing else.** Its input schema has a
single property. It cannot be handed a rate, a weight or a deadline, so it
cannot restate one.
2. **The stored record holds the solver result only.** `solve` discards the
problem payload once the solver has run. There is no field on the stored
record that could carry an input value.
3. **There is no arithmetic on the `explain` path.** Values are looked up by key
and rendered as strings. Selecting the recommended card reads a flag the
solver already set; it does not compare or compute.
`verify_narration(narration, stored)` turns the claim into a check. It scans the
text for digit runs and rejects any token absent from the stored result, so a
narration that rounds 11480.0 to "11500", converts 0.1246 to "12.46%", or adds
two stored costs together fails — each of those is a number the solver never
produced. A host can apply it to any narration before display, including one the
model wrote in its own words rather than one `explain` returned. `explain` runs
it on its own output before returning.
What the guard deliberately allows, so it is not mistaken for a stronger claim:
digits inside stored strings and dict keys count as present, because a narration
may quote those strings verbatim. `"ortools-cpsat+py3dbp"` admits `3`, and the
field name `eta_p90_days` admits `90`. It is a lexical check on numeric tokens,
not a proof of semantic correctness: a narration that attaches the right number
to the wrong label still passes.
## Install
Python 3.10 or newer.
```bash
python -m venv .venv
./.venv/bin/pip install ortools py3dbp pytest
```
Or install the package itself, which pulls the two runtime dependencies:
```bash
pip install -e .
```
`ortools` is the CP-SAT engine; `py3dbp` is the 3D packer, MIT-licensed. The MCP
server adds nothing beyond the standard library — the stdio protocol surface is
small enough to implement directly, which keeps the dependency list auditable.
## Run
The server speaks newline-delimited JSON-RPC 2.0 on stdin/stdout:
```bash
python -m solver_mcp
```
The solver is also usable without MCP, as a library or as a child process:
```python
from load_planner import generate_scenarios
result = generate_scenarios(problem)
```
```bash
cat fixtures/cohort_urgent.json | python -m load_planner.cli
```
## Wiring into an MCP client
Any client that launches stdio servers takes a command and arguments. For a
client using the common `mcpServers` configuration shape:
```json
{
"mcpServers": {
"load-planner": {
"command": "/absolute/path/to/.venv/bin/python",
"args": ["-m", "solver_mcp"],
"env": { "PYTHONPATH": "/absolute/path/to/load-planner-mcp" }
}
}
}
```
`PYTHONPATH` is only needed when the package has not been installed into the
interpreter's environment; after `pip install -e .` the `env` block can go.
The handshake is the standard one: `initialize`, then the
`notifications/initialized` notification, then `tools/list`. The server reports
protocol version `2025-06-18` and accepts `2025-03-26` and `2024-11-05`,
echoing back whichever the client asked for if it is one of those.
## Tests
```bash
python -m pytest tests/ -q
```
Three suites: the solver's own tests against the fixture; the MCP handshake and
both tools, exercised in process and over a real stdio subprocess; and the
tamper suite, which asserts that a narration containing a value absent from the
stored result is rejected.
## Limits
- **Single machine, single process.** The result store is in memory and lives
for the life of the server process. A `result_id` from one process resolves in
another only because ids are content-addressed and the solver is
deterministic — re-solving the same problem reproduces the id. There is no
shared store, no eviction, and no bound on the number of retained results.
- **Solver runtimes.** CP-SAT is called once per enumerated mode-pattern, at
most three times per `solve`, on a model with one integer variable and two
constraints; it returns in milliseconds. The cost that scales is the 3D
packer, which places one item per pallet — a few hundred pallets is fine, tens
of thousands is not. `solve` runs synchronously and has no internal timeout, so
a host should impose its own.
- **Whole-order assignment only.** Splitting one order across two modes (a
base-plus-surge policy) is not modelled; each order is assigned entirely to
one mode.
- **Seeded risk inputs.** The delay distributions in `risk.py` are published
carrier-reliability seeds, not measurements from your own lane history. They
are the right shape and the wrong precision until a caller replaces them.
- **The guard is lexical.** See the note above: `verify_narration` checks that
every number in a narration appears in the result. It does not check that the
number was used to mean the right thing.
TDQS
Scored across 2 tools
solve and explain have clearly separated responsibilities: solve is the only numeric-producing tool, while explain only consumes a stored result_id and never touches inputs or arithmetic. There is no overlap or plausible misselection.
Both tool names are bare imperative verbs following the same style: solve and explain. Although the pattern is not verb_noun, it is entirely consistent across the server.
With only two tools, the server is minimal and borderline thin. Each tool has a distinct role, but the surface feels light for a load-planning MCP.
The core solve-then-explain workflow is covered, and solve returns a result_id for later explanation. Gaps appear around stored result management, such as listing or retrieving prior results independently.