Skip to main content
Glama
Azzaraell

logistics-mcp

by Azzaraell

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):

{
  "mcpServers": {
    "logistics": {
      "command": "npx",
      "args": ["-y", "logistics-mcp"]
    }
  }
}

Claude Code:

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:

{
  "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:

{
  "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_manifestsget_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

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.jsonhooks/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.

Latest Blog Posts

MCP directory API

We provide all the information about MCP servers via our MCP API.

curl -X GET 'https://glama.ai/api/mcp/v1/servers/Azzaraell/logistics-mcp'

If you have feedback or need assistance with the MCP directory API, please join our Discord server