logistics-mcp
by Azzaraell
README.md
# logistics-mcp
Operations teams ask questions about shipments all day — "where is SBY-1042", "which route
missed its SLA last month", "what would 12 kg to Makassar cost". The answers sit in a
logistics system that an AI assistant cannot see. This MCP server exposes that system as
nine tools, so Claude can answer from the actual data instead of from a screenshot the
user pasted into the chat.
It runs out of the box against a bundled demo dataset (200 synthetic shipments across 6
cities over 3 months), so you can try every tool without credentials. Point it at your own
backend by setting two environment variables.
The domain model comes from a production logistics platform I built (Next.js + Supabase,
deployed on GCP). This repo contains the MCP server and a synthetic demo dataset, not the
client application; no real customer names, rates, or operational data appear anywhere in it.
## Architecture
```
Claude (Desktop / Code)
| MCP, stdio
v
logistics-mcp
tool handlers <- zod-validated inputs, row/byte caps, actionable errors
|
LogisticsSource <- one interface; handlers do not know which side is active
/ \
demo source rest source
fixtures/*.json LOGISTICS_API_URL + LOGISTICS_API_TOKEN (Bearer)
```
The demo source reads the checked-in fixture JSON. The rest source expects the upstream
contract documented below. Writes are disabled unless `ALLOW_WRITES=1`; in demo mode an
`update_shipment_status` call mutates memory only and never touches the fixture files.
## Install
Claude Desktop (`claude_desktop_config.json`):
```json
{
"mcpServers": {
"logistics": {
"command": "npx",
"args": ["-y", "logistics-mcp"]
}
}
}
```
Claude Code:
```bash
claude mcp add logistics -- npx -y logistics-mcp
```
Both configurations need nothing else: no URL, no token, no clone. To connect your own
backend instead of the demo data, add `LOGISTICS_API_URL` and `LOGISTICS_API_TOKEN` to the
server's environment.
## What a conversation looks like
Recorded against the demo dataset.
> **You:** What's the status of shipment SBY-1003?
Claude calls `track_shipment` and gets the public timeline:
```json
{
"spNumber": "SBY-1003",
"events": [
{ "at": "2026-07-11T05:35:01.991Z", "status": "READY", "location": "Surabaya", "description": "Shipment registered and waiting for pickup" },
{ "at": "2026-07-11T14:35:01.991Z", "status": "PICKED_UP", "location": "Surabaya", "description": "Picked up from sender" },
{ "at": "2026-07-12T11:35:01.991Z", "status": "IN_TRANSIT", "location": "Surabaya to Yogyakarta", "description": "On the way to the destination region" },
{ "at": "2026-07-13T02:35:01.991Z", "status": "AT_HUB", "location": "Yogyakarta hub", "description": "Arrived at destination sorting hub" },
{ "at": "2026-07-13T13:35:01.991Z", "status": "OUT_FOR_DELIVERY", "location": "Yogyakarta", "description": "With the courier for final delivery" },
{ "at": "2026-07-14T21:35:01.991Z", "status": "DELIVERED", "location": "Yogyakarta", "description": "Delivered to the recipient" }
]
}
```
> **You:** And what would 12 kg to Makassar cost on STANDARD?
`quote_shipping_rate` resolves the layered rate table and shows the math:
```json
{
"freight": 174000,
"currency": "IDR",
"breakdown": {
"pricePerKg": 14500,
"minCharge": 25000,
"appliedRule": "destination_special",
"formula": "max(ceil(12 * 14500), 25000)"
},
"alternatives": [
{ "product": "ECONOMY", "freight": 68400 },
{ "product": "EXPRESS", "freight": 312000 }
]
}
```
Other questions the demo data supports: "which route had the worst on-time rate in June"
(`delivery_performance`), "what's on the next load to Surabaya" (`list_manifests` →
`get_manifest`), "show everything Kirana Textiles shipped that's still in transit"
(`search_shipments`), "which customers still have unpaid invoices" (`list_invoices`).
## Tools
| Tool | What it answers |
|---|---|
| `search_shipments` | Find shipments by status, date range, route, or customer (limit default 20, max 100) |
| `get_shipment` | Full detail: items, extra charges, total amount, status history |
| `track_shipment` | Public tracking timeline, without internal ops notes |
| `quote_shipping_rate` | Price for route + weight + product, with the winning rate rule and the math |
| `list_manifests` | Outbound loads per region and date, with counts and total weight |
| `get_manifest` | Contents of one load |
| `delivery_performance` | Per-route on-time rate, average transit days, exception counts for a period |
| `list_invoices` | Customer invoices by payment status or customer, with amount and due date |
| `update_shipment_status` | Write. Disabled unless `ALLOW_WRITES=1` |
## Configuration
| Variable | Default | Effect |
|---|---|---|
| `LOGISTICS_API_URL` | _(empty)_ | Empty: bundled demo dataset. Set: REST backend at this URL |
| `LOGISTICS_API_TOKEN` | _(empty)_ | Bearer token for the REST backend. Required when the URL is set |
| `ALLOW_WRITES` | `0` | Only the exact value `1` enables `update_shipment_status` |
| `LOGISTICS_TIMEOUT_MS` | `10000` | Per-call timeout for the data source |
| `LOGISTICS_MAX_RESPONSE_BYTES` | `100000` | Responses above this are trimmed with a note on how to narrow the query |
If `LOGISTICS_API_URL` is set without a token, the server exits with a message naming the
missing variable. It does not fall back to demo data — a demo answer presented as
production data is worse than an error.
## REST backend contract
The rest source expects these endpoints under `LOGISTICS_API_URL`, all with
`Authorization: Bearer <LOGISTICS_API_TOKEN>`:
```
GET /shipments?status&from&to&origin&destination&customer&limit&offset
GET /shipments/:spNumber
GET /shipments/:spNumber/tracking
GET /rates?origin&destination
GET /manifests?region&date&limit&offset
GET /manifests/:id
GET /invoices?status&customer&limit&offset
POST /shipments/:spNumber/status { "status": "...", "note": "..." }
```
Response shapes match the TypeScript interfaces in `src/sources/types.ts`.
## Security notes
- Read-only by default. The single write tool stays off unless `ALLOW_WRITES=1`, and when
it is off the tool says how to enable it instead of failing with a generic error.
- No credentials in the repo and no credential defaults in code. The token comes from the
environment and is sent only to `LOGISTICS_API_URL`.
- Tool errors return sentences the model can act on ("no shipment matches SBY-9999; check
the number or use search_shipments"), not stack traces or raw upstream responses.
- List responses are capped in rows and in bytes, so one tool call cannot flood the
client's context window.
- stdio transport only; the server opens no network listener of its own.
## Development
```bash
npm install
npm run build # tsc
npm test # vitest, runs against the demo dataset
npm run generate:fixtures # regenerate fixtures/data deterministically
```
`fixtures/generate.ts` uses a fixed seed; regeneration must produce byte-identical output.
If it does not, the generator picked up nondeterminism — fix the generator, never hand-edit
the JSON. The demo-data review checklist in `.claude/agents/demo-data-reviewer.md` covers
the consistency rules the dataset must satisfy (chronology, rate math, cross-file
references).
The `.claude/` setup is part of the repo's workflow, not decoration: a review subagent
(`agents/demo-data-reviewer.md`), a fixture-check skill (`skills/check-fixtures/`), and two
hooks wired in `settings.json` — `hooks/no-stdout-log.sh` keeps stray `console.log` out of
the stdio server, and `hooks/no-null-bytes.sh` blocks `git commit` when a staged text file
contains null bytes (a guard against UTF-16 output from PowerShell redirects, which once
published an empty README to npm).
## Limitations
- stdio only; no HTTP/SSE transport.
- The demo dataset is synthetic and small by design (200 shipments). Aggregations like
`delivery_performance` fetch up to 1000 rows and stop there.
- No OAuth; the REST adapter authenticates with a static bearer token.
TDQS
A4.1/5.0
Scored across 9 tools
Disambiguation5/5
Each tool serves a distinct purpose: shipment search, detail, tracking, manifest contents, rate quoting, manifest listing, delivery performance, invoices, and status updates. No overlap.
Naming Consistency5/5
All tools follow a consistent verb_noun pattern with snake_case (e.g., search_shipments, list_manifests, update_shipment_status).
Tool Count5/5
9 tools cover the main logistics operations without being excessive. Each tool serves a clear, non-redundant function.
Completeness4/5
Covers essential queries (search, detail, tracking, manifests, pricing, performance, invoices) and a status update. Lacks shipment creation or deletion, but these are outside the stated read-heavy scope.
Maintenance
ActivityMaintained
ResponsivenessSyncing