Skip to main content
Glama
StenoJS
by StenoJS

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 library. Zie 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.

Related MCP server: Willys MCP Server

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

Volledige itemlijst van één bon (prijs, korting, barcode)

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) 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), 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) 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.

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).

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

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

Lokaal draaien

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:

    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 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))").

  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 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".

A
license - permissive license
-
quality - not tested
C
maintenance

Maintenance

Maintainers
Response time
Release cycle
Releases (12mo)
Commit activity

Resources

Unclaimed servers have limited discoverability.

Looking for Admin?

If you are the server author, to access and configure the admin panel.

Related MCP Servers

View all related MCP servers

Related MCP Connectors

View all MCP Connectors

Latest Blog Posts

MCP directory API

We provide all the information about MCP servers via our MCP API.

curl -X GET 'https://glama.ai/api/mcp/v1/servers/StenoJS/lidl-mcp'

If you have feedback or need assistance with the MCP directory API, please join our Discord server