Skip to main content
Glama
AnishaRamanuj

cargotrace-mcp

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.

CI License: MIT Python MCP Typing Lint

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.

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_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

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=false

Quickstart

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 needed

Run 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-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 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"}'
421

Every 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, 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 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 --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, nulls 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

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). 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 © 2026 Anisha V. Shah

Related MCP Connectors

Related MCP Servers

  • A
    license
    Not graded
    quality
    D
    maintenance
    Ocean 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
  • A
    license
    Not graded
    quality
    B
    maintenance
    Provides 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 npm
    48 PyPI
    MIT
  • A
    license
    Not graded
    quality
    F
    maintenance
    Enables 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 npm
    MIT
  • A
    license
    A
    quality
    B
    maintenance
    Enables 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.
    9
    253 npm
    MIT