pc-express-mcp
by akbaruddink
README.md
# pc-express-mcp
[](LICENSE)
<!-- Add a CI status badge here once this is pushed to GitHub:
[](https://github.com/<you>/pc-express-mcp/actions/workflows/tests.yml) -->
An unofficial [MCP](https://modelcontextprotocol.io) server that wraps PC Express
(Loblaws' online grocery platform — Real Canadian Superstore, Loblaws, No
Frills, Zehrs, Your Independent Grocer, T&T) so an LLM can search products
and manage your cart for you.
**Not affiliated with, endorsed by, sponsored by, or officially connected
with Loblaw Companies Limited, PC Express, or any of their banners/brands
in any way.** All trademarks referenced belong to their respective owners.
This talks to an undocumented, reverse-engineered API and can break
without notice. See [Limitations & risks](#limitations--risks) and
[docs/LEGAL.md](docs/LEGAL.md) before you rely on it, fork it, or host it
for anyone but yourself.
**This tool does not place orders or submit payment.** It fills your cart
and gives you a checkout link to finish yourself, in the PC Express app or a
browser — see [docs/ARCHITECTURE.md](docs/ARCHITECTURE.md#place_order-never-submits-payment).
**If you turn on the optional remote/HTTP mode, read
[Remote/mobile access](#remotemobile-access-optional-for-the-claude-iosandroid-app)
in full first** — it's open self-service multi-tenancy by default (anyone
with a PC Express account can provision themselves access to whatever
you're hosting this on), which is a materially different risk profile from
the default local mode.
<details>
<summary><strong>Contents</strong></summary>
- [Quick start](#quick-start)
- [Usage](#usage)
- [Remote/mobile access](#remotemobile-access-optional-for-the-claude-iosandroid-app)
- [Limitations & risks](#limitations--risks)
- [Development](#development)
- [Learn more](#learn-more)
- [Project layout](#project-layout)
</details>
## Quick start
**1. Install dependencies.** [`uv`](https://docs.astral.sh/uv/) is
recommended — it's what this project is built and tested with, and the only
option that installs the exact, fully-pinned dependency versions in
`uv.lock` (see [Dependency policy](#dependency-policy)):
```bash
cd pc-express-mcp
uv sync
```
Or plain `pip`:
```bash
cd pc-express-mcp
python3 -m venv .venv && source .venv/bin/activate
pip install -e .
# or: pip install -r requirements.txt
```
Requires Python 3.10+. Add the `http` extra (`-e ".[http]"` / `uv sync
--extra http`) only if you want [remote/mobile access](#remotemobile-access-optional-for-the-claude-iosandroid-app).
**2. Configure `.env`:**
```bash
cp .env.example .env
```
- `PCEXPRESS_BANNER` / `PCEXPRESS_STORE_ID` — `store_id` has no search API
(see [docs/RESEARCH.md](docs/RESEARCH.md)); find yours once via your
banner's store locator (e.g.
`https://www.realcanadiansuperstore.ca/store-locator`), or set it later
via the `set_active_store` tool.
- `PCEXPRESS_CLIENT_ID` / `PCEXPRESS_CLIENT_SECRET` — already have working
defaults baked into `config.py` (see
[docs/ARCHITECTURE.md](docs/ARCHITECTURE.md#why-the-oauth-client-idsecret-are-baked-into-configpy));
you normally don't need to set either.
- Load `.env` into your shell however you prefer (`export $(cat .env |
xargs)`, `direnv`, or your MCP client's env-var passthrough).
**3. One-time login** (opens a real browser to the real PC ID login page —
your password never touches this codebase):
```bash
python scripts/login.py
```
Follow the printed instructions: log in, then paste back the
`com.loblaw.pcx://...` redirect URL it fails to open. Tokens get written to
`~/.pcexpress-mcp/auth_state.json` (or `$PCEXPRESS_STATE_DIR`), `chmod 600`.
**4. Run the server:**
```bash
python -m pc_express_mcp.server
```
**5. Point an MCP client at it.** Claude Desktop
(`claude_desktop_config.json`):
```json
{
"mcpServers": {
"pc-express": {
"command": "/absolute/path/to/pc-express-mcp/.venv/bin/python",
"args": ["-m", "pc_express_mcp.server"],
"env": {
"PCEXPRESS_BANNER": "superstore",
"PCEXPRESS_STORE_ID": "1531",
"PCEXPRESS_STATE_DIR": "/absolute/path/to/home/.pcexpress-mcp"
}
}
}
}
```
Run `scripts/login.py` from a normal terminal first (it needs a real
browser) — Claude Desktop launches the server directly and can't complete
the interactive login for you.
Want this reachable from the Claude iOS/Android app instead of a local
subprocess? See [Remote/mobile access](#remotemobile-access-optional-for-the-claude-iosandroid-app).
## Usage
Search results and cart items carry a ready-to-paste `photo_markdown`
field, so products actually show up as photos in the chat instead of
plain text — see [docs/RESEARCH.md](docs/RESEARCH.md#product-photos-in-chat-why-plain-markdown-not-mcp-imageui-features)
for why that's plain markdown rather than an MCP-specific image/UI
mechanism. `interactive_product_search` goes a step further on clients
that support it — see below and [docs/RESEARCH.md](docs/RESEARCH.md#interactive-product-search-widget-mcp-apps).
| Tool | Spends money? | Notes |
|---|---|---|
| `list_stores` | No | Returns known/validated stores; no search API exists |
| `set_active_store(store_id, banner?)` | No | Validates against the live pickup-locations endpoint. Warns (`cart_note`) if this account's cart is currently bound to a *different* store — call `switch_cart_store` to fix it; see [docs/RESEARCH.md](docs/RESEARCH.md#the-real-fix-switch_cart_store-found-from-a-user-supplied-real-capture) |
| `get_store_hours(store_id?)` | No | Open/closed + today's hours, fetched fresh (deliberately not cached) |
| `get_loyalty_status` | No | Real PC Optimum points balance + stamp-card status. **Not** an available-offers feed — no such endpoint exists in this API, see [docs/RESEARCH.md](docs/RESEARCH.md#loyalty-offers-no-dedicated-endpoint-found) |
| `search_products(query, size?, offset?, include_nutrition?)` | No | At most `size` results in PC Express's ranking, with real pagination (`offset`, `has_more`) — see [docs/RESEARCH.md](docs/RESEARCH.md#search-pagination). Per result: description, barcode, unit price, deals, photo. No nutrition from PC Express; `include_nutrition` attaches Open Food Facts data (patchy) |
| `interactive_product_search(query?, product_codes?, size?)` | No | Provide exactly one of `query` (a normal search) or `product_codes` (a curated list of specific products you already know about — how Claude shows the user exactly what it's recommending, not just search results). On a client that renders [MCP Apps](https://github.com/modelcontextprotocol/ext-apps) UI (confirmed live, Add-to-Cart round trip included: Claude Desktop, claude.ai web, and the Claude mobile app), shows results as an interactive widget with per-item Add-to-Cart buttons and tap-to-cycle through each product's photos, instead of text. Degrades automatically to full `search_products`-equivalent text/photos on any client that doesn't support it, so it's always safe to call. See [docs/RESEARCH.md](docs/RESEARCH.md#interactive-product-search-widget-mcp-apps) |
| `get_nutrition_info(barcode)` | No | Nutrition facts, ingredients, allergens, Nutri-Score/NOVA grade via [Open Food Facts](https://openfoodfacts.org) (a separate, free database — not PC Express data). "Not found" is common and expected, not a bug — see [docs/RESEARCH.md](docs/RESEARCH.md#nutrition-enrichment-open-food-facts) |
| `get_cart` | No | Items plus the cart's bound `store_id`, `fulfillment_method`, booked `slot` and a `totals` breakdown (subtotal, tax, fees, tip). `status: "NO_CART"` right after checkout |
| `add_to_cart(items)` | No | `items`: list of `{product_code, quantity?, fulfillment_method?}`. **Adds to** existing quantities. Bare codes from history/orders are resolved; mode defaults to the cart's. Returns `applied`/`rejected` per code (PC Express silently ignores codes it can't use) and errors if nothing was added |
| `remove_from_cart(product_codes)` | No | Remove several products in one call; codes not in the cart come back in `rejected` |
| `update_quantity(items)` | No | `items`: list of `{product_code, quantity}` — set exact quantities; `quantity=0` removes |
| `switch_cart_store(store_id, postal_code, confirm?)` | No | Re-binds this banner's cart to another store (the fix for `cart_store_mismatch`) and makes it the active store. The cart is shared account-wide, so a non-empty one needs `confirm=True`; reports `previous_store_id`, `items_repriced`, `items_dropped`. See [docs/RESEARCH.md](docs/RESEARCH.md#cart-is-bound-to-a-single-store) |
| `get_available_slots(date?, days?)` | No | Available delivery slots for the cart's store and delivery address, with this account's own fees, from the website's checkout service. See [docs/RESEARCH.md](docs/RESEARCH.md#delivery-slots-and-checkout-the-websites-checkout-service) |
| `book_delivery_slot(date, start_time)` | No | Holds a slot on the cart (expires ~1 hour later unless checkout completes). Superstore only so far |
| `place_order(confirm)` | **No** | Requires `confirm=True`. Validates the cart and returns the checkout page's real totals (tax, fees, tip, held slot) plus the checkout link. Never submits payment. |
| `get_order_status(order_id?, limit?)` | No | Recent orders, or one order by number: suffixed line `code`s, tips/stamps split into `adjustments`, totals that reconcile. New orders can take hours to reach the list; `status` is never populated upstream |
| `get_purchase_history(limit?, max_orders_scanned?, top_n?)` | No | What this household buys at the active store, most-bought first: cart-ready codes, weight for weighed items, tips excluded, usual `fulfillment_types`. Cross-check cart picks against this. See [docs/RESEARCH.md](docs/RESEARCH.md#get_purchase_history-catering-cart-picks-to-what-the-household-actually-buys) |
All 17 tools carry proper MCP tool annotations (`readOnlyHint`/`destructiveHint`/
`idempotentHint`/`openWorldHint`), so any MCP client can categorize them the
way it would Gmail/other well-built MCP servers — `place_order` is flagged
non-read-only *and* destructive on purpose, since it's the closest thing to
a "spends money" action here even though it never actually submits payment.
Every response is trimmed of low-value bulk before being returned to the
model (a raw order detail is ~15x smaller after simplification, a raw store
lookup ~35x) — see [docs/RESEARCH.md](docs/RESEARCH.md#response-size-verification).
## Remote/mobile access (optional, for the Claude iOS/Android app)
By default this server runs over **stdio** (a local subprocess) — that's
what Claude Desktop and Claude Code use, and it's all you need on a laptop.
**Claude's mobile apps cannot spawn local processes; they only support
*remote* MCP servers**, added as a custom connector via claude.ai on the
web (settings then sync to mobile). To use this from the iOS/Android app,
the server has to run somewhere internet-reachable instead.
`python -m pc_express_mcp.server --http` (or `PCEXPRESS_HTTP=1`) serves the
same tools over **Streamable HTTP**, gated by a self-service, multi-tenant
OAuth 2.1 + PKCE authorization server this project runs itself
(`oauth_server.py`). Full design rationale (why not a static bearer header,
why open self-service instead of an allowlist, and the four design
iterations that got here) is in
[docs/ARCHITECTURE.md](docs/ARCHITECTURE.md#http-mode--multi-tenant-oauth-server-design-history)
— the summary:
**Open self-service, no allowlist, no Dynamic Client Registration.**
Whatever email is typed into Claude's "Add custom connector" > Advanced
settings > OAuth Client ID field (no client_secret — PKCE covers a
public/native client, leave it blank) is treated as a *claim*, not a fact.
`/authorize` consolidates a real PC ID login into the same page and only
approves the connector if the account that logs in there matches the
claimed email exactly. On a match, that person's PC Express credentials are
encrypted directly into the OAuth token minted for them — **never mixed
with anyone else's, never written to this server's disk at all** (see
[docs/SECURITY.md](docs/SECURITY.md) for exactly what that does and doesn't
protect against). **No operator action is needed per new person** — anyone
who can log into a PC Express account can start using the server as that
account.
> **Read this before turning HTTP mode on.** Self-service multi-tenancy is
> a real scope change from "personal tool for my own account" — anyone
> with a (free, instantly-creatable) PC Express account can self-provision
> access to whatever server you're running this on. That means your
> compute, your bandwidth, and your IP/hosting reputation are now exposed
> to however many strangers' Loblaws activity ends up flowing through it.
> It also means every tenant's login/refresh traffic shares the same
> borrowed Android app `client_id`/secret — if Loblaw's fraud systems flag
> unusual volume on that credential and rotate or ban it, **every tenant
> loses access simultaneously**, not just whoever triggered it. There is no
> rate limiting, no per-tenant quota, and no abuse detection implemented
> (see [docs/SECURITY.md](docs/SECURITY.md)). If you only want to share
> this with a specific few people you trust, doing that safely would need a
> smaller change (an allowlist checked before approving `/authorize`) that
> isn't what's built here — this is genuinely open to anyone who finds the
> URL and has a PC Express account. **Deploying your own instance never
> shares infrastructure with anyone else's** — "self-hosting" here means
> exactly that: your server, your `PCEXPRESS_TOKEN_SECRET`, your tenants.
Install the extra dependency first: `pip install -e ".[http]"` (or `uv sync
--extra http`), then generate the token-encryption secret once:
```bash
python scripts/generate_secret.py
# put the output in your .env as PCEXPRESS_TOKEN_SECRET=...
```
```bash
PCEXPRESS_PUBLIC_URL=https://pcexpress.yourdomain.com \
PCEXPRESS_TOKEN_SECRET=<output from generate_secret.py> \
python -m pc_express_mcp.server --http
```
- `PCEXPRESS_PUBLIC_URL` — your server's public `https://` origin, no
trailing slash. Required: used to build the OAuth discovery metadata
Claude fetches, which must exactly match the URL Claude used to reach you.
- `PCEXPRESS_TOKEN_SECRET` — required. The single key that encrypts every
tenant's PC Express credentials into their OAuth token. Generate it once
with `scripts/generate_secret.py`; never commit it; rotating it logs
everyone out at once.
- `PCEXPRESS_HTTP_HOST` still defaults to `127.0.0.1` (put a reverse proxy
in front — see hosting options below); only set it to `0.0.0.0` if that
proxy runs on a *different* host from this process.
- No account needs to be pre-configured — that's the whole point of
self-service. There is no `PCEXPRESS_OAUTH_CLIENT_ID` anymore.
### Setting it up in Claude
1. On claude.ai web: Settings → Connectors → Add custom connector.
2. URL: `https://pcexpress.yourdomain.com/mcp` (note the `/mcp` path).
3. Open "Advanced settings" → **OAuth Client ID**: your own PC Express
login email → leave **OAuth Client Secret blank**.
4. Claude redirects you to `/authorize`, which shows PC ID login
instructions right there on the page: a link to the real PC ID login
(opens in a new tab), and a box to paste back the redirect once you're
done. If the account you log into matches step 3, it redirects back to
Claude with a working connection. If it's a *different* PC Express
account, it's rejected.
5. It'll sync to the iOS/Android app automatically (custom connectors can
only be *added* on web/desktop, not from mobile — but work from mobile
once added).
Claude Code can use the same OAuth flow directly (it declares its own
loopback callback, which `/authorize` also accepts) if you'd rather verify
against a client with a friendlier debugging story than the mobile app
before fighting the web UI.
### Before exposing this to the internet at all
- Put a real reverse proxy with a valid TLS certificate in front of it
(Caddy, nginx + certbot, Cloudflare Tunnel). This server does not
terminate TLS itself.
- If your hosting supports IP allowlisting, Anthropic's MCP traffic
originates from `160.79.104.0/21` — restricting inbound access to that
range (plus your own IP for testing) meaningfully shrinks the exposure
versus leaving it open to the whole internet.
- Understand what's now at stake: this is genuinely internet-reachable, and
every connected tenant's tokens flow through whatever host runs this. See
[Limitations & risks](#limitations--risks) and
[docs/SECURITY.md](docs/SECURITY.md).
### Self-hosting with Docker
`docker build .` alone (or `docker run` with no extra flags) defaults to
**stdio mode** — the same safe-by-default posture as running this package
directly with no arguments: no network exposure, no OAuth server. HTTP mode
is opt-in, one level up, in `docker-compose.yml`, so that choice is
explicit rather than something a container defaults into silently.
```bash
cp .env.example .env
# fill in PCEXPRESS_PUBLIC_URL, PCEXPRESS_TOKEN_SECRET
# (python scripts/generate_secret.py), and PCEXPRESS_DOMAIN (bare domain,
# for Caddy's automatic HTTPS)
docker compose up -d --build
```
This starts two containers: the app itself (no host ports published — only
reachable from Caddy over the compose network) and Caddy, which requests a
Let's Encrypt certificate for `PCEXPRESS_DOMAIN` and reverse-proxies to the
app. Point that domain's DNS at this host first.
**No volume is mounted for the app, and that's deliberate**: HTTP mode
keeps zero persistent state on disk — every tenant's PC Express credentials
live only inside the encrypted OAuth token they're holding. The app
container is fully disposable: `docker compose up -d --build` again after a
code change recreates it with zero data loss beyond forcing currently-
connected tenants to reconnect.
Verify: `curl https://your-domain/health` → `{"status":"ok"}`, then point
Claude's "Add custom connector" at `https://your-domain/mcp`.
Want stdio mode in a container instead (e.g. so Claude Desktop doesn't need
a local Python install)? Use the plain `Dockerfile` build, with
`~/.pcexpress-mcp` mounted so `scripts/login.py`'s token survives container
restarts:
```bash
docker build -t pc-express-mcp .
docker run -i --rm -v ~/.pcexpress-mcp:/home/pcexpress/.pcexpress-mcp pc-express-mcp
```
### One-click deploy (Fly.io)
`fly.toml` is checked in, pre-configured for HTTP mode — chosen because its
free/hobby tier supports this project's Dockerfile directly with no extra
build config, and needs **no persistent volume at all** (thanks to the
stateless credential design), which several alternatives require a paid
tier to get.
```bash
# Install: https://fly.io/docs/flyctl/install/
fly auth login
fly launch --no-deploy # detects fly.toml; pick your own app name when asked
fly secrets set \
PCEXPRESS_PUBLIC_URL=https://<your-app-name>.fly.dev \
PCEXPRESS_TOKEN_SECRET=$(python scripts/generate_secret.py)
fly deploy
```
Verify the same way: `curl https://<your-app-name>.fly.dev/health`, then
`https://<your-app-name>.fly.dev/mcp` as the connector URL in Claude.
Want a custom domain? `fly certs add pcexpress.yourdomain.com`, point a
CNAME at your Fly app, then update the `PCEXPRESS_PUBLIC_URL` secret to
match.
Railway, Render, and similar PaaS platforms should work too (same
Dockerfile, same "no volume needed, set two env vars" shape) — Fly.io is
just the one this project ships config for.
### Hosting on a home machine: Cloudflare Tunnel
If you're running this on a machine you already own and control, a
Cloudflare Tunnel gets you a real HTTPS URL **without opening any inbound
port on your router** — `cloudflared` makes an outbound-only connection to
Cloudflare, which proxies HTTPS traffic to it. Requires a domain added to
Cloudflare (free plan is fine).
```bash
# 1. Install cloudflared (see https://developers.cloudflare.com/cloudflare-one/connections/connect-networks/downloads/)
# 2. Authenticate and create a named tunnel
cloudflared tunnel login
cloudflared tunnel create pcexpress-mcp
# 3. ~/.cloudflared/config.yml
cat <<'EOF' > ~/.cloudflared/config.yml
tunnel: <tunnel-id-from-step-2>
credentials-file: /home/you/.cloudflared/<tunnel-id>.json
ingress:
- hostname: pcexpress.yourdomain.com
service: http://127.0.0.1:8090
- service: http_status:404
EOF
# 4. Point DNS at the tunnel
cloudflared tunnel route dns pcexpress-mcp pcexpress.yourdomain.com
# 5. Run the tunnel (add --service install-style if you want it to survive reboots)
cloudflared tunnel run pcexpress-mcp
```
In a separate terminal (or a systemd unit), run the MCP server itself.
**Keep `PCEXPRESS_HTTP_HOST` at its default (`127.0.0.1`)** — `cloudflared`
connects to it locally, so it should never need to listen on
`0.0.0.0`/your LAN. `0.0.0.0` is only for a reverse proxy or container on a
*separate* host:
```bash
PCEXPRESS_PUBLIC_URL=https://pcexpress.yourdomain.com \
python -m pc_express_mcp.server --http
```
Verify from an outside network (e.g. your phone on cellular data) before
touching Claude at all: `curl https://pcexpress.yourdomain.com/health` →
`{"status":"ok"}`.
### Hosting on a VPS with a public IP (Caddy)
If you already have a VPS (no NAT/router to work around), point an A
record at the VPS's IP, run Caddy as a reverse proxy in front of the server
(still bound to `127.0.0.1`, since Caddy runs on the same box), and Caddy
handles Let's Encrypt automatically:
```
# /etc/caddy/Caddyfile
pcexpress.yourdomain.com {
reverse_proxy 127.0.0.1:8090
}
```
Run the MCP server itself as a systemd service so it survives reboots and
restarts on crash — an `EnvironmentFile` pointed at a `chmod 600` file
holding `PCEXPRESS_PUBLIC_URL` and `PCEXPRESS_TOKEN_SECRET` (plus
`PCEXPRESS_HTTP_HOST=127.0.0.1`/`PCEXPRESS_HTTP_PORT=8090`, both already
defaults) keeps the unit file itself free of anything sensitive.
`sudo systemctl reload caddy` after editing the Caddyfile; it obtains the
cert on first request to the new domain. This exact setup is what this
project's own live deployment runs — see
[docs/DEPLOYMENT.md](docs/DEPLOYMENT.md) for the real bugs that surfaced
building it and the full verification record.
## Limitations & risks
- **HTTP mode is a materially different risk than the default stdio mode.**
Only run `--http` if you've read [Remote/mobile access](#remotemobile-access-optional-for-the-claude-iosandroid-app)
in full and deliberately want your PC Express tokens on an
internet-reachable host. Default to stdio unless you specifically need
mobile access.
- **Reverse-engineered, undocumented API.** Every endpoint and OAuth
constant here was sourced from third-party writeups, not official docs
(see [docs/RESEARCH.md](docs/RESEARCH.md)). Loblaw can change endpoints,
response shapes, or rotate the Android app's client secret at any time,
silently breaking this tool.
- **Terms of Service risk.** Automated access to a retailer's ordering
platform via a reverse-engineered API is very likely outside what
Loblaws' website/app Terms of Use contemplate as acceptable use, even for
personal, low-volume use on your own account. This project does not
attempt to bypass Akamai or any other bot-detection system (the one-time
login is a real human, in a real browser); nonetheless, using an
unofficial API at all carries some risk of rate-limiting, CAPTCHA
challenges, or account action if Loblaw's systems flag the traffic
pattern. Use at your own risk, on your own account, at low volume. See
[docs/LEGAL.md](docs/LEGAL.md) for the full disclaimer, no-affiliation
statement, and what this project deliberately does and doesn't do.
- **`get_available_slots` is the least verified** piece here — see
[docs/DEPLOYMENT.md](docs/DEPLOYMENT.md#verification-status-precisely)
for exactly what has and hasn't been confirmed against a live account.
`search_products`/`get_cart` are both now verified against real,
non-empty results (`search_products` was extended with real pagination
and enriched per-product data after a direct request; `get_cart` was
fixed after a real bug report — see [docs/DEPLOYMENT.md](docs/DEPLOYMENT.md)).
- **No nutrition facts or ingredients list is available anywhere in this
API.** `search_products` results were checked for this specifically
(`ingredients` is null on every real result seen) and the only
single-product-detail endpoint this client has confirmed does not work
(see `api_client.get_product`'s docstring). If you need nutrition data,
pair a result's `barcode` with an external source.
- **No store-search API.** `list_stores` can only show stores you've
already validated via `set_active_store`.
- **Single account, single session assumption** (stdio mode). Running two
instances of this server (or this server plus `login.py`) concurrently
against the same refresh token will cause one of them to fail with an
auth error, because refresh tokens are single-use/rotating.
- **One active cart per PC Express *banner*** — Superstore, No Frills,
etc. each have their own independent cart; within a single banner,
though, there's still only one cart account-wide, bound to whichever
store it was last used at (confirmed live — an earlier version of this
doc said "account-wide" with no banner qualifier, which was wrong: a
user's own account had two genuinely separate, simultaneously valid
carts, one per banner, at the same time). If your account is shared
across locations on the *same* banner (e.g. family members both
ordering from Superstore, at different Superstore locations), this
tool will tell you clearly when that's the problem (`cart_note` from
`set_active_store`, `cart_store_mismatch` from a failed add) and can now
actually fix it: call `switch_cart_store(store_id, postal_code)` to
re-bind the cart to the store you need, no app step required. See
[docs/RESEARCH.md](docs/RESEARCH.md#the-real-fix-switch_cart_store-found-from-a-user-supplied-real-capture).
- **Rate limiting is unknown and undocumented**, and this project doesn't
implement any beyond a single 401-triggered token refresh retry. Keep
usage to the "occasional grocery order" volume this was built for. See
[docs/SECURITY.md](docs/SECURITY.md) for the full threat model, including
what HTTP mode does and doesn't protect against.
## Development
```bash
uv sync --extra dev --extra http # installs the exact versions in uv.lock
uv run pytest
```
Plain `pip`/`venv` work too (`pip install -e ".[dev,http]"` then `pytest`),
but only `uv sync` reproduces the exact, fully-pinned dependency tree this
project was actually tested against.
- `tests/test_auth_pkce.py` — pure logic (PKCE generation, banner lookup),
no network or credentials needed.
- `tests/test_oauth_server.py` — the full OAuth flow (discovery metadata,
`/authorize`'s PC-ID-account-match consent, PKCE-verified `/token`
exchange, single-use codes, refresh rotation, the mismatched-account
rejection path, and the stateless token encryption itself — tampering,
wrong secret, PC-ID-driven refresh rotation) against a dummy inner app via
`httpx`'s ASGI transport. The PC ID exchange itself is mocked — never
hits the real network or touches real credentials.
- `tests/test_http_app.py` — the same flow end-to-end against the *real*
composed app (`server.build_http_app()`, including the actual
`mcp.streamable_http_app()`), with its lifespan driven the same way
uvicorn drives it. This is what caught the lifespan bug in
[docs/DEPLOYMENT.md](docs/DEPLOYMENT.md).
- `tests/test_simplifiers.py` — the response-simplifier functions against
fixture data reconstructed from real, live-verified response shapes.
- `tests/test_snapshot_shapes.py` — the same simplifiers against real,
anonymized API response captures checked into
`tests/fixtures/raw_responses/` (see that directory's README and
`scripts/capture_snapshots.py`) — a regression guard against exactly the
failure mode that shipped once already: a simplifier silently returning
nulls against a real response its own hand-written test fixture never
actually exercised.
None of these need network access or real credentials (the snapshot files
are static, already-captured data, not a live call). There is no
integration test suite against the live PC Express API, for obvious
reasons — see [docs/DEPLOYMENT.md](docs/DEPLOYMENT.md#verification-status-precisely)
for what's been verified manually instead.
Coverage: `uv run pytest --cov=pc_express_mcp --cov-report=term-missing`
(also runs in CI, uploaded as a build artifact). Coverage is concentrated in
the pure-logic modules (crypto, auth parsing, OAuth flow, response
simplifiers) by design — the tool bodies and the live API client are
exercised by manual live verification instead, not mocked out into a false
sense of coverage.
### Dependency policy
Every dependency in `pyproject.toml` is pinned exact (`==`), not `>=` —
this project does not track new releases automatically. `uv.lock`
(committed) pins the *entire* transitive dependency tree the same way; CI
installs from it with `uv sync --locked`, which fails loudly if the lock
file and `pyproject.toml` ever drift apart instead of silently
re-resolving. Bumping a dependency is a deliberate action here: update the
version, run `uv lock`, re-run the full test suite, and (for anything
touching the HTTP/OAuth/crypto path) a live smoke check — never an
incidental side effect of a fresh install some time later.
`requirements.txt` mirrors the direct dependencies for non-uv `pip install
-r requirements.txt` users, but can't pin the transitive closure the way
`uv.lock` does — prefer `uv sync` or `pip install -e .` when you can.
Want to contribute? See [CONTRIBUTING.md](CONTRIBUTING.md).
## Learn more
- [docs/RESEARCH.md](docs/RESEARCH.md) — how every undocumented endpoint
and constant here was sourced, and confidence levels per endpoint.
- [docs/ARCHITECTURE.md](docs/ARCHITECTURE.md) — authentication design, why
`place_order` never submits payment, and the multi-tenant OAuth server's
full design history (including the stateless-credential redesign).
- [docs/DEPLOYMENT.md](docs/DEPLOYMENT.md) — real bugs found deploying to a
real VPS, and the precise, honest record of what has and hasn't been
verified against a live account/deployment.
- [docs/SECURITY.md](docs/SECURITY.md) — threat model, what's deliberately
not implemented and why, and the audit findings from this project's
security review.
- [docs/LEGAL.md](docs/LEGAL.md) — no-affiliation/trademark disclaimer,
what this project does and deliberately doesn't do, Terms of Service
risk, and no-warranty/liability statement. Not legal advice.
- [CONTRIBUTING.md](CONTRIBUTING.md) — how to contribute.
## Project layout
```
pc-express-mcp/
├── pc_express_mcp/
│ ├── config.py # OAuth + API constants (see docs/RESEARCH.md)
│ ├── auth.py # PKCE login, token refresh/rotation, local state
│ ├── token_crypto.py # stateless encrypted-token encode/decode (HTTP mode)
│ ├── oauth_server.py # self-service multi-tenant OAuth 2.1 + PKCE server
│ ├── api_client.py # pcx-bff HTTP client
│ ├── session_state.py # active banner/store/cart (non-secret)
│ └── server.py # MCP tool definitions
├── scripts/
│ ├── login.py # one-time interactive PC ID login
│ └── generate_secret.py # generates PCEXPRESS_TOKEN_SECRET for HTTP mode
├── tests/
├── docs/ # research, architecture, deployment, security
├── Dockerfile # defaults to stdio mode
├── docker-compose.yml # HTTP mode + Caddy (automatic HTTPS)
├── fly.toml # one-click deploy config (Fly.io)
├── .env.example
└── .gitignore # excludes .env and all local state/token files
```
This server cannot be deployed
Maintenance
ActivityMaintained
ResponsivenessNo issues