Skip to main content
Glama
donadelicc

kundeavis-mcp

by donadelicc
README.md
# kundeavis-mcp

An MCP server that makes Norwegian grocery **kundeaviser** (weekly store flyers) queryable
by 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, Coop
Mega/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 to
compile).

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

```bash
npm i -g github:donadelicc/kundeavis-mcp
```

```json
{
  "mcpServers": {
    "kundeavis": { "command": "kundeavis-mcp" }
  }
}
```

The first query fetches the current flyers automatically (about 45 HTTP requests, a few
seconds) 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 start
with 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 a
week rollover: at two minutes past expiry, a KIWI "Uke 30" catalog served 12 of the 79
offers it had served minutes earlier — and all 12 survivors were two-week offers running
to 2 August. Everything scoped to the week itself was already unreachable. `/offers`
serves only what is valid *at request time*.

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

There is no archive endpoint and no backfill. `offset` past the end returns `[]`, and date
or 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. A
  backfill attempt looks like it worked and stores nothing.
- `offer_count` overstates what `/offers` will ever return — MENY's "Uke 31" claims 151
  and serves 70, before *and* after the flyer activates. It is not a timing artefact and
  not 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 appears
before it activates, but a week drops off the moment it ends. Once `run_till` passes there
is nothing to go back for. **Miss a week and it is gone permanently.** So:

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

launchd rather than cron specifically because a job missed while the Mac was asleep runs
on 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 **zero**
HTTP 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-price
parser is still improving — archiving today's output would freeze today's bugs into a
history that can never be re-derived. `kundeavis-mcp import` replays snapshots through
the *current* normalizer, so every future parser fix retroactively improves every week
you have ever captured.

Snapshots are ~380 KB gzipped per sync, so a year is roughly 20–40 MB. They are named to
the second and never overwritten: the API does not serve a stable set (two syncs 35
minutes apart returned 1,786 and 1,782 offers, four present only in the first), so
collapsing them would quietly drop offers. Replay unions snapshots, and conflicts resolve
to 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 — it
isn't ours to redistribute, which is the whole basis on which this project queries the
API client-side.

```bash
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 get
wrong. KIWI's *"HAMBURGER 79,90 / 4 x 100 g"* is **199.75 kr/kg**, not 799 — the pack total
is `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 is
cross-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's
member-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 confidently
wrong 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 not
  comparable to kr/kg — "pr. pk" and "/STK" both mean pieces but one is a pack and the
  other an item — so `pcs` offers are excluded from cheapest-first rankings.
- **Two different discount numbers, never conflated.** `discount_pct` is the retailer's own
  claim, from the flyer's pre-price, and is `null` for ~83% of offers. `vs_baseline_pct` is
  *our* 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 on
Saturday 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 every
Norwegian grocery offer), and headings are retail shorthand — `GULOST&CHEDDAR`,
`NAKKEKOTELETT`, `REKER I LØSVEKT`. So `data/ingredients.yaml` maps them to canonical
ingredients, and it is the most useful thing to contribute to.

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

```bash
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 the
heading or appears in it delimited by spaces, so `SVINEKOTELETT` matches
`SVINEKOTELETT MED BEN` but **not** `SVINEKOTELETTER` — Norwegian inflection is not
handled for you, and you should enumerate the forms you expect. Æ, Ø and Å survive
normalization; 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 ingredient
to claim it wins and the second never fires — which is how `RIBBE` ended up meaning
spareribs rather than juleribbe, and `PINNEKJØTT` sat on `lamb` despite selling at a
multiple 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 observe
`RIBBE` or `PINNEKJØTT` would mean poor coverage every December. Those entries are inert
in July and cost nothing to carry; correct them against real headings when the season
arrives.

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:

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

## Data source

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

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

Accordingly:

- This project is **not affiliated with, endorsed by, or connected to Tjek, mattilbud.no, or
  any 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, an
  identifying 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

```bash
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 implement
`node: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 empty
  on every offer), so a regional price difference is invisible. `find_stores` is a separate
  lookup.
- **Regional catalog duplicates are collapsed, not merged.** Chains publish one catalog per
  region — 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 for
  near-identical data.
- **Catalog metadata overstates its own contents.** A catalog claiming 80 offers may only
  expose 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. Baselines
  only 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 will
  understate how good a deal is. Accumulating more weeks does not remove that bias; only
  a 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.

TDQS

A4/5.0

Scored across 7 tools

Disambiguation4/5

Most tools have clearly distinct purposes: search/compare/history/cheapest all target prices but from different angles (single scan vs cross-chain vs cross-week vs ranked summary). cheapest_by_category and search_offers overlap somewhat (both surface cheap offers), though category vs free-text search differentiates them reasonably. price_history vs compare_ingredient also share some ground but split time-series from current-week cross-chain.

Naming Consistency4/5

Naming follows a consistent verb_noun pattern (search_offers, compare_ingredient, find_stores, list_chains, price_history). The two outliers are cheapest_by_category (adjective_noun preposition) and sync (single verb), which break the pattern but are still readable and not confusing.

Tool Count5/5

Seven tools is a well-scoped surface for a grocery-offer MCP server. Each covers a meaningful concern: discovery, ranking, cross-chain comparison, history, store lookup, chain coverage, and data refresh. No tool feels superfluous.

Completeness5/5

The domain is grocery-offer lookup and meal planning, and the surface covers it thoroughly: search, cheapest-ranking, cross-chain comparison, historical baseline, store proximity, chain coverage, and upstream sync. The only minor gap is no explicit per-season/weekly API refresh trigger beyond sync, but sync covers data freshness.

Maintenance

ActivitySlowing
ResponsivenessNo issues