cargotrace-mcp
README.md
# cargotrace-mcp
**A Model Context Protocol server that lets AI assistants track air waybills and ocean containers and compare freight quotes across carriers — through typed tools backed by check-digit validation, rate limiting, circuit breakers and retries.**
[](https://github.com/AnishaRamanuj/cargotrace-mcp/actions/workflows/ci.yml)
[](LICENSE)




Python 3.12 · FastMCP · FastAPI · Pydantic v2 · httpx · Redis · Docker · GitHub Actions
> [!IMPORTANT]
> **Simulated carriers.** Every carrier in this repository (Aerolink, Nordwind, Bluewater,
> Harborline) is fictional, and every shipment, schedule and price is synthetic data produced by the
> bundled sandbox. No real carrier API is called and no affiliation with any airline or shipping line
> is implied. The default routing table assigns a few real-world-*format* AWB prefixes and container
> owner codes (e.g. `176`, `MSKU`) to these fictional carriers only so that demos route somewhere; it
> says nothing about who owns those codes.
<!-- demo.gif: record with `asciinema rec` (or a screen recorder in Claude Desktop / VS Code Copilot Chat) while running `make sandbox`, `make serve-demo` and `make demo`, then convert with `agg` -->
## Why
Freight visibility is fragmented: every airline and ocean line exposes tracking and rates through a
different interface — IATA Cargo-IMP/FSU-style status messages, DCSA Track & Trace JSON, legacy SOAP,
flat-file feeds — each with its own codes, time zones, units and failure modes. An assistant can only
answer "where is my container and when will it land?" if something normalises those feeds into one
typed contract and protects carriers from bursty, retry-happy clients. cargotrace-mcp is that layer,
exposed over the Model Context Protocol so Claude, ChatGPT and GitHub Copilot can all use the same
tools.
## What it does
- **Six typed MCP tools.** Every argument and result is a Pydantic v2 model, so clients receive a JSON
Schema for inputs *and* structured outputs. All tools are annotated read-only and idempotent.
| Tool | Purpose |
|---|---|
| `track_shipment` | Current stage, latest and next event, full normalised timeline for an AWB or container |
| `get_milestones` | Timeline filtered to actual events and/or one stage |
| `estimate_arrival` | ETA with its basis (actual / carrier estimate / planned), confidence and delay vs. plan |
| `compare_quotes` | Concurrent fan-out to every carrier for the mode; cheapest-first, with per-carrier errors |
| `list_carriers` | Carriers, routing keys, rate limits and live circuit-breaker state |
| `validate_reference` | Offline AWB / ISO 6346 check-digit validation and routing preview |
- **Resources and a prompt:** `cargotrace://carriers`, `cargotrace://milestone-codes`, the template
`cargotrace://milestone-codes/{code}`, and a `shipment_briefing` prompt for customer- or
operations-facing status updates.
- **Check digits:** AWB mod-7 (`176-12345675`, `17612345675` and `176 1234 5675` all normalise to the
same value) and ISO 6346 (`CSQU3054383`, `MSKU6011672`), ASCII digits only, with error messages
that state the expected digit so the model can self-correct.
- **One canonical `Milestone` model** for both modes: the source code is kept verbatim (15 IATA FSU
codes, 10 DCSA event codes) and mapped to cross-mode stages — 11 progress stages (`booked` …
`delivered`) plus 2 non-progress ones (`exception`, `inspected`) — with the ACT/EST/PLN classifier
and UTC timestamps.
- **Four simulated carriers with deliberately different wire formats** — FSU-style JSON with local
time offsets and pounds, SOAP-style XML with `DDMMMYY` dates and SOAP faults, DCSA-style unordered
event lists, and a pipe-delimited feed with trailer counts — each parsed by an async adapter.
- **Resilience around every carrier call:** cache → circuit breaker → per-call deadline → retry
(exponential backoff with full jitter, honours `Retry-After`, never retries 4xx, never waits past
the deadline) → per-carrier token bucket (atomic Redis Lua script, in-memory fallback) →
per-attempt timeout.
- **Transports:** stdio for desktop clients and Streamable HTTP at `/mcp`, with a bearer API key
(constant-time comparison), Host-header (DNS-rebinding) protection and an unauthenticated
`/healthz`. The server refuses to serve HTTP to other machines without a key unless you opt in.
- **Carrier sandbox:** a FastAPI app serving the four carriers with deterministic synthetic data and
fault injection (latency up to a hang, 429 + `Retry-After`, 5xx) via env var, admin API or query
string. It runs in-process by default, so a desktop client needs a single command.
## Architecture
```mermaid
flowchart LR
subgraph clients["MCP clients"]
claude["Claude Desktop / Claude Code"]
chatgpt["ChatGPT connector"]
copilot["VS Code + GitHub Copilot"]
end
claude -- "stdio" --> server
copilot -- "stdio or HTTP" --> server
chatgpt -- "Streamable HTTP over HTTPS" --> server
subgraph app["cargotrace-mcp"]
server["FastMCP server<br/>6 tools · resources · prompt<br/>bearer auth · Host guard"]
service["TrackingService<br/>validate → route → policies<br/>quote fan-out (TaskGroup)"]
cache[("Cache<br/>Redis / in-memory")]
breaker["Circuit breaker<br/>per carrier"]
deadline["Per-call deadline<br/>timeout = carrier fault"]
retry["Retry policy<br/>backoff + jitter · Retry-After<br/>budget-aware"]
bucket["Token bucket<br/>Redis Lua / in-memory"]
adapters["Carrier adapters<br/>FSU JSON · SOAP XML · DCSA JSON · pipe feed"]
server --> service --> cache
service --> breaker --> deadline --> retry --> bucket --> adapters
end
redis[("Redis")]
cache -.-> redis
bucket -.-> redis
adapters -- "httpx (async)" --> sandbox["Carrier sandbox (FastAPI)<br/>4 simulated carriers · synthetic data<br/>fault injection"]
```
The MCP server runs on FastMCP (Starlette underneath); FastAPI powers the carrier sandbox, which
stands in for the carriers' own APIs. A `compare_quotes` call fans out concurrently; each carrier
gets its own deadline, and one slow or failing carrier becomes an entry in `errors` instead of
failing the call:
```mermaid
sequenceDiagram
participant C as MCP client
participant S as TrackingService
participant A as aerolink adapter
participant N as nordwind adapter
C->>S: compare_quotes(mode=air, DXB→JFK, 180 kg, 1.5 m³)
par per-carrier deadline (8 s)
S->>A: cache? → breaker → retry → token → POST /v1/quotes
A-->>S: 2 offers
and
S->>N: cache? → breaker → retry → token → SOAP RateRequest
N-->>S: 503, 503, 503 (3 attempts, breaker records one fault)
end
S-->>C: quotes (cheapest first) + errors=[nordwind: server_error], complete=false
```
## Quickstart
Requires [uv](https://docs.astral.sh/uv/). Redis is optional (used for the shared rate limiter and
cache when `CARGOTRACE_REDIS_URL` is set).
```bash
git clone https://github.com/AnishaRamanuj/cargotrace-mcp.git
cd cargotrace-mcp
uv sync # Python 3.12+, dev tools included
uv run cargotrace validate 176-12345675 MSKU6011672 # offline check-digit validation
uv run pytest # full test suite, no services needed
```
Run it over HTTP exactly as in the demo below (three terminals, all in the repository root):
```bash
# once: generate an API key into a git-ignored file that every terminal can source
python3 -c "import secrets; print('export CARGOTRACE_API_KEY=' + secrets.token_urlsafe(24))" > .env.demo
# terminal 1 - carrier sandbox on :8102
uv run cargotrace sandbox --port 8102 # or: make sandbox
# terminal 2 - MCP server on :8101, with the API key, Redis db 1 and the external sandbox
. ./.env.demo
CARGOTRACE_REDIS_URL=redis://localhost:6379/1 CARGOTRACE_CARRIER_BASE_URL=http://127.0.0.1:8102 \
uv run cargotrace serve --transport http --port 8101 # or: make serve-demo
# terminal 3 - call every tool, then inject faults and watch it degrade
. ./.env.demo
uv run python examples/demo_client.py --url http://127.0.0.1:8101/mcp # or: make demo
bash examples/fault_demo.sh # or: make fault-demo
```
Or with containers (Redis + sandbox + server, published on 127.0.0.1 only): `cp .env.example .env`,
set `CARGOTRACE_API_KEY` (compose refuses to start without it), then `docker compose up --build`.
## Connect an MCP client
Run `uv sync` once first, so the first launch by a client doesn't have to build the environment.
The configurations below follow each client's documented format. What this repository verifies is
the protocol side: the tests drive every tool through an in-memory FastMCP client and the Streamable
HTTP app with auth, and [`docs/demo.md`](docs/demo.md#stdio-launched-the-way-the-desktop-configs-launch-it)
captures the exact stdio launch command these configs use (`demo_client.py --stdio`). The desktop
apps themselves are not driven by the test suite.
### Claude Desktop (stdio)
Add to `claude_desktop_config.json` (macOS: `~/Library/Application Support/Claude/`, Windows:
`%APPDATA%\Claude\`) and restart Claude Desktop — see
[`examples/claude_desktop_config.json`](examples/claude_desktop_config.json):
```json
{
"mcpServers": {
"cargotrace": {
"command": "uv",
"args": ["run", "--directory", "/absolute/path/to/cargotrace-mcp", "cargotrace", "serve"]
}
}
}
```
If Claude Desktop reports `spawn uv ENOENT`, replace `"uv"` with the absolute path printed by
`which uv` (e.g. `/Users/you/.local/bin/uv`): GUI apps on macOS don't inherit your shell's `PATH`.
No network port is opened: the client launches the server as a subprocess and the carrier sandbox
runs in-process.
### Claude Code
```bash
# local, stdio
claude mcp add cargotrace -- uv run --directory /absolute/path/to/cargotrace-mcp cargotrace serve
# remote/shared, Streamable HTTP
claude mcp add --transport http cargotrace-http http://127.0.0.1:8101/mcp \
--header "Authorization: Bearer $CARGOTRACE_API_KEY"
```
A project-scoped `.mcp.json` equivalent is in [`examples/claude-code.mcp.json`](examples/claude-code.mcp.json).
### VS Code + GitHub Copilot
This repository ships [`.vscode/mcp.json`](.vscode/mcp.json) with two servers: `cargotrace` (stdio,
launched with `uv run --directory ${workspaceFolder} …`) and `cargotrace-http` (Streamable HTTP; VS
Code prompts for the API key and stores it as a secret input). Open the folder, start a server from
the `mcp.json` editor or **MCP: List Servers**, then use the tools from Copilot Chat in **Agent** mode.
### ChatGPT (remote MCP connector)
ChatGPT connects to MCP servers from OpenAI's infrastructure, so it needs a **publicly reachable HTTPS
URL** pointing at `/mcp` (Streamable HTTP); `localhost` will not work. Custom connectors are added from
ChatGPT's connector settings (developer mode at the time of writing) — check OpenAI's current
documentation, as plan availability and the auth options offered change. At the time of writing the
connector dialog offers OAuth or no authentication, not a static `Authorization` header, so either
run a short-lived demo instance without an API key (the data is synthetic; the server requires
`CARGOTRACE_ALLOW_UNAUTHENTICATED=true` to do that) or put the server behind an OAuth provider —
FastMCP ships providers for Entra ID, Auth0 and others; wiring one in is on the roadmap.
Two ways to get that URL — a tunnel to your machine for a quick demo, or Azure Container Apps for a
stable endpoint (with the replica and session caveats) — are written up in
[`docs/deployment.md`](docs/deployment.md).
## Demo (captured from a real run)
Captured on 2026-10-24 at about 20:10 UTC with the three-terminal setup above (sandbox on :8102, MCP
server on :8101 with `CARGOTRACE_REDIS_URL=redis://localhost:6379/1`). The sandbox anchors timelines to
the current UTC day, so the same references show different progress on other days.
[`docs/demo.md`](docs/demo.md) has the rest of the run: the raw structured JSON, the fault-injection
transcript (breaker opening, `Retry-After`, a hung carrier cut off by its deadline), the server log
and the keys in Redis.
**HTTP surface** — health check, missing API key, and a foreign `Host` header:
```console
$ curl -s http://127.0.0.1:8101/healthz
{"status":"ok","version":"0.1.0"}
$ curl -s -o /dev/null -w "%{http_code}\n" http://127.0.0.1:8101/mcp -H "Content-Type: application/json" -d '{"jsonrpc":"2.0","id":1,"method":"ping"}'
401
$ curl -s -o /dev/null -w "%{http_code}\n" http://127.0.0.1:8101/mcp -H "Host: attacker.example" -H "Authorization: Bearer $CARGOTRACE_API_KEY" -H "Content-Type: application/json" -d '{"jsonrpc":"2.0","id":1,"method":"ping"}'
421
```
**Every tool over Streamable HTTP** — [`examples/demo_client.py`](examples/demo_client.py) prints one
summary per call, derived from the structured result:
```console
$ uv run python examples/demo_client.py --url http://127.0.0.1:8101/mcp
connected to http://127.0.0.1:8101/mcp: 6 tools -> track_shipment, get_milestones, estimate_arrival, compare_quotes, list_carriers, validate_reference
> list_carriers {}
4 carriers
aerolink air IATA FSU-style JSON (REST) circuit=closed failures=0
nordwind air SOAP-style XML (legacy) circuit=closed failures=0
bluewater ocean DCSA Track & Trace-style JSON (REST) circuit=closed failures=0
harborline ocean pipe-delimited text feed circuit=closed failures=0
> validate_reference {"reference": "176 1234 8210"}
valid=True kind=awb formatted=176-12348210 -> aerolink
> validate_reference {"reference": "CSQU3054384"}
valid=False error=container number CSQU3054384 fails the ISO 6346 check: last digit should be 3, not 4
> track_shipment {"reference": "176-12348210"}
176-12348210 via Aerolink Cargo (simulated): LHR -> SIN
stage=transferred exception=False events=14 from_cache=False
latest=ARR(ACT)@DXB 2026-10-24T16:20:00Z
next=DEP(EST)@DXB 2026-10-24T22:14:00Z
> track_shipment {"reference": "17612348210"}
176-12348210 via Aerolink Cargo (simulated): LHR -> SIN
stage=transferred exception=False events=14 from_cache=True
latest=ARR(ACT)@DXB 2026-10-24T16:20:00Z
next=DEP(EST)@DXB 2026-10-24T22:14:00Z
> track_shipment {"reference": "MSKU6011749"}
MSKU6011749 via Bluewater Container Line (simulated): CNSHA -> NLRTM
stage=departed exception=False events=11 from_cache=False
latest=DEPA(ACT)@CNSHA 2026-10-03T15:52:00Z
next=ARRI(EST)@NLRTM 2026-11-03T20:20:00Z
> get_milestones {"reference": "CSQU6011620", "include_planned": false}
4/9 events
ACT CONF AEJEA 2026-10-15T05:41:00Z
ACT GTIN AEJEA 2026-10-16T13:04:00Z
ACT LOAD AEJEA 2026-10-19T05:34:00Z
ACT DEPA AEJEA 2026-10-23T12:39:00Z
> estimate_arrival {"reference": "020-12345745"}
Expected at NRT around 2026-10-25 14:34 UTC (carrier estimate, 5.1h later than planned).
> compare_quotes {"mode": "air", "origin": "DXB", "destination": "JFK", "weight_kg": 180, "volume_cbm": 1.5}
complete=True cheapest=NOR-A4D427F8 fastest=NOR-2CD8F98E
nordwind Economy USD 1039.63 4 days NOR-A4D427F8
aerolink Standard USD 1216.63 3 days AER-991FDD28
nordwind Express USD 1420.38 2 days NOR-2CD8F98E
aerolink Priority USD 1522.75 2 days AER-E6015AA6
> compare_quotes {"mode": "ocean", "origin": "CNSHA", "destination": "NLRTM", "weight_kg": 9000, "container_type": "40HC"}
complete=True cheapest=HAR-3FA31680 fastest=BLU-8138C377
harborline Economy USD 3090.63 36 days HAR-3FA31680
bluewater Standard USD 3504.48 32 days BLU-0F6D9BD6
bluewater Express USD 3973.91 29 days BLU-8138C377
> track_shipment {"reference": "999-12345675"}
ERROR: [unsupported_reference] AWB 999-12345675 is valid, but no configured carrier handles airline prefix 999. Supported prefixes: 020, 057, 074, 157, 176, 618
```
The second `track_shipment` is a cache hit (`from_cache=True`); the first air shipment is at its
transshipment hub, so its stage is `transferred`, not `arrived`; the 180 kg / 1.5 m³ air shipment is
priced on its 250 kg volumetric weight.
## Design decisions
**Why MCP instead of (only) a REST API.** One server works with every MCP client: the tool
descriptions and JSON Schemas let the model choose the right tool and fill arguments correctly, the
structured results are typed, and the same code runs locally over stdio (no open port, ideal for
Claude Desktop and VS Code) or remotely over Streamable HTTP (ChatGPT, shared deployments). The
service layer is transport-agnostic, so a REST facade would be a thin addition; the carrier sandbox
underneath *is* plain REST/SOAP.
**Canonical model, lossless.** Carriers disagree on everything, so the model keeps each event's
original code and code system (IATA FSU or DCSA) and adds a cross-mode stage on top. Unknown codes
are kept with `stage: null` rather than dropped — carriers add codes, and silently losing an event is
worse than showing an unmapped one. Timestamps are normalised to UTC at the edge (one carrier sends
local offsets, one sends GMT `DDMMMYY HHMM`), weights to kilograms, transit times to days. Arriving at
a transshipment hub is reported as `transferred`, not `arrived`. Some events describe the shipment
without moving it forward: a discrepancy (`DIS`) is an `exception`, and a DCSA inspection (`INSP`) is
`inspected` — an inspection is not a release and can happen at origin or destination, so neither
ever becomes the shipment's current stage.
**Validate before spending anything.** Check digits are verified before routing, so a typo never
costs a cache lookup, a rate-limit token or a carrier call, and the error says which digit was
expected so the model can fix the user's input. Only ASCII digits count (`str.isdigit()` would accept
`²` or Arabic-Indic digits); a Hypothesis test fuzzes the parsers with arbitrary text to check they
raise nothing but `InvalidReferenceError`. Errors surface as MCP tool errors prefixed with a
machine-readable kind (`[invalid_reference]`, `[not_found]`, `[circuit_open]` …); unexpected exceptions
are masked so internals never leak.
**Order of the resilience layers.**
`cache → breaker → deadline → retry → [rate-limit token → attempt timeout → adapter]`. A cache hit
costs nothing and is served even while a carrier's breaker is open. The breaker wraps the whole call,
so one logical call produces one verdict (three attempts against a dead carrier don't trip the
breaker three times faster). The token bucket sits *inside* the retry loop, so retries spend tokens
too and a retry storm can never exceed a carrier's quota (covered by
`test_retries_spend_rate_limit_tokens`).
**Deadlines are carrier faults, and retries respect them.** Every logical call has a deadline
(`CARGOTRACE_TRACK_DEADLINE_S`, 10 s; `CARGOTRACE_QUOTE_DEADLINE_S`, 8 s per carrier in a comparison).
It is applied *inside* the breaker and surfaces as a `timeout` error, so a carrier that hangs counts
as a fault — an earlier version cancelled the call from outside, which the breaker rightly treated
as "no verdict", so a hung carrier was retried on every comparison and never isolated
(`test_a_hanging_carrier_trips_the_breaker_and_stops_being_called` is the regression test). The retry
loop is budget-aware: each attempt's timeout is the smaller of `CARGOTRACE_REQUEST_TIMEOUT_S` and the
time left, and a backoff or `Retry-After` wait is only started if at least a quarter-second attempt
still fits afterwards. So a hung carrier costs at most the deadline (8 s in the captured fault demo),
the worst case of a tool call is bounded by configuration, and settings where a deadline is shorter
than one request timeout are rejected at start-up.
**Rate limiting: token bucket in Redis via Lua.** Carriers publish per-account quotas, which must hold
across every server replica, so the bucket lives in Redis. Refill-and-take runs as a single Lua script:
it is atomic on the Redis server (no read-modify-write race between replicas; a test fires 40
concurrent acquisitions from four bucket instances and exactly `burst` succeed), it uses Redis's
`TIME` so replicas with skewed clocks agree, and an idle bucket's key expires after it would have
refilled completely anyway. If Redis is unreachable the limiter falls back to an in-process bucket —
availability over strictness: during an outage each replica enforces the limit on its own. A call
waits at most `CARGOTRACE_RATE_LIMIT_MAX_WAIT_S` for a token, then gets a `throttled` error instead of
queueing: that error is never retried within the call (retrying it would multiply the wait), though
it stays `retryable` for the client.
**Circuit breaker semantics.** Every outcome gets one of three verdicts. *Fault* — timeouts (including
the deadline), connection errors, 5xx, 429, unparsable payloads, a 404 from a non-lookup endpoint,
adapter bugs: `N` *consecutive* faults open the circuit. *Healthy* — the carrier answered and we asked
for something wrong (404 for an unknown shipment, 400): counts as a success. *No verdict* — the
carrier was never contacted (our own rate limiter refused the call) or the caller cancelled: the
breaker releases the admission without touching its counters, so a locally throttled half-open probe
can neither close nor re-open the circuit. If the limiter stops a *retry* after the carrier already
failed in that call, the carrier's failure is the call's outcome. Open → fail fast (no network, no
token) for the reset timeout. Half-open → exactly one probe; concurrent callers are rejected while it
runs, success closes the circuit, failure re-opens it for a full timeout. Breaker state is per process
on purpose: it is cheap to relearn, and sharing it through Redis would put Redis in every carrier
call's failure domain.
**Retries.** Only errors flagged retryable are retried, at most `CARGOTRACE_RETRY_ATTEMPTS` attempts
(3 attempts = 2 retries). Waits use exponential backoff with full jitter
(`uniform(0, min(max, base·2ⁿ))`) so many clients don't retry in lockstep; a carrier's `Retry-After`
(seconds or HTTP date) is honoured exactly, but if it exceeds `CARGOTRACE_MAX_RETRY_AFTER_S` or the
remaining deadline, the error is returned at once — a tool call that silently blocks for a minute is
worse than "rate limited, retry in 60 s".
**Partial results over all-or-nothing.** Quote comparison uses an `asyncio.TaskGroup` with a deadline
per carrier; every failure mode, including an adapter bug, is converted into a `CarrierFailure` entry
so one bad integration can't sink the comparison. `complete=false` tells the model the list is partial.
**Carrier text is untrusted input.** Whatever a carrier sends back — an error body, a SOAP fault
string, a feed's error message, a value that failed to parse — could contain instructions aimed at the
model reading the tool result. Adapters log such text server-side and expose at most a strictly-shaped
error code (`[A-Z][A-Z0-9_]{1,39}`, e.g. `carrier code INVALID_REQUEST`) or the exception type; tests
assert that injected text in a 400 body, a fault string or a payload value never appears in the error
message.
**Stateful vs. stateless HTTP.** By default Streamable HTTP keeps an MCP session per client in the
server's memory, which is simplest and fine for one instance. `CARGOTRACE_STATELESS_HTTP=true` drops
sessions so any replica can serve any request behind a load balancer, at the cost of server-initiated
messages (none of these tools need them). The tools themselves are stateless; shared state is the
cache and the token buckets, which go to Redis.
**Simulated carriers.** Real carrier APIs require commercial agreements and credentials, can't be
exercised in CI, and won't fail on demand. The sandbox reproduces what actually makes integrations
hard — four genuinely different payload shapes, four different ways of saying "not found" (404, a SOAP
*Client* fault on HTTP 500, an empty list, an `ERR` record on HTTP 200), local times, pounds,
truncated feeds — deterministically (seeded by the reference) and with fault injection, which makes
both the tests and the demo reproducible. Routes only go via a carrier's hub when that adds at most
20% to the distance, and sailing distances use lane factors (e.g. ×2.2 for Asia–Europe via Suez) so
Shanghai–Rotterdam takes about a month, not two weeks.
**Adding a carrier.** Subclass `HttpCarrierAdapter`, declare a `CarrierInfo` (name, routing keys,
rate limit), and implement `track()` / `quote()` to parse the carrier's payload into the canonical
models; pass `shipment_lookup=True` to `_request` for an endpoint whose 404 means "unknown shipment".
[`examples/custom_carrier.py`](examples/custom_carrier.py) is a complete tracking-only adapter for a
CSV feed (the class is 36 non-blank lines) and registers it in the MCP server next to the bundled
carriers with `open_service(settings, extra_adapters=...)`, which hands the factory its own HTTP
client for real network calls. [`tests/test_examples.py`](tests/test_examples.py) shows it
inheriting routing, retries and caching with no extra code, and appearing in `list_carriers`. The
smallest bundled adapter with quotes, [`harborline.py`](src/cargotrace/adapters/harborline.py), is
about 110 non-blank lines including imports.
**Security posture.** Constant-time API-key comparison. The HTTP transport refuses to start when it
is reachable from other machines — a non-loopback bind such as the Docker image's `0.0.0.0`, or a
public hostname in `CARGOTRACE_ALLOWED_HOSTS` — without an API key of at least 24 characters, unless
`CARGOTRACE_ALLOW_UNAUTHENTICATED=true` is set explicitly. Host/Origin validation on loopback binds
(DNS-rebinding protection) with an explicit allowlist for public hostnames; XML parsed with
`defusedxml`; secrets only via environment (`SecretStr`, never logged; Redis passwords redacted in
logs); `.env.example` ships no usable key; compose publishes ports on 127.0.0.1 only; the container
runs as a non-root user.
## Testing
```bash
uv run pytest # 261 tests (1 needs real Redis), ~5 s
CARGOTRACE_TEST_REDIS_URL=redis://localhost:6379/1 uv run pytest # Redis tests against a real server
uv run ruff check . && uv run ruff format --check . && uv run mypy # lint, format, mypy --strict
```
Tests that exercise Redis use a real server when `CARGOTRACE_TEST_REDIS_URL` is set (CI runs Redis 7
as a service container, and a Redis that is configured but unreachable fails the run when `CI` is
set) and fall back to `fakeredis` with Lua support otherwise; they namespace and delete their own
keys. Everything else is deterministic: a fake clock drives breakers, buckets, caches, retry budgets
and ETAs, the sandbox clock is pinned, and no test touches the network. A handful of deadline tests
use real sub-second timeouts against an injected 30 s hang.
| Area | What is covered |
|---|---|
| Identifiers | Known-good/bad AWBs and containers; Hypothesis properties (randomly drawn valid numbers pass; single-digit substitutions are caught except mod-7's ±7 blind spot and ISO 6346's 10→0 one); differential test against an independent ISO 6346 oracle; fuzzing with arbitrary text and non-ASCII digits |
| Adapters | Hand-written sample payloads in each carrier's format (`tests/fixtures`) → canonical model: offsets, pounds, unordered events, SOAP faults, truncated feeds, unknown codes, non-USD quotes, `null`s and non-numeric amounts; HTTP status, 404-meaning and transport-error mapping; carrier text kept out of error messages |
| Resilience | Token bucket burst/refill/isolation/atomicity (in-memory and Redis); breaker transitions with a fake clock (half-open single probe, cancellation, stragglers, three-way verdicts, throttled probes); retry classification, `Retry-After`, give-up cap, deadline budget |
| Service | Contract test of every adapter against the sandbox; caching (including cache hits while a breaker is open); retries; breaker open → fast-fail → recovery; rate-limit fail-fast without retrying; throttled retries and probes; per-attempt timeouts; per-call deadlines; a hung carrier opening the breaker; partial quote results; adapter-bug isolation |
| MCP end-to-end | In-memory `fastmcp.Client` calling every tool, resource, template and the prompt, validating structured output against the Pydantic models; error surfacing; a custom carrier registered in the server |
| HTTP transport | Real Starlette app in-process: 401 without/with wrong key, authenticated calls, `/healthz`, Host-header 421, stateless mode without session ids; CLI refusal of unauthenticated or weak-key exposure |
| Configuration | A copied `.env.example` leaves the stdio defaults intact; blank values mean unset; deadline validation |
| Sandbox | Determinism, wire formats, not-found conventions, fault injection (admin API, query string, env), hub detours, plausible transit times |
Verified locally on Python 3.12.13 and 3.13.17: `ruff check`, `ruff format --check` and
`mypy --strict` clean; 261 tests passing on both versions with `CARGOTRACE_TEST_REDIS_URL` pointing at
Redis 7.
## Project structure
```text
cargotrace-mcp/
├── src/cargotrace/
│ ├── server.py FastMCP server: tools, resources, prompt, API-key auth, /healthz
│ ├── service.py TrackingService: validation, routing, policy pipeline, quote fan-out
│ ├── models.py Pydantic v2 domain models (AirWaybill, ContainerNumber, Milestone, …)
│ ├── identifiers.py AWB mod-7 and ISO 6346 check-digit algorithms
│ ├── codes.py IATA FSU + DCSA milestone catalogue and canonical stages
│ ├── eta.py Arrival estimation from a canonical timeline
│ ├── registry.py AWB prefix / owner code → adapter routing
│ ├── cache.py Redis TTL cache with in-memory fallback
│ ├── resilience/ rate_limit.py (token bucket, Redis Lua), breaker.py, retry.py
│ ├── adapters/ base.py + aerolink (FSU JSON), nordwind (SOAP XML),
│ │ bluewater (DCSA JSON), harborline (pipe feed)
│ ├── sandbox/ FastAPI carrier sandbox: app, synthetic data, formats, faults
│ ├── runtime.py Composition root (Settings → object graph, extra adapters)
│ ├── config.py pydantic-settings configuration and HTTP exposure checks
│ ├── clock.py · errors.py · cli.py
├── tests/ 261 tests + carrier payload fixtures
├── examples/ demo client, fault demo, custom carrier, client configs
├── docs/ demo.md (full captured transcripts), deployment.md (tunnel, ACA)
├── .vscode/mcp.json VS Code / Copilot servers (stdio + HTTP)
├── .github/workflows/ci.yml lint, mypy, tests (3.12 + 3.13, Redis service), Docker build + smoke run
├── Dockerfile · docker-compose.yml · Makefile · pyproject.toml · uv.lock
```
## Configuration
All settings are environment variables (a `.env` file in the working directory is read too; see
[`.env.example`](.env.example)). Blank values mean "unset".
| Variable | Default | Purpose |
|---|---|---|
| `CARGOTRACE_API_KEY` | unset | Bearer key required on `/mcp` when set (HTTP only); ≥ 24 characters when exposed |
| `CARGOTRACE_ALLOW_UNAUTHENTICATED` | `false` | Allow serving HTTP to other machines without a key (explicit opt-in) |
| `CARGOTRACE_REDIS_URL` | unset | Redis for the shared token bucket and cache; unset = in-memory |
| `CARGOTRACE_CARRIER_BASE_URL` | unset | Carrier sandbox URL; unset = sandbox served in-process |
| `CARGOTRACE_HTTP_HOST` / `_PORT` / `_PATH` | `127.0.0.1` / `8101` / `/mcp` | HTTP bind and endpoint (`--host/--port/--path` override) |
| `CARGOTRACE_ALLOWED_HOSTS` | empty | Comma-separated public hostnames accepted in the `Host` header |
| `CARGOTRACE_STATELESS_HTTP` | `false` | Stateless Streamable HTTP (no session affinity needed across replicas) |
| `CARGOTRACE_REQUEST_TIMEOUT_S` | `5.0` | Timeout per carrier HTTP attempt |
| `CARGOTRACE_TRACK_DEADLINE_S` | `10.0` | Deadline for a whole tracking call (attempts, waits, backoff); ≥ request timeout |
| `CARGOTRACE_QUOTE_DEADLINE_S` | `8.0` | Same, per carrier in `compare_quotes`; ≥ request timeout |
| `CARGOTRACE_RETRY_ATTEMPTS` | `3` | Attempts per logical call (1 = no retries) |
| `CARGOTRACE_RETRY_BASE_DELAY_S` / `_MAX_DELAY_S` | `0.2` / `2.0` | Backoff base and cap (full jitter) |
| `CARGOTRACE_MAX_RETRY_AFTER_S` | `5.0` | Longest carrier `Retry-After` we will wait for |
| `CARGOTRACE_RATE_LIMIT_MAX_WAIT_S` | `2.0` | Longest a call waits for a local rate-limit token |
| `CARGOTRACE_RATE_LIMIT_OVERRIDES` | `{}` | JSON, e.g. `{"nordwind": {"rate_per_second": 1, "burst": 2}}` |
| `CARGOTRACE_BREAKER_FAILURE_THRESHOLD` | `5` | Consecutive carrier faults that open a circuit |
| `CARGOTRACE_BREAKER_RESET_TIMEOUT_S` | `30.0` | Open → half-open delay |
| `CARGOTRACE_TRACKING_CACHE_TTL_S` / `_QUOTE_CACHE_TTL_S` | `120` / `600` | Cache lifetimes (0 disables) |
| `CARGOTRACE_LOG_LEVEL` | `INFO` | Log level (logs go to stderr) |
| `SANDBOX_HOST` / `SANDBOX_PORT` | `127.0.0.1` / `8102` | Sandbox bind address |
| `SANDBOX_FAULTS` | empty | Initial fault plan, JSON keyed by carrier |
| `SANDBOX_FROZEN_NOW` | unset | Pin the sandbox clock (ISO-8601) for reproducible timelines |
Default per-carrier rate limits (tokens per second / burst): aerolink 10/20, nordwind 2/4, bluewater
8/16, harborline 5/5.
The sandbox's admin API — `GET /admin/faults`, `PUT /admin/faults/{carrier}` (JSON body:
`latency_ms`, `status_code`, `retry_after_s`, `remaining`), `DELETE /admin/faults/{carrier}`,
`DELETE /admin/faults` and `GET /admin/stats` — is unauthenticated by design: it is a development
tool, so don't expose port 8102 beyond your machine (compose publishes it on 127.0.0.1 only).
## Roadmap and limitations
- **No real carriers.** All four integrations are simulated; real ones need commercial agreements and
would plug in through the same adapter interface (`extra_adapters`).
- **Client apps are not exercised automatically.** Tests use FastMCP's client over the in-memory,
HTTP and stdio transports; Claude Desktop, VS Code Copilot and ChatGPT have to be tried by hand.
- **Authentication** is a single static API key. Per-user OAuth (FastMCP auth providers) and scopes are
the next step, and would also enable ChatGPT connectors with authentication.
- **No request coalescing.** Concurrent identical requests on a cold cache each reach the carrier
(bounded by the token bucket); a per-key single-flight would remove the duplicates.
- **ETAs come only from carrier events** (actual > estimate > plan). There is no predictive model and no
ETA is invented when the carrier has published none.
- **USD only.** Non-USD quotes are rejected instead of being mis-ranked; FX conversion is not implemented.
- **Breaker state is per process**; rate limits are shared only when Redis is configured.
- **Sandbox realism:** timelines are anchored to the current UTC day, daylight saving is ignored for
local times, the legacy feed is assumed to be GMT, and distances, prices and transit times are
synthetic approximations.
- **No history or push.** Shipments are fetched on demand; MCP resource subscriptions or webhooks for
milestone changes would be a natural extension.
- CI builds the Docker image, checks it refuses an unauthenticated public bind, and starts it with a
key to probe `/healthz` and the 401; the compose stack itself is not exercised by CI.
## License
[MIT](LICENSE) © 2026 Anisha V. Shah
This server cannot be deployed
Maintenance
ActivityMaintained
ResponsivenessNo issues