Metadata-Version: 2.5
Name: hktvmall-mcp
Version: 0.11.5
Summary: MCP server for HKTVmall (Hong Kong): search, product cards, order history and basket edits read back from HKTVmall; never pays
Project-URL: Homepage, https://github.com/benjamin-di/hktvmall-mcp-server
Project-URL: Issues, https://github.com/benjamin-di/hktvmall-mcp-server/issues
Author: benjamin-di
License-Expression: MIT
License-File: LICENSE
Keywords: groceries,hktvmall,hong-kong,mcp,model-context-protocol,shopping
Classifier: Development Status :: 4 - Beta
Classifier: Environment :: Console
Classifier: Intended Audience :: End Users/Desktop
Classifier: Operating System :: OS Independent
Classifier: Programming Language :: Python :: 3
Classifier: Programming Language :: Python :: 3.12
Classifier: Programming Language :: Python :: 3.13
Classifier: Topic :: Office/Business
Requires-Python: >=3.12
Requires-Dist: cryptography>=50.0.0
Requires-Dist: fastmcp==4.0.10
Requires-Dist: platformdirs>=4.3
Provides-Extra: login
Requires-Dist: patchright==1.61.2; extra == 'login'
Description-Content-Type: text/markdown

# hktvmall-mcp

An [MCP](https://modelcontextprotocol.io) server for [HKTVmall](https://www.hktvmall.com), Hong Kong's online mall
and supermarket. It lets an AI assistant (Claude, ChatGPT, Cursor, ...) search HKTVmall for a whole shopping list,
compare prices and unit prices, show product cards with photos, read your order history and set quantities in your
real basket. **You pay yourself, on HKTVmall:** the server cannot place, pay for or change an order.

> Unofficial. Not affiliated with or endorsed by HKTVmall or Hong Kong Television Network Limited. HKTVmall has no
> public API; this server uses the same endpoints HKTVmall's own website uses, gently and only when you ask. Use it
> with your own account for your own shopping; HKTVmall's terms and conditions apply to you.

**Requirements**

- A connection from **Hong Kong**: HKTVmall answers connections from elsewhere with errors.
- An HKTVmall account for the basket and order history (search works without one).
- [uv](https://docs.astral.sh/uv/) (it fetches Python 3.12 if needed), or Docker for a server.
- Google Chrome for signing in (or Chromium, see [Signing in](#signing-in)).

## Quick start

1. **Sign in once.** A browser window opens on HKTVmall's own login page; log in as usual (HKTVmall's own checks
   included). It closes once you are signed in, and the session is saved for the server.

   ```sh
   uvx --from "hktvmall-mcp[login] @ git+https://github.com/benjamin-di/hktvmall-mcp-server" hktvmall-mcp login
   ```

2. **Add the server to your client.** It runs over stdio, started by the client:

   - Claude Code: `claude mcp add hktvmall -- uvx --from git+https://github.com/benjamin-di/hktvmall-mcp-server hktvmall-mcp`
   - Claude Desktop, Cursor and most other clients: add to the client's MCP configuration

     ```json
     {
       "mcpServers": {
         "hktvmall": {
           "command": "uvx",
           "args": ["--from", "git+https://github.com/benjamin-di/hktvmall-mcp-server", "hktvmall-mcp"]
         }
       }
     }
     ```

3. **Ask.** "Put milk, a dozen eggs and some bananas in my HKTVmall basket", "What did I order last month? Add the
   same toilet paper again", "Which of these olive oils is cheapest per 100 ml?".

These commands run the latest code from GitHub; add `@vX.Y.Z` to the URL to stay on a release. The server is not
on PyPI yet: a package called `hktvmall-mcp` there is not this project until this README says so.

## Tools

| Tool | What it does |
|---|---|
| `hktv_search(queries[1..25], zone, per_query)` | The whole shopping list in one HKTVmall request; a few lean candidates per item (id, title, title_zh, brand, pack, price, availability, seller, delivery label such as "Next Day Delivery", `price_per_unit` per 100 ml, 100 g, roll or piece when the pack size is unambiguous, `review_count`, `url`). `max_price_cents` is sent to HKTVmall as a price range. Supermarket zone by default, widened per item when it has nothing; at most 100 products per call. |
| `hktv_get_products(ids)` | Current price, stock, delivery, `price_per_unit`, `review_count`, `summary` (shelf life, packing), `origin`, `image` and `url`; `not_found` lists ids HKTVmall does not sell right now. |
| `hktv_show_products(items[{id, note}] 1..12, title)` | The assistant's picks as product cards (photo gallery, price, unit price, rating, delivery, seller, the assistant's note, a link), drawn by clients that support MCP Apps (claude.ai, ChatGPT); other clients get the same data as text. |
| `hktv_delivery_slots()` | HKTVmall's next delivery slots for your first address, the fee, `free_from` and the slot already chosen. You pick a slot at checkout. |
| `hktv_reviews(id, limit 1..5, rating)` | Public reviews: average, count, breakdown by rating and recent comments; nothing about the reviewers. |
| `hktv_get_cart()` | The basket as HKTVmall shows it: lines, delivery groups with fees and `amount_to_free_delivery`, totals (as HKTVmall's order summary computes them), `checkout_url`, and HKTVmall's checkout warnings in `notes`. |
| `hktv_update_cart(changes[{id, quantity}], confirmed)` | Exact quantities (0 removes); each change's outcome (`applied`, `adjusted` with the reason such as `stock_limit`, or `rejected` with the reason such as `out_of_stock` or `store_closed`) is read back from HKTVmall. Raising a line above 15 or adding over HK$1,500 in one call changes nothing (`requires_buyer_review`) unless the person agreed (`confirmed`). |
| `hktv_recent_orders(limit 1..50, offset, items)` | Your order history, newest first: number, date, status, total, deliveries and the items (id, title, quantity, line total). Pages back through the whole history with `offset`. Read-only: never the address, payment or vouchers. Re-ordering goes through `hktv_update_cart`, line by line. |
| `hktv_status(check)` | Sign-in state, today's budget, any pause and the latest unresolved error. `check=true` runs a cached health check against HKTVmall (for monitoring). |

Money is `{"cents": 12990, "display": "HK$129.90"}`. Errors are `{"error": {code, next_step, retryable}}`, and the
`next_step` text tells the assistant what to do. A call that changed anything never returns an error: it reports each
change's outcome.

## Safety

Enforced in code and pinned by `tests/test_safety.py`:

- **No money moves.** No endpoint for placing, paying for, changing or cancelling an order, and none for vouchers,
  credit, addresses, profiles, wish lists or delivery-slot changes, exists in the code. You check out on HKTVmall.
- **Only HKTVmall.** Requests go only to `www.hktvmall.com`, its search service and its public review service, to an
  allowlist of paths; redirects that leave those hosts are refused. The product cards load photos from HKTVmall's
  image servers only and connect nowhere.
- **The truth is the basket.** HKTVmall's replies to basket changes are not always true (an item silently dropped,
  a quantity silently capped), so every change is read back and reported as HKTVmall then shows it.
- **Gentle.** One request at a time, at most about three a second, a daily request budget, and a pause of 5 minutes
  (30 when it repeats) whenever HKTVmall pushes back.
- **Your account only.** Each call acts for the account its connection belongs to, never one named in a tool
  argument. Product names, descriptions and reviews are treated as data, not instructions.

## Privacy

Everything stays on the computer that runs the server, in its state folder (`~/.local/state/hktvmall-mcp` on Linux,
`~/Library/Application Support/hktvmall-mcp` on macOS, `%LOCALAPPDATA%\hktvmall-mcp` on Windows, `/state` in the
container; `HKTVMALL_STATE_DIR` overrides it):

| File | What it holds |
|---|---|
| `session.json` | Your HKTVmall sign-in (cookies) and the browser identity it was made with. **Treat it like a password.** |
| `browser/` | The sign-in window's browser profile, so HKTVmall recognises the browser next time. |
| `state.json`, `shared.json` | Today's request budget, any pause, unresolved errors and health-check results. |
| `orders.json` | The item lines of completed orders already read (id, title, quantity, line total), so the history loads quickly. |

Nothing is sent anywhere except HKTVmall. Logs (stderr) never contain cookies, search terms or HKTVmall's error text;
a basket change HKTVmall did not apply as asked is logged with its product id and quantities, for diagnosis. The
server never reads your address or payment details (the delivery-slot lookup reads only the delivery zone of your
first address).
`hktvmall-mcp logout` deletes the session and the browser profile.

## Signing in

`hktvmall-mcp login` opens HKTVmall's login page in your installed Google Chrome, using a separate profile in the
state folder. You do every step yourself; nothing is clicked or typed for you. When HKTVmall's own home page says you
are signed in, the session is saved and the window closes. A running server picks up the new session with its next
call.

- No Chrome? Run `patchright install chromium`, then `hktvmall-mcp login --browser chromium`, both the same way as
  in the [Quick start](#quick-start) (`uvx --from "hktvmall-mcp[login] @ git+https://github.com/benjamin-di/hktvmall-mcp-server" ...`).
- HKTVmall decides how long a session lasts. When it ends, tools return `session_expired`: sign in again.
- `hktvmall-mcp status` shows whether the saved session is signed in, without asking HKTVmall anything.

## Running it as a server

For remote clients (claude.ai, ChatGPT and the Claude and ChatGPT apps reach connectors over HTTPS) or a shared
home server, run it over streamable HTTP. It refuses to start without a bearer token:

```sh
docker run -d --name hktvmall-mcp -p 127.0.0.1:8000:8000 \
  -e MCP_BEARER_TOKEN="$(openssl rand -hex 32)" \
  -v hktvmall-state:/state \
  ghcr.io/benjamin-di/hktvmall-mcp-server:latest
```

or `MCP_BEARER_TOKEN=... uvx --from git+https://github.com/benjamin-di/hktvmall-mcp-server hktvmall-mcp serve --port 8000`.
The MCP endpoint is `/mcp`;
`/health` is open for health checks (the image's `HEALTHCHECK` runs `hktvmall-mcp health`). Put it behind your own
HTTPS reverse proxy to reach it from outside, and give clients the bearer (`Authorization: Bearer <token>`).

The server still needs a signed-in session in its state folder. Sign in with `hktvmall-mcp login` on a computer with
a screen in the same network and copy that computer's `session.json` into the server's state folder (the volume
above); the server adopts it on its next call. HKTVmall decides how long a session lasts; if it ends sooner on the
server, sign in again the same way.

### Hosting several people (experimental)

One server can also hold other people's HKTVmall accounts, each person acting only on their own. They add the
server as a connector in Claude or ChatGPT and press Connect: the server's own OAuth sign-in asks for an invite code
you gave them, then shows them HKTVmall's real login page through a browser running next to the server
(`ghcr.io/benjamin-di/hktvmall-mcp-login-browser`, viewed through noVNC; nothing on the login is automated). Their
sessions are stored encrypted (`HKTVMALL_SESSION_KEY`). Invites and accounts are managed with
`hktvmall-mcp admin invite LABEL | invites | revoke PREFIX | accounts | disconnect ID`. This needs a public HTTPS
address (`HKTVMALL_PUBLIC_URL`) and the login browser service (`HKTVMALL_LOGIN_URL`, `HKTVMALL_LOGIN_TOKEN`). All
accounts share the server's IP address, so requests are paced and budgeted for all of them together. Wrong invite
codes are rate-limited per client address from `X-Forwarded-For`: the reverse proxy in front must overwrite that
header with the client's address, not append to it.

Run the login browser on a private network next to the server, never published to the internet: it listens on port
8080, refuses to start without `HKTVMALL_LOGIN_TOKEN` (at least 32 characters, the same value the server gets) and
holds at most `HKTVMALL_LOGIN_SLOTS` (2) logins at a time, each ended after `HKTVMALL_LOGIN_MINUTES` (15)
unfinished. The server's `HKTVMALL_LOGIN_URL` names it by host and port, such as `http://hktvmall-mcp-login:8080`.

### Session snapshot (for operators)

Instead of its own `session.json`, the default account can read a session that another program keeps signed in and
publishes as a file: set `HKTVMALL_SESSION_SNAPSHOT` (an absolute path, mounted read-only) and
`HKTVMALL_EXPECTED_EMAIL` (the account it must belong to). The server never writes it; it adopts a file only when
its `generation` is greater than the one it holds, and checks HKTVmall's home page before the first basket call of
each generation. The file is JSON, at most 512 KiB:

```json
{"schema": 1, "target": "hktvmall", "generation": 3, "email": "you@example.com",
 "user_agent": "Mozilla/5.0 ...", "verified_at": "2030-01-01T00:00:00+00:00",
 "cookies": [{"name": "JSESSIONID", "value": "...", "domain": "www.hktvmall.com", "path": "/",
              "secure": true, "expires": null}]}
```

`expires` is Unix seconds or null; it must contain a `JSESSIONID` for `www.hktvmall.com`. Symlinks, files that are
not regular files and timestamps in the future are rejected. `session.json` uses the same format.

## Configuration

| Variable | Default | |
|---|---|---|
| `MCP_BEARER_TOKEN` | required for `serve` | one or more tokens (comma-separated, at least 16 characters each), all for the default account |
| `HKTVMALL_STATE_DIR` | per-user folder (see [Privacy](#privacy)); `/state` in the image | sessions, budgets, pause, order cache |
| `HKTVMALL_DAILY_BUDGET` | 2000 | the default account's HKTVmall requests per day (resets 00:00 Hong Kong time) |
| `HKTVMALL_KEEPALIVE_SECONDS` | 21600 | how often an idle session is refreshed (re-mints its token and reads the basket) |
| `HKTVMALL_SESSION_SNAPSHOT`, `HKTVMALL_EXPECTED_EMAIL` | unset | read the default account's session from a published file ([above](#session-snapshot-for-operators)) |
| `HKTVMALL_SESSION_KEY` | unset | 32 random bytes, base64: enables connected accounts and encrypts their sessions |
| `HKTVMALL_ACCOUNT_DAILY_BUDGET` | 500 | each connected account's requests per day |
| `HKTVMALL_TOTAL_DAILY_BUDGET` | 4000 | all accounts together per day |
| `HKTVMALL_PUBLIC_URL` | unset | the server's public HTTPS address, for connected accounts' OAuth sign-in |
| `HKTVMALL_LOGIN_URL`, `HKTVMALL_LOGIN_TOKEN` | unset | the login browser service (`http://host:port` on a private network) and its bearer |
| `LOG_LEVEL` | INFO | |

## Troubleshooting

| Code | What to do |
|---|---|
| `not_signed_in` | Run `hktvmall-mcp login`. |
| `session_expired` | HKTVmall ended the sign-in: run `hktvmall-mcp login` again (connected people reconnect; a snapshot needs a newer generation). |
| `account_mismatch` | HKTVmall shows a different account than the saved one: sign in again. |
| `session_source_missing` / `session_source_invalid` | The session snapshot is missing, unreadable, for another account or malformed: check its publisher, mount and `HKTVMALL_EXPECTED_EMAIL`. A malformed `session.json`: sign in again. |
| `blocked` | HKTVmall pushed back; calls resume after the pause (`hktv_status.paused_until`). |
| `budget_exhausted` | Today's budget is spent; it resets at 00:00 Hong Kong time. |
| `upstream_error` | HKTVmall or the network failed; retry in a minute. From outside Hong Kong, every call fails this way. |
| `schema_changed` | HKTVmall changed a page or API the server reads. Check for a newer release, or [open an issue](https://github.com/benjamin-di/hktvmall-mcp-server/issues). |
| `basket_unreadable` | HKTVmall cannot show the basket (for example after a zero-quantity line added elsewhere): open the basket in HKTVmall's app or site and remove the broken line. |
| `outcome_unknown` | A change was sent but could not be read back: check the basket before changing anything again. |

## How it works

HKTVmall has no public API, so the server speaks to the endpoints its website uses, over plain HTTP, with the
cookies of a browser session you signed in to. [docs/hktvmall-site.md](https://github.com/benjamin-di/hktvmall-mcp-server/blob/main/docs/hktvmall-site.md) records what is known
about those endpoints and their quirks; [docs/DESIGN.md](https://github.com/benjamin-di/hktvmall-mcp-server/blob/main/docs/DESIGN.md) explains the decisions. When HKTVmall
changes its site, calls fail with `schema_changed` until the server is updated.

## Development

See [CONTRIBUTING.md](https://github.com/benjamin-di/hktvmall-mcp-server/blob/main/CONTRIBUTING.md). Tests never contact HKTVmall.

## License

[MIT](https://github.com/benjamin-di/hktvmall-mcp-server/blob/main/LICENSE).
