cargotrace-mcp
Click on "Deploy Server".
Wait a few minutes for the server to deploy. Once ready, it will show a "Started" state.
In the chat, type
@followed by the MCP server name and your instructions, e.g., "@cargotrace-mcpWhere is container MSKU6011672 and when will it arrive?"
That's it! The server will respond to your query, and you can continue using it as needed.
Here is a step-by-step guide with screenshots.
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.
Python 3.12 · FastMCP · FastAPI · Pydantic v2 · httpx · Redis · Docker · GitHub Actions
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.
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.
Related MCP server: Logistics AI MCP
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_shipmentCurrent stage, latest and next event, full normalised timeline for an AWB or container
get_milestonesTimeline filtered to actual events and/or one stage
estimate_arrivalETA with its basis (actual / carrier estimate / planned), confidence and delay vs. plan
compare_quotesConcurrent fan-out to every carrier for the mode; cheapest-first, with per-carrier errors
list_carriersCarriers, routing keys, rate limits and live circuit-breaker state
validate_referenceOffline AWB / ISO 6346 check-digit validation and routing preview
Resources and a prompt:
cargotrace://carriers,cargotrace://milestone-codes, the templatecargotrace://milestone-codes/{code}, and ashipment_briefingprompt for customer- or operations-facing status updates.Check digits: AWB mod-7 (
176-12345675,17612345675and176 1234 5675all 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
Milestonemodel 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
DDMMMYYdates 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
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:
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=falseQuickstart
Requires uv. Redis is optional (used for the shared rate limiter and
cache when CARGOTRACE_REDIS_URL is set).
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 neededRun it over HTTP exactly as in the demo below (three terminals, all in the repository root):
# 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-demoOr 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
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:
{
"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
# 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.
VS Code + GitHub Copilot
This repository ships .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.
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 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:
$ 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"}'
421Every tool over Streamable HTTP — examples/demo_client.py prints one
summary per call, derived from the structured result:
$ 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, 618The 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 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 shows it
inheriting routing, retries and caching with no extra code, and appearing in list_carriers. The
smallest bundled adapter with quotes, 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
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 --strictTests 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 ( |
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, |
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 |
HTTP transport | Real Starlette app in-process: 401 without/with wrong key, authenticated calls, |
Configuration | A copied |
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
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.lockConfiguration
All settings are environment variables (a .env file in the working directory is read too; see
.env.example). Blank values mean "unset".
Variable | Default | Purpose |
| unset | Bearer key required on |
|
| Allow serving HTTP to other machines without a key (explicit opt-in) |
| unset | Redis for the shared token bucket and cache; unset = in-memory |
| unset | Carrier sandbox URL; unset = sandbox served in-process |
|
| HTTP bind and endpoint ( |
| empty | Comma-separated public hostnames accepted in the |
|
| Stateless Streamable HTTP (no session affinity needed across replicas) |
|
| Timeout per carrier HTTP attempt |
|
| Deadline for a whole tracking call (attempts, waits, backoff); ≥ request timeout |
|
| Same, per carrier in |
|
| Attempts per logical call (1 = no retries) |
|
| Backoff base and cap (full jitter) |
|
| Longest carrier |
|
| Longest a call waits for a local rate-limit token |
|
| JSON, e.g. |
|
| Consecutive carrier faults that open a circuit |
|
| Open → half-open delay |
|
| Cache lifetimes (0 disables) |
|
| Log level (logs go to stderr) |
|
| Sandbox bind address |
| empty | Initial fault plan, JSON keyed by carrier |
| 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
/healthzand the 401; the compose stack itself is not exercised by CI.
License
MIT © 2026 Anisha V. Shah
This server cannot be deployed
Maintenance
Related MCP Connectors
Neutral freight reference + validation layer for AI agents: ADR, HS, UN/LOCODE, freight math
Multi-carrier shipping for AI agents: compare rates, buy labels, track packages, validate addresses
Ocean & multimodal freight intelligence: rates, landed cost, transit, customs, risk, ship decisions
AI-native platform: cybernetic freight agents transact, verify, find services. Get verified leads.
Related MCP Servers
- AlicenseNot gradedqualityDmaintenanceOcean container shipping intelligence for AI agents — D\&D tariffs, freight rates, vessel schedules, port congestion, inland haulage across 6 major carriers. 24 MCP tools.MIT
- AlicenseNot gradedqualityBmaintenanceProvides supply chain and shipping tools including shipment tracking, route optimization, warehouse inventory management, delivery ETA estimation, and customs documentation. Enables logistics operations through natural language interactions with Claude.4 npm48 PyPIMIT

Easyship MCPofficial
AlicenseNot gradedqualityFmaintenanceEnables AI agents to manage global shipping operations, including rate comparison, shipment creation, label purchasing, tracking, pickup scheduling, address validation, billing, and analytics, via natural language.57 npmMIT- AlicenseAqualityBmaintenanceEnables AI assistants to query and manage logistics shipment data, supporting operations like tracking, quoting, and performance analysis via nine tools backed by a demo dataset or a REST API.9253 npmMIT