Skip to main content
Glama
Azzaraell

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