donadelicc

kundeavis-mcp

Community donadelicc
Updated

MCP server for Norwegian grocery store weekly offers (kundeaviser) — unit-price verified, ingredient-tagged, built for AI meal planning.

kundeavis-mcp

An MCP server that makes Norwegian grocery kundeaviser (weekly store flyers) queryableby an AI agent, so it can plan cheap meals from what's actually on offer this week.

Roughly 1,800 offers a week across 16 chains — KIWI, REMA 1000, MENY, Extra, SPAR, CoopMega/Prix, Bunnpris, Joker, Obs, Matkroken, Nærbutikken, Jacobs, Europris, Gigaboks,Holdbart — normalised to comparable unit prices and tagged with canonical ingredients.

you: plan three cheap dinners for this week
agent: [cheapest_by_category category=meat]
       → sausage 55 kr/kg at Obs, pork chop 69.90 kr/kg at Obs, pork neck 89.90 kr/kg at Coop Prix

Install

Requires Node 22.5+ (it uses the built-in node:sqlite, so there is no native module tocompile).

Not on npm yet — install from the repo. dist/ is built during install:

npm i -g github:donadelicc/kundeavis-mcp
{
  "mcpServers": {
    "kundeavis": { "command": "kundeavis-mcp" }
  }
}

The first query fetches the current flyers automatically (about 45 HTTP requests, a fewseconds) into ~/.kundeavis-mcp/offers.db. Nothing else to set up.

Each machine keeps its own database and its own archive. Two agents on two machines startwith two separate histories — see below if you want them to share one.

Tools

Tool What it answers
cheapest_by_category "What protein is cheap this week?" Cheapest offer per ingredient, ranked by unit price. The main meal-planning entry point.
search_offers Free-text plus filters on ingredient, category, chain, unit-price basis, discount.
compare_ingredient One ingredient priced across every chain, against a baseline from earlier weeks.
price_history Cheapest unit price per week per chain, to judge whether a "tilbud" is genuinely good.
find_stores Grocery stores near a coordinate. Offers are chain-national, so this answers which shop to go to.
list_chains Coverage per chain: offers, how many have a usable unit price, how many are tagged.
sync Force a refresh. Runs automatically when nothing valid is cached.

There is no history upstream — capture it or lose it

This is the single most important thing to know before relying on this tool.

Offers vanish at their own run_till, with no grace period. Watched live across aweek rollover: at two minutes past expiry, a KIWI "Uke 30" catalog served 12 of the 79offers it had served minutes earlier — and all 12 survivors were two-week offers runningto 2 August. Everything scoped to the week itself was already unreachable. /offersserves only what is valid at request time.

And old catalogs are deleted outright. Verified against real expired IDs recoveredfrom archived mattilbud.no HTML: a KIWI catalog from May 2024 and three Danish ones from2020 all return CATALOG_NOT_FOUND, byte-identical to the response for an ID that neverexisted. (A just-expired catalog's record lingers a while — it still answered 200, stillclaiming offer_count: 85 — but the offers behind it were already gone, which is the onlypart that matters.)

There is no archive endpoint and no backfill. offset past the end returns [], and dateor ordering parameters are accepted but silently ignored.

Two traps worth knowing:

  • offers?catalog_id=<expired> returns HTTP 200 with an empty array, not an error. Abackfill attempt looks like it worked and stores nothing.
  • offer_count overstates what /offers will ever return — MENY's "Uke 31" claims 151and serves 70, before and after the flyer activates. It is not a timing artefact andnot a pagination bug; the reachable set is simply smaller. Reported as a shortfall.

What the listing gives you is lookahead, not lookback: next week's flyer appearsbefore it activates, but a week drops off the moment it ends. Once run_till passes thereis nothing to go back for. Miss a week and it is gone permanently. So:

kundeavis-mcp schedule --install   # twice daily, macOS launchd

launchd rather than cron specifically because a job missed while the Mac was asleep runson the next wake; cron just skips it, and a skipped Saturday is an unrecoverable week.Runs pass --if-stale, which decides locally — most fire and exit having made zeroHTTP requests.

What to back up

Three tiers, and only the middle one matters:

~/.kundeavis-mcp/offers.db derived cache don't back up — rebuildable
~/.kundeavis-mcp/history/*.ndjson.gz raw API payloads, immutable this is the asset
a private git repo off-machine copy recommended

The archive stores the raw payloads, not normalized rows, because the unit-priceparser is still improving — archiving today's output would freeze today's bugs into ahistory that can never be re-derived. kundeavis-mcp import replays snapshots throughthe current normalizer, so every future parser fix retroactively improves every weekyou have ever captured.

Snapshots are ~380 KB gzipped per sync, so a year is roughly 20–40 MB. They are named tothe second and never overwritten: the API does not serve a stable set (two syncs 35minutes apart returned 1,786 and 1,782 offers, four present only in the first), socollapsing them would quietly drop offers. Replay unions snapshots, and conflicts resolveto the best-evidenced copy rather than the last one written.

Back it up somewhere private. Please don't commit offer data to a public repo — itisn't ours to redistribute, which is the whole basis on which this project queries theAPI client-side.

kundeavis-mcp history    # what you have captured
kundeavis-mcp import     # rebuild the database from it, no network

Unit prices: read the confidence field

Comparing groceries means comparing unit prices, and flyer data makes that easy to getwrong. KIWI's "HAMBURGER 79,90 / 4 x 100 g" is 199.75 kr/kg, not 799 — the pack totalis pieces × size × SI factor, and ignoring pieces is wrong by 4×.

Descriptions often repeat the retailer's own printed unit price, so every computed value iscross-checked against it. Each offer carries the outcome:

unit_price_confidence Meaning
verified Our value matches the kr/kg or kr/l figure printed in the flyer.
computed Derived from the pack data, but nothing comparable was printed — or the source is internally ambiguous (see notes).
unavailable We declined to serve a number. unit_price is null.

On a live week: 98.3% of checkable offers verified (865 / 880), and 15 of 1,782 offers(0.8%) had their unit price withheld. Those are genuine source inconsistencies — Coop'smember-vs-non-member pricing, a flyer quoting drained weight while the API reports gross,Tjek reporting pieces: 1 for a multipack. A withheld value is deliberate: a confidentlywrong kr/kg is the worst thing a price-comparison tool can do.

Two more rules the tools follow:

  • Never mix bases. unit_price_basis is kg, l or pcs. A per-piece price is notcomparable to kr/kg — "pr. pk" and "/STK" both mean pieces but one is a pack and theother an item — so pcs offers are excluded from cheapest-first rankings.
  • Two different discount numbers, never conflated. discount_pct is the retailer's ownclaim, from the flyer's pre-price, and is null for ~83% of offers. vs_baseline_pct isour inference from stored history. The second is never presented as the first.

"This week" means valid right now, not the current ISO week. Flyers change over onSaturday or Sunday, so those differ by a day or two every week.

Ingredient coverage

The upstream API returns no categories at all (category_ids is empty on everyNorwegian grocery offer), and headings are retail shorthand — GULOST&CHEDDAR,NAKKEKOTELETT, REKER I LØSVEKT. So data/ingredients.yaml maps them to canonicalingredients, and it is the most useful thing to contribute to.

Currently ~57% of offers carry an ingredient tag. The untagged remainder is mostlybrand-specific compound names and non-food. To help:

kundeavis-mcp headings          # ranked worklist of what isn't covered
# add aliases to data/ingredients.yaml
kundeavis-mcp retag             # re-tag locally, no network, prints the new coverage
node test/taxonomy-lint.mjs     # catches aliases that can never fire

Matching is whole-token, not prefix or substring. An alias matches when it equals theheading or appears in it delimited by spaces, so SVINEKOTELETT matchesSVINEKOTELETT MED BEN but not SVINEKOTELETTER — Norwegian inflection is nothandled for you, and you should enumerate the forms you expect. Æ, Ø and Å survivenormalization; other diacritics are folded, so write ENTRECOTE. Add the ingredient word,not the brand.

Run the lint before opening a PR. A duplicate alias fails silently — the first ingredientto claim it wins and the second never fires — which is how RIBBE ended up meaningspareribs rather than juleribbe, and PINNEKJØTT sat on lamb despite selling at amultiple of fresh lamb's price per kg.

The taxonomy also carries seasonal vocabulary written ahead of the season (jul, påske,fårikål, grillsesong). Since expired flyers can't be re-fetched, waiting to observeRIBBE or PINNEKJØTT would mean poor coverage every December. Those entries are inertin July and cost nothing to carry; correct them against real headings when the seasonarrives.

An optional LLM fallback resolves unknown headings and appends them to data/learned.yaml,with low-confidence answers diverted to needs_review.yaml. It is off by default —a routine sync should not spend money unless you asked:

KUNDEAVIS_LLM=1 ANTHROPIC_API_KEY=... kundeavis-mcp sync

Data source

Offer data comes from the read endpoints of Tjek (formerly ShopGun / eTilbudsavis), theplatform Norwegian retailers publish their flyers through and which sites like mattilbud.noare built on. Retailers submit hotspot metadata, so offers arrive already structured — thereis no OCR here.

Please read this before depending on it. That API is undocumented, not published opendata. It happens to serve these reads unauthenticated, but it advertisesX-Api-Key/X-Token in its CORS headers, which suggests a permissive default rather than apublic contract. It can change or close at any time.

Accordingly:

  • This project is not affiliated with, endorsed by, or connected to Tjek, mattilbud.no, orany retailer. All trademarks belong to their owners.
  • No offer data is redistributed. Every install queries upstream itself and caches locally.What ships in this repo is our own code and taxonomy.
  • The client is deliberately gentle: concurrency capped at 4, exponential backoff, anidentifying User-Agent, and aggressive caching. A full national sync is ~45 requests.Please don't raise those limits.

MIT licensed — covering this project's code and the ingredient taxonomy, not upstream data.

Development

bun install
bun run build                      # tsc -> dist/
bun run test                       # taxonomy lint + archive round trip + unit prices
bun run validate:unitprice         # correctness gate: computed vs printed, live week
node test/taxonomy-lint.mjs        # alias collisions, categories, shape (no network)
KUNDEAVIS_HISTORY=./dev-history node test/archive-roundtrip.mjs   # replay + union
node test/mcp-smoke.mjs            # end-to-end MCP over stdio
KUNDEAVIS_DB=./dev.db KUNDEAVIS_HISTORY=./dev-history node dist/cli.js sync
KUNDEAVIS_DB=./dev.db node dist/cli.js stats

test:archive needs a snapshot to work from, so run a sync first.

bun is fine for dev, but the server itself runs on Node — Bun does not implementnode:sqlite.

Env var Default
KUNDEAVIS_DB ~/.kundeavis-mcp/offers.db
KUNDEAVIS_HISTORY ~/.kundeavis-mcp/history
KUNDEAVIS_DATA the packaged data/ directory
KUNDEAVIS_LLM unset (LLM fallback off)
ANTHROPIC_API_KEY — required only with KUNDEAVIS_LLM=1

Known limitations

  • Offers are chain-national. The API exposes no per-store pricing (store_id is emptyon every offer), so a regional price difference is invisible. find_stores is a separatelookup.
  • Regional catalog duplicates are collapsed, not merged. Chains publish one catalog perregion — Holdbart ships 26 with identical labels — and we keep the richest variant per(chain, label, date range). Fetching all of them would triple our request count fornear-identical data.
  • Catalog metadata overstates its own contents. A catalog claiming 80 offers may onlyexpose 73 at any offset. Reported as a shortfall, not treated as an error.
  • Price history starts when you do, and cannot be backfilled. See above. Baselinesonly cover weeks this installation captured while they were live.
  • The baseline is built from other weeks' offers, which are also discounts.vs_baseline_pct therefore compares a sale price against other sale prices and willunderstate how good a deal is. Accumulating more weeks does not remove that bias; onlya shelf-price reference would. Read it as "cheap for a tilbud", not "cheap".
  • Long-running seasonal magazines carry no structured offers (Coop Mega's "Sommermat",Joker's "Den gode sommermaten"). The weekly flyers that matter for price hunting all do.

MCP Server · Populars

MCP Server · New

    DareDev256

    FCPXML MCP

    🎬 The first AI-powered MCP server for Final Cut Pro XML. Control your edits with natural language.

    Community DareDev256
    acunningham-ship-it

    Veil

    Stealth browser for AI agents — real Chrome over raw CDP, no Playwright/Puppeteer. TypeScript + MCP-native. Passes sannysoft 57/57, bypasses Cloudflare.

    Cassette-Editor

    Oh My Cassette: Chat Your Raw Clips Into a Finished Cut

    你的随身 AI 剪辑搭档 | Pocket AI co-editor for video montage — AI video editing plugin & MCP server for Claude Code, Codex, Hermes & OpenCode

    Community Cassette-Editor
    trendsmcp-ai

    Trends MCP

    MCP server for live trend data. Query Google Search, YouTube, TikTok, Reddit, Amazon, Wikipedia, News sentiment, Web Traffic, App Downloads, Steam, npm and more. Works with Claude, Cursor, VS Code, GitHub Copilot, ChatGPT, Windsurf, Cline, Raycast and any MCP-compatible.

    Community trendsmcp-ai
    jacob-bd

    Gemini Notebook (formerly Google NotebookLM) CLI & MCP Server

    Programmatic access to Gemini Notebook - via command-line interface (CLI), Model Context Protocol (MCP) server, and AI agent skills.

    Community jacob-bd