Skip to main content
Glama
StenoJS
by StenoJS
README.md
# lidl-mcp

Self-hosted MCP-server voor Lidl Plus (NL + DE) — kassabonnen uitlezen en coupons
beheren. Analoog aan `ah-mcp`, gebouwd op de reverse-engineered [`lidl-plus`](https://github.com/Andre0512/lidl-plus)
library. Zie [`LIDL_MCP_HANDOVER.md`](./LIDL_MCP_HANDOVER.md) voor de volledige
achtergrond en ontwerpbeslissingen.

## Ontwerpkeuzes (uit de bouw-sessie)

- **NL + DE**: één server, elke tool-call neemt een `country`-parameter (`NL`/`DE`).
- **Coupon-activatie is inbegrepen** (`lidl_activate_coupon` / `lidl_deactivate_coupon`)
  — dit zijn schrijf-acties op het echte Lidl-account.
- **Geen database.** Alles wordt live bij de Lidl-API opgehaald, geen lokale cache.
- **Login gebeurt buiten de container.** De `lidl-plus`-library logt in via headless
  Selenium/Chrome met een synchrone 2FA-callback — te zwaar en te fragiel om in deze
  lichte MCP-image te bakken. In plaats daarvan: je haalt **lokaal, eenmalig per land**
  een refresh-token op via de CLI, en die zet je als environment variable in de
  Portainer-stack. Er is dus bewust **geen `lidl_login`-tool**.

  Refresh-tokens zijn niet officieel gedocumenteerd qua levensduur, maar in de praktijk
  (zelfde tokenmechanisme als de Home Assistant Lidl Plus-integratie) blijven ze
  weken tot maanden geldig — herhaling is incidenteel, geen doorlopende taak.

## Tools

| Tool | Type | Functie |
|---|---|---|
| `lidl_check_status` | read | Check of er een geldig token is voor NL/DE (eerste stap bij problemen) |
| `lidl_get_receipts` | read | Lijst recente kassabonnen (datum, winkel, totaal, ticket-id) |
| `lidl_get_receipt_detail` | read | Itemdetails van één bon — JSON via de mobiele API, of platte bontekst via de web-sessie-fallback bij HTML-format bonnen (zie verderop) |
| `lidl_get_coupons` | read | Lijst beschikbare/geactiveerde coupons |
| `lidl_activate_coupon` | **write** | Activeer een coupon op het echte account |

De schrijf-tool is gemarkeerd met `readOnlyHint: false` in de tool-annotations, zodat
MCP-clients die dat respecteren automatisch voorzichtiger zijn / om bevestiging vragen.

`lidl_deactivate_coupon` bestaat wél in de code (`_handle_set_coupon(activate=False)` in
[`server.py`](src/server.py)) maar is bewust **niet** als tool geregistreerd — Steno wil
deactivatie niet aanbieden, geen agent kan 'm dus aanroepen. Zie de code-commentaar ter
plekke voor de reden.

## Transports — twee endpoints, verschillende clients

De server biedt twee MCP-transports tegelijk aan (zie [`server_sse.py`](src/server_sse.py)),
elk voor een andere client:

| Endpoint | Transport | Voor | Secret-vorm |
|---|---|---|---|
| `/sse` | Legacy SSE (`GET /sse` + los `POST /messages`) | Home Assistant MCP Client-integratie | query-param: `?key=<MCP_SSE_SECRET>` |
| `/mcp/<secret>` | Streamable HTTP (één endpoint, GET+POST+DELETE) | Claude.ai Custom Connectors | pad-segment: `/mcp/<MCP_SSE_SECRET>` |

Beide gebruiken dezelfde `MCP_SSE_SECRET`-waarde, alleen anders doorgegeven.

**Waarom twee transports?** Claude.ai's Custom-Connector-setup verwacht standaard de
modernere Streamable-HTTP-transport en doet een `POST` naar de opgegeven URL. Onze
legacy-SSE-route ondersteunt alleen `GET`, dus dat werd `405`, waarna Claude terugvalt op
OAuth-discovery (`.well-known/oauth-*`, `/register`) — die altijd faalt (geen OAuth-server)
en een misleidende *"Couldn't register with Lidl's sign-in service"*-foutmelding geeft.
Bevestigd via live container-logs (2026-08-14), zelfde patroon als eerder bij `ah-mcp`
opgelost. Zie de `steno-infra-patterns`-skill voor de generieke versie van deze les
(geldt voor élke toekomstige zelf-gehoste MCP-server, niet alleen dit project).

Onderweg ook een losstaande bug gevonden en gefixt: de legacy `/sse`-handler was een
gewone `async def`-functie die zelf al rechtstreeks over de ASGI `send()` schreef maar
niets terugstuurde — Starlette's `Route` wrapt zulke functies via `request_response()`,
die een `Response`-retourwaarde verwacht en crasht (`TypeError: 'NoneType' object is not
callable`) zodra de verbinding normaal afsluit. Bleef verborgen in eerdere tests omdat die
de verbinding altijd hard afkapten (`curl -m 2`) i.p.v. netjes lieten sluiten. Gefixt door
beide handlers class-based te maken (Starlette behandelt class-instances als rauwe
ASGI-apps, geen wrapping).

## Login / refresh-token ophalen (lokaal, per land)

### Aanbevolen: handmatige OAuth-code-capture (`scripts/manual_login.py`)

De ingebouwde `lidl-plus ... auth`-CLI stuurt Selenium een gescripte browser-sessie
door Lidl's inlogscherm. Lidl heeft dat scherm intussen vernieuwd (de library-selectors
zijn verouderd, zie [PR #25](https://github.com/Andre0512/lidl-plus/pull/25)) en er zijn
meldingen dat Lidl automatisch/Selenium-gestuurde logins actief detecteert en blokkeert.
Gebruik daarom liever dit script: het genereert dezelfde OAuth-login-URL, jij logt daarmee
gewoon in je eigen echte browser in (geen automatisering die Lidl kan herkennen), en
plakt de code uit de laatste (mislukkende) redirect terug in de terminal.

```bash
pip install -e .
python scripts/manual_login.py -c NL -l nl
```

1. Open de getoonde URL in je eigen browser en log in zoals gebruikelijk (inclusief 2FA).
2. Aan het eind probeert de browser te navigeren naar
   `com.lidlplus.app://callback?code=...` — dat *mislukt altijd* in een gewone browser
   (geen app geregistreerd voor dat schema). Dat is verwacht: de foutmelding of
   adresbalk toont dan de volledige URL met de code erin.
3. Plak die (volledige URL of alleen de code) terug in de terminal → het script wisselt
   'm in en print het `refresh_token`.

Herhaal voor Duitsland met `-c DE -l de`.

### Alternatief: de lidl-plus CLI (Selenium, kan momenteel vastlopen)

Vereist: Python 3.11+ en Chrome. Werkt alleen zolang Lidl's inlogscherm overeenkomt met
wat de library verwacht — momenteel dus niet gegarandeerd (zie hierboven).

```bash
pip install ".[auth]"
lidl-plus -c NL -l nl -u <telefoonnummer of e-mail> --2fa phone auth
# volg de 2FA-prompt in de terminal → refresh_token verschijnt in de output
```

> **Let op:** installeer niet los `pip install "lidl-plus[auth]"` — dat trekt de nieuwste
> `blinker` en `pyOpenSSL` binnen, en `selenium-wire` (onderhoudt zelf al jaren niets
> meer) is met geen van beide compatibel:
> - nieuwe `blinker` mist het interne, inmiddels verwijderde `blinker._saferef`-attribuut
>   → CLI toont de misleidende `Can't connect to web browser. Please install Chrome,
>   Chromium or Firefox`, terwijl Chrome prima werkt (verbergt een
>   `ModuleNotFoundError: No module named 'blinker._saferef'`)
> - nieuwe `pyOpenSSL` mist `X509.get_extension()`, waar de meegeleverde mitmproxy-fork
>   op leunt voor TLS-cert-interceptie tijdens de login → crasht pas ná het
>   wachtwoord-prompt met `AttributeError: 'X509' object has no attribute
>   'get_extension'`
>
> De `[auth]`-extra in [pyproject.toml](pyproject.toml) pint daarom `blinker==1.4` en
> `pyOpenSSL==22.1.0`. Gebruik dus altijd `pip install ".[auth]"` (uit de project-root),
> niet het losse package.

(`-p <wachtwoord>` kan als vlag, maar laat 'm weg om interactief te worden gevraagd i.p.v.
in je shell-historie te belanden. `--2fa email` als je liever een code per e-mail krijgt.)

Herhaal voor Duitsland met `-c DE -l de` als je dat ook wilt gebruiken.

Losse bonnen/coupons opvragen kan ook direct via de CLI, handig om te verifiëren dat een
token werkt zonder de MCP-server erbij te halen:

```bash
lidl-plus -c NL -l nl -r <refresh_token> receipt --all
lidl-plus -c NL -l nl -r <refresh_token> coupon
```
Bewaar het resulterende refresh-token **niet in dit repo** — zet het direct in de
Portainer-stack env vars (zie hieronder), of lokaal in een `.env` (zie `.env.example`,
staat in `.gitignore`).

Als een token later verlopen blijkt (`lidl_check_status` of een andere tool meldt een
auth-fout): herhaal deze stap en werk de env var bij.

## Bekende API-quirks (lidl-plus library, gemeten 2026-08-14)

Twee losse problemen die je kunt tegenkomen zodra je met een geldig refresh-token
daadwerkelijk data probeert op te halen — geen van beide is een bug in dit project,
maar in de onderliggende `lidl-plus`-library / Lidl's eigen API:

1. **WAF-tarpit op de "app-headers".** De library stuurt standaard drie headers mee bij
   elke geauthenticeerde aanvraag om zich voor te doen als de officiële app:
   `App-Version: 999.99.9`, `Operating-System: iOs`, `App: com.lidl.eci.lidl.plus`.
   Lidl's WAF lijkt precies deze (overduidelijk statische/nep) fingerprint te herkennen
   en te tarpitten: de TLS-handshake slaagt gewoon, maar de server stuurt daarna 0 bytes
   terug tot de timeout — geen foutcode, gewoon stilte. Verwijder je die drie headers en
   stuur je alleen `Authorization` (+ `Accept-Language`) mee, dan antwoordt dezelfde
   endpoint binnen ~0,3s gewoon met 200 en echte data. [`lidl_client.py`](src/lidl_client.py)
   patcht dit automatisch weg via `_strip_waf_fingerprint_headers()` — dus met de tools
   uit dit project heb je hier verder geen last van, maar als je de kale `lidl-plus`-CLI
   of -library ergens anders rechtstreeks gebruikt, loop je hier alsnog tegenaan.
2. **Coupon-lijst-endpoint (v2) is dood.** `LidlPlusApi.coupons()` (GET
   `/api/v2/{country}`) geeft een kale 404 — waarschijnlijk een endpoint dat Lidl heeft
   opgeruimd. Het oudere `coupon_promotions_v1()` (GET `/app/api/v1/promotionslist`)
   werkt wél en levert dezelfde soort data. Dit project gebruikt daarom overal
   `coupon_promotions_v1()` / `activate_coupon_promotion_v1()` in plaats van
   `coupons()` / `activate_coupon()`. Er is geen v1-tegenhanger voor deactiveren — die
   tool is daarom bewust niet geregistreerd (zie `_handle_set_coupon` in `server.py`).
3. **Coupon-activatie werkt alleen voor `"Standard"`-type coupons, niet voor
   `"AssignablePromotion"`-type coupons** (bijv. persoonlijk toegewezen aanbiedingen).
   Getest (2026-08-14): een `"Standard"`-coupon activeren via `activate_coupon_promotion_v1()`
   geeft gewoon HTTP 200 en `isActivated` klopt daarna. Dezelfde aanroep op een
   `"AssignablePromotion"`-coupon geeft een echte 404 van Lidl's eigen API (dus geen
   WAF-blokkade meer, na de header- en Content-Length-fixes) — er is geen bekend werkend
   activatie-endpoint voor dit coupon-type in deze library. `lidl_activate_coupon`
   controleert nu wel expliciet de HTTP-status en geeft een duidelijke foutmelding i.p.v.
   ten onrechte "geactiveerd" te melden (was zelf een bug, ondertussen gefixt).
4. **`coupon_promotions_v1()` dedupliceert niet per winkel-instantie.** Generieke acties
   (bijv. "Wiedereröffnung", "Aktionsrabatt") komen meerdere keren voor in de lijst — één
   keer per winkel — met identieke titel/korting/geldigheidsdatum maar een andere
   `promotionId`. De Lidl-app/website groepeert die kennelijk tot 1 zichtbare coupon;
   deze library niet. Getest (2026-08-14): aantal unieke combinaties van
   (titel, korting, startdatum) kwam voor Steno's account exact overeen met het aantal
   dat de website toonde (17 AllStores + 11 OnlineShop = 28 = 15 winkel + 13 online).
   De app zelf toont daarnaast nog extra content (bijv. een "jetzt spielen"-swipe-spel,
   combi-coupons met meerdere producten) die niet via deze API-endpoints beschikbaar
   blijkt — een bekende dekkingslacune, geen bug.
5. **Het `availability.text`-veld bevat vrijwel altijd generieke boilerplate-tekst**
   ("Mit diesem Coupon haben wir leider Schwierigkeiten...") — ook bij coupons die nooit
   aangeraakt zijn en gewoon activeerbaar/bruikbaar zijn. Dit veld is **geen betrouwbare
   indicator** of een coupon werkt; gebruik de HTTP-status van de activatie-aanroep zelf
   (zie punt 3) en `isActivated` in een verse her-ophaal, niet dit tekstveld.
6. **AssignablePromotion-coupons zijn wél te activeren — maar niet via de mobiele API.**
   De website (`www.lidl.de/prm/...`) gebruikt een apart endpoint:
   `POST https://www.lidl.de/prm/api/v1/{country}/promotions/{id}/activation?language={taal}`
   (let op: het `id`-veld, niet `promotionId`). Bevestigd werkend (2026-08-14) — Steno
   activeerde hiermee "Grafschafter Goldkrüstchen" en `isActivated` klopte daarna ook via
   de mobiele API. Dit endpoint gebruikt echter **cookie/CSRF-gebaseerde websessie-auth**
   (in plaats van de OAuth refresh-token-flow die de rest van dit project gebruikt) — een
   sessie die maar ~1 uur geldig blijft, geen refresh-token-achtige langlevende variant.
   Nog **niet geïmplementeerd** in dit project: zou een tweede, apart login-mechanisme
   vereisen (web-sessie + CSRF-token onderhouden) voor een relatief smalle usecase. Zie
   git-geschiedenis/chatlog voor de volledige curl indien dit later alsnog gebouwd wordt.
7. **Itemdetails van bonnen (`lidl_get_receipt_detail`) zijn kapot voor recente bonnen
   via de mobiele API.** `LidlPlusApi.ticket()` (GET `tickets.lidlplus.com/api/v2/{country}/tickets/{id}`)
   geeft een lege HTTP 400 terug voor bonnen met `isHtml`/`hasHtmlDocument: true` in de
   lijst (kennelijk alle bonnen sinds medio 2026 — Lidl slaat ze nu op als kant-en-klaar
   HTML-document i.p.v. gestructureerde JSON-itemdata). Wél een werkend alternatief
   gevonden (2026-08-15), zie hieronder.

## Itemdetails van HTML-format bonnen (web-sessie-fallback)

`lidl_get_receipt_detail` probeert eerst de mobiele API (`ticket()`), en valt bij een
lege HTTP 400 (zie quirk 7 hierboven) automatisch terug op een tweede, apart
auth-mechanisme: een **cookie-gebaseerde `www.lidl.de`-websessie**, los van de OAuth
refresh-token-flow die de rest van deze server gebruikt.

**Endpoint**: `GET https://www.lidl.de/mre/api/v1/tickets/{id}?country={land}&languageCode={taal}-{land}`
— retourneert JSON met een `ticket.htmlPrintedReceipt`-veld: de volledige bon als
kant-en-klaar HTML-document (`<pre>`-blok met de regeleinden al als letterlijke tekst
erin). [`lidl_client.fetch_receipt_text()`](src/lidl_client.py) strip de HTML-tags
(stdlib `html.parser`, geen extra dependency nodig) en geeft de leesbare platte
bontekst terug — **geen gestructureerde itemlijst** (geen los JSON-array met
artikel/aantal/prijs), maar wel de complete, leesbare inhoud (artikelen, kortingen,
BTW-overzicht, betaalmethode — identiek aan wat de Lidl Plus-website zelf toont).

**Levensduur: de authToken-JWT zelf is ~1 uur geldig, maar er is een silent-refresh-
endpoint gevonden** (`GET www.lidl.de/mla/api/v1/token`, alleen cookies nodig — ontdekt
2026-08-15 doordat de website 'm zelf periodiek aanroept zonder gebruikersactie).
`fetch_receipt_text()` roept dit voor elke aanvraag eerst aan en vervangt de
`authToken`-waarde in de cookie door het verse token uit de respons, vóór de eigenlijke
bon-aanvraag. Bleek in de praktijk (14 augustus) **niet voldoende** om de sessie na het
originele ~1-uur-punt levend te houden — de sessie was alsnog verlopen, dus dit is hooguit
een kleine verlenging, geen structurele oplossing.

### Persistente web-sessie (lidl-web-refresher)

Structurele oplossing (16 augustus): een aparte sidecar-container die een **al
ingelogde** sessie periodiek hergebruikt, in plaats van steeds een nieuwe cookie
handmatig te vangen. Bewust geen geautomatiseerde login-flow (zelfde afweging als bij
AssignablePromotion-coupon-activatie) — alleen gewone paginanavigatie met een bestaande
sessie, geen herhaalde login-pogingen die Lidl's bot-detectie kunnen raken.

**Opzet (eenmalig):**
1. Lokaal (niet op de server): `pip install playwright && playwright install chromium`,
   dan `python scripts/save_web_session.py -c NL` (en/of `-c DE`). Er opent een echte
   browser, jij logt zelf in zoals altijd (2FA etc.), sluit af met Enter. Resultaat:
   `web_session/nl_state.json` / `de_state.json` — bevat live sessie-cookies, **nooit
   committen** (staat al in `.gitignore`).
2. Kopieer die state-file(s) naar het `web-session-data`-volume op de server:
   ```bash
   docker cp web_session/nl_state.json lidl-web-refresher:/data/web-session/nl_state.json
   docker cp web_session/de_state.json lidl-web-refresher:/data/web-session/de_state.json
   ```
   (Of via Portainer UI → Volumes → `web-session-data` → bestand uploaden, als `docker cp`
   niet praktisch is vanaf je eigen machine.)
3. `lidl-web-refresher` (zie `docker-compose.yml`) draait vanaf dan elke
   `REFRESH_INTERVAL_MINUTES` (default 20) een headless navigatie naar de
   aankoopgeschiedenis-pagina, vangt daarbij het verzoek op dat de pagina zelf naar de
   tickets-lijst-API maakt om een echt ticket-ID te pakken, en doet vervolgens een
   **echte in-browser fetch (`credentials: "include"`) naar de bon-DETAIL-API** voor dat
   ticket — pas dán worden de cookies geëxtraheerd en geschreven naar `{land}.cookie` in
   hetzelfde volume, door `lidl-mcp` (`src/lidl_client.py`, `_read_web_cookie()`) als
   eerste geprobeerd, vóór de env var-fallback.

   **Waarom die detail-fetch-stap nodig is (ontdekt 15/16 augustus)**: aanvankelijk
   navigeerde de refresher alléén naar de lijstpagina. Dat gaf cookies die er geldig
   uitzagen (geen login-redirect, JWT nog binnen zijn 1-uur-venster) maar bij de
   daadwerkelijke bon-detail-aanvraag alsnog consistent een 401 opleverden — ook zonder
   de silent-refresh-tussenstap in `lidl_client.py`, dus de cookie zelf was het probleem,
   niet die tussenstap. Blijkbaar valideert/ververst Lidl iets sessie-gerelateerds
   specifiek bij een bon-detail-aanvraag dat de lijstpagina niet triggert. De
   detail-fetch-stap repliceert daarom exact het soort verzoek dat bij de oorspronkelijke
   handmatige her-vangst (DevTools op een geopende bon-detail) al wél werkte. Bevestigd
   werkend voor DE (status 200, gevolgd door een geslaagde echte bon-opvraging).

   **Bekende beperking**: als een land al lange tijd geen actieve aankopen/bonnen heeft
   (getest met een NL-account waarvan de laatste bon van maanden terug was), kan de
   refresher geen tickets-lijst-response vangen binnen de timeout (20s) — de detail-fetch-
   stap wordt dan overgeslagen en de cyclus valt terug op het oude gedrag (cookie
   ververst, maar niet expliciet gevalideerd). Geen crash, gewoon een duidelijke
   waarschuwing in de logs; zodra dat land weer een recente bon heeft zou dit vanzelf
   weer moeten oppakken.

**Robuustheid**: een corrupte/onvolledige `{land}_state.json` (bijv. door een verminkte
copy-paste, zie hieronder) crasht de container niet meer — de cyclus wordt overgeslagen
met een duidelijke logregel, de container blijft draaien en probeert het gewoon de
volgende cyclus opnieuw. Vóór deze fix (14/15 augustus) leidde dit tot een crash-loop.

**Bestanden correct naar de server krijgen**: copy-paste van een lange JSON-string via de
Portainer-webconsole knipt af bij een terminal-buffergrens (bevestigd: exact 4096 bytes,
ongeacht encoding) — dus **niet** doen. Werkende routes: `docker cp` (als je Docker-CLI
met de host kan praten), of via de Samba-share-add-on (bestand naar de `share`-map, dan
op de server met een tijdelijk hulpcontainertje — bind-mount van
`/mnt/data/supervisor/share` plus het `web-session-data`-volume — ernaartoe kopiëren).
SCP werkte in de praktijk niet betrouwbaar vanaf Windows (herhaaldelijke "Corrupted MAC
on input"-fouten met OpenSSH-for-Windows 9.5p2, ook na cipher/MAC-overrides) — niet verder
uitgezocht, Samba-route was sneller.

**Als de sessie toch doodloopt** (zichtbaar in `docker logs lidl-web-refresher` als
"Sessie lijkt niet meer geldig"): stap 1-2 hierboven herhalen. Hoe vaak dit nodig is, is
nog niet in de praktijk bevestigd — in theorie zou een normale, actief hergebruikte
sessie veel langer moeten meegaan dan de oude ~1-uur-cyclus, maar Lidl kan sessies ook om
andere redenen ongeldig maken.

**Oude methode (handmatig, als noodgreep of om zonder de sidecar te testen)**:
environment variable `LIDL_{NL|DE}_WEB_COOKIE` rechtstreeks zetten in Portainer:
1. Log in op [www.lidl.de](https://www.lidl.de) (of de NL-variant), open een willekeurige
   bon in je aankoopgeschiedenis.
2. DevTools (`F12`) → **Network**-tab → filter op **Fetch/XHR** → ververs/open de
   bon-pagina.
3. Zoek het verzoek naar `mre/api/v1/tickets/...` → rechtsklik → **Copy as cURL**.
4. Haal uit die cURL de volledige waarde van de `-b`/Cookie-header (alles tussen de
   aanhalingstekens na `-b`), en zet die in `LIDL_{NL|DE}_WEB_COOKIE`.

## Lokaal draaien

```bash
cp .env.example .env
# vul LIDL_NL_REFRESH_TOKEN (en/of LIDL_DE_REFRESH_TOKEN) in .env in
docker compose up --build
curl http://localhost:8000/health
```

## Deployment — Portainer-stack op de HA-host (`steno-infra-patterns`-skill)

Geverifieerd tegen de bestaande stacks (`vault-broker-mcp`, `npm-mcp`) op dezelfde
Portainer-omgeving (endpoint "primary", id 1):

1. **Git-repository-methode.** Maak een leeg GitHub-repo (bijv. `StenoJS/lidl-mcp`,
   zelfde account als `vault-broker-mcp`/`nginx-proxy-manager-mcp`), en push dit repo
   ernaartoe:
   ```bash
   git remote add origin https://github.com/StenoJS/lidl-mcp.git
   git branch -M main
   git push -u origin main
   ```
   Deploy daarna de Portainer-stack met `StackCreateDockerStandaloneRepository`
   (`RepositoryURL` = bovenstaande, `ComposeFile` = `docker-compose.yml`,
   `endpointId` = 1) — of handmatig via Portainer UI → Stacks → Add stack → Repository.
2. **Netwerk**: `hassio` (extern, gedeeld met de andere MCP-stacks) — al in
   [`docker-compose.yml`](docker-compose.yml) opgenomen.
3. **Poort**: `8934` (host) → `8000` (container) — vrij op dit moment; check bij twijfel
   welke poorten al in gebruik zijn (`ah-mcp`: 3000/9876, `supermarkt-mcp`: 8000,
   `vault-broker-mcp`: 8933, playwright-mcp's: 8931/8932).
4. **Secrets als stack-environment-variables** (Portainer UI, niet in de compose-YAML
   of dit repo): `LIDL_NL_REFRESH_TOKEN`, `LIDL_DE_REFRESH_TOKEN`, `MCP_SSE_SECRET`
   (genereer met `python -c "import secrets; print(secrets.token_hex(16))"`).
   `LIDL_NL_WEB_COOKIE`/`LIDL_DE_WEB_COOKIE` zijn nu optioneel (alleen nog fallback) —
   zie "Persistente web-sessie (lidl-web-refresher)" hierboven voor de aanbevolen route.
5. **`lidl-web-refresher`-sidecar**: staat al in `docker-compose.yml`, wordt automatisch
   meegedeployed. Vereist wél de eenmalige handmatige state-upload (zie boven) voordat
   `lidl_get_receipt_detail` voor HTML-format bonnen werkt — zonder die state doet de
   sidecar niets schadelijks, hij logt alleen "nog geen state aanwezig" en slaat over.
5. **Nginx Proxy Manager** (HA-add-on, UI op poort 81 van de HA-host): nieuwe Proxy
   Host, bijv. `lidlmcp.steno.nl` → forward naar `lidl-mcp:8000` (containernaam, via het
   gedeelde `hassio`-netwerk) of naar `<host-ip>:8934` (published poort) — beide werken,
   eerste is consistenter met hoe `vault-broker-mcp` het doet. SSL via Let's Encrypt
   (automatisch). **Geen speciale NPM-config nodig voor het secret** — dat wordt nu
   applicatie-side gecheckt (`MCP_SSE_SECRET`, zie `server_sse.py`), NPM hoeft alleen
   het volledige pad+querystring gewoon door te geven, wat het standaard al doet.
   Client-URL's worden dan (zie [Transports](#transports--twee-endpoints-verschillende-clients)
   hierboven voor waarom er twee zijn):
   - HA MCP Client: `https://lidlmcp.steno.nl/sse?key=<MCP_SSE_SECRET>`
   - Claude.ai Custom Connector: `https://lidlmcp.steno.nl/mcp/<MCP_SSE_SECRET>`
6. **HA MCP Client-integratie**: nieuwe instance toevoegen naast de bestaande twee
   (ah-mcp, nl-supermarkt-mcp), wijzend naar de `/sse`-URL uit stap 5. Interactieve
   config-flow (Instellingen → Apparaten & diensten → Integratie toevoegen → MCP Client).
7. **Conversation-agent-prompt**: uitgebreid met een derde tool-beschrijving — gebruik
   `lidl-mcp` specifiek voor vragen over eigen Lidl-aankopen/bonnen/coupons, niet voor
   prijsvergelijking (dat blijft `nl-supermarkt-mcp`). Zit in de `conversation`-subentry
   van de Google Generative AI-integratie in HA (niet in dit repo — HA-config).
8. **Claude.ai Custom Connector** (optioneel, los van de HA-integratie): Instellingen →
   Connectors → Add custom connector → de `/mcp/<secret>`-URL uit stap 5.

Alle 8 stappen zijn doorlopen voor de live deployment (2026-08-14) — dit is dus ook een
logboek van wat er staat, niet alleen een handleiding voor de eerste keer.

## Beperkingen / risico's

- `lidl-plus` is **niet-officieel**, reverse-engineered van de Lidl Plus-app-API en kan
  zonder waarschuwing stoppen met werken.
- Response-schema's van `tickets()` / `ticket()` / `coupons()` zijn niet door Lidl
  gedocumenteerd; deze server geeft de JSON grotendeels ruw door in plaats van op
  veldnamen te gokken die kunnen wijzigen.
- Coupon-activatie is een echte schrijf-actie op het Lidl-account — er is geen "dry
  run".

Maintenance

ActivityMaintained
ResponsivenessNo issues