@cyanheads/openfoodfacts-mcp-server 0.4.0 → 0.4.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/AGENTS.md +8 -3
- package/CLAUDE.md +8 -3
- package/Dockerfile +18 -0
- package/README.md +42 -11
- package/changelog/0.4.x/0.4.1.md +30 -0
- package/changelog/0.4.x/0.4.2.md +33 -0
- package/dist/config/server-config.d.ts +1 -0
- package/dist/config/server-config.d.ts.map +1 -1
- package/dist/config/server-config.js +11 -0
- package/dist/config/server-config.js.map +1 -1
- package/dist/index.js +9 -2
- package/dist/index.js.map +1 -1
- package/dist/mcp-server/tools/definitions/browse-taxonomy.tool.d.ts.map +1 -1
- package/dist/mcp-server/tools/definitions/browse-taxonomy.tool.js +13 -8
- package/dist/mcp-server/tools/definitions/browse-taxonomy.tool.js.map +1 -1
- package/dist/mcp-server/tools/definitions/compare-products.tool.d.ts +79 -7
- package/dist/mcp-server/tools/definitions/compare-products.tool.d.ts.map +1 -1
- package/dist/mcp-server/tools/definitions/compare-products.tool.js +298 -75
- package/dist/mcp-server/tools/definitions/compare-products.tool.js.map +1 -1
- package/dist/mcp-server/tools/definitions/get-product.tool.d.ts +20 -3
- package/dist/mcp-server/tools/definitions/get-product.tool.d.ts.map +1 -1
- package/dist/mcp-server/tools/definitions/get-product.tool.js +123 -40
- package/dist/mcp-server/tools/definitions/get-product.tool.js.map +1 -1
- package/dist/mcp-server/tools/definitions/index.d.ts +91 -38
- package/dist/mcp-server/tools/definitions/index.d.ts.map +1 -1
- package/dist/mcp-server/tools/definitions/search-products.tool.d.ts +11 -4
- package/dist/mcp-server/tools/definitions/search-products.tool.d.ts.map +1 -1
- package/dist/mcp-server/tools/definitions/search-products.tool.js +386 -114
- package/dist/mcp-server/tools/definitions/search-products.tool.js.map +1 -1
- package/dist/mcp-server/tools/read-source.d.ts +31 -0
- package/dist/mcp-server/tools/read-source.d.ts.map +1 -0
- package/dist/mcp-server/tools/read-source.js +45 -0
- package/dist/mcp-server/tools/read-source.js.map +1 -0
- package/dist/services/mirror/mirror-feeds.d.ts +58 -0
- package/dist/services/mirror/mirror-feeds.d.ts.map +1 -0
- package/dist/services/mirror/mirror-feeds.js +95 -0
- package/dist/services/mirror/mirror-feeds.js.map +1 -0
- package/dist/services/mirror/mirror-ingester.d.ts +112 -0
- package/dist/services/mirror/mirror-ingester.d.ts.map +1 -0
- package/dist/services/mirror/mirror-ingester.js +564 -0
- package/dist/services/mirror/mirror-ingester.js.map +1 -0
- package/dist/services/mirror/mirror-job.d.ts +9 -0
- package/dist/services/mirror/mirror-job.d.ts.map +1 -0
- package/dist/services/mirror/mirror-job.js +48 -0
- package/dist/services/mirror/mirror-job.js.map +1 -0
- package/dist/services/mirror/mirror-lock.d.ts +45 -0
- package/dist/services/mirror/mirror-lock.d.ts.map +1 -0
- package/dist/services/mirror/mirror-lock.js +128 -0
- package/dist/services/mirror/mirror-lock.js.map +1 -0
- package/dist/services/mirror/mirror-queries.d.ts +76 -0
- package/dist/services/mirror/mirror-queries.d.ts.map +1 -0
- package/dist/services/mirror/mirror-queries.js +281 -0
- package/dist/services/mirror/mirror-queries.js.map +1 -0
- package/dist/services/mirror/mirror-scheduler.d.ts +41 -0
- package/dist/services/mirror/mirror-scheduler.d.ts.map +1 -0
- package/dist/services/mirror/mirror-scheduler.js +131 -0
- package/dist/services/mirror/mirror-scheduler.js.map +1 -0
- package/dist/services/mirror/mirror-store.d.ts +150 -0
- package/dist/services/mirror/mirror-store.d.ts.map +1 -0
- package/dist/services/mirror/mirror-store.js +281 -0
- package/dist/services/mirror/mirror-store.js.map +1 -0
- package/dist/services/mirror/mirror-sync.d.ts +62 -0
- package/dist/services/mirror/mirror-sync.d.ts.map +1 -0
- package/dist/services/mirror/mirror-sync.js +226 -0
- package/dist/services/mirror/mirror-sync.js.map +1 -0
- package/dist/services/mirror/record-builder.d.ts +78 -0
- package/dist/services/mirror/record-builder.d.ts.map +1 -0
- package/dist/services/mirror/record-builder.js +957 -0
- package/dist/services/mirror/record-builder.js.map +1 -0
- package/dist/services/openfoodfacts/nutrition-sets.d.ts +132 -0
- package/dist/services/openfoodfacts/nutrition-sets.d.ts.map +1 -0
- package/dist/services/openfoodfacts/nutrition-sets.js +507 -0
- package/dist/services/openfoodfacts/nutrition-sets.js.map +1 -0
- package/dist/services/openfoodfacts/openfoodfacts-service.d.ts +202 -24
- package/dist/services/openfoodfacts/openfoodfacts-service.d.ts.map +1 -1
- package/dist/services/openfoodfacts/openfoodfacts-service.js +583 -104
- package/dist/services/openfoodfacts/openfoodfacts-service.js.map +1 -1
- package/dist/services/openfoodfacts/types.d.ts +27 -4
- package/dist/services/openfoodfacts/types.d.ts.map +1 -1
- package/dist/services/openfoodfacts/types.js +15 -0
- package/dist/services/openfoodfacts/types.js.map +1 -1
- package/dist/services/openfoodfacts/user-agent.d.ts +11 -0
- package/dist/services/openfoodfacts/user-agent.d.ts.map +1 -0
- package/dist/services/openfoodfacts/user-agent.js +17 -0
- package/dist/services/openfoodfacts/user-agent.js.map +1 -0
- package/dist/services/taxonomy/taxonomy-service.d.ts +49 -14
- package/dist/services/taxonomy/taxonomy-service.d.ts.map +1 -1
- package/dist/services/taxonomy/taxonomy-service.js +124 -35
- package/dist/services/taxonomy/taxonomy-service.js.map +1 -1
- package/package.json +9 -3
- package/server.json +3 -3
- package/skills/openfoodfacts-guide/SKILL.md +5 -3
package/AGENTS.md
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
# Developer Protocol
|
|
2
2
|
|
|
3
3
|
**Server:** openfoodfacts-mcp-server
|
|
4
|
-
**Version:** 0.4.
|
|
4
|
+
**Version:** 0.4.2
|
|
5
5
|
**Framework:** [@cyanheads/mcp-ts-core](https://www.npmjs.com/package/@cyanheads/mcp-ts-core) `^0.13.18`
|
|
6
6
|
**Engines:** Bun ≥1.4.0, Node ≥24.0.0
|
|
7
7
|
**MCP SDK:** `@modelcontextprotocol/server` ^2.4.0
|
|
@@ -206,16 +206,20 @@ See framework CLAUDE.md and the `api-errors` skill for the full auto-classificat
|
|
|
206
206
|
src/
|
|
207
207
|
index.ts # createApp() entry point
|
|
208
208
|
config/
|
|
209
|
-
server-config.ts # Server-specific env vars (OFF_BASE_URL, rate limits)
|
|
209
|
+
server-config.ts # Server-specific env vars (OFF_BASE_URL, rate limits, OFF_MIRROR_PATH)
|
|
210
210
|
services/
|
|
211
|
+
mirror/ # Optional local export mirror (OFF_MIRROR_PATH): store, record builder, ingester, queries, refresh schedule
|
|
211
212
|
openfoodfacts/
|
|
212
|
-
openfoodfacts-service.ts # Open Food Facts API client (HTTP, rate limiting, retry)
|
|
213
|
+
openfoodfacts-service.ts # Open Food Facts API client (HTTP, rate limiting, retry); reads the mirror first when one is ready
|
|
214
|
+
user-agent.ts # Identifying User-Agent shared by the client and the mirror's downloads
|
|
215
|
+
nutrition-sets.ts # Stored as-sold nutrition sets: choosing one for a prepared-only read
|
|
213
216
|
types.ts # Domain types
|
|
214
217
|
taxonomy/
|
|
215
218
|
taxonomy-service.ts # Tag vocabulary — live resolution, offline sample, merge/fallback policy, tag-value canonicalization
|
|
216
219
|
utils/
|
|
217
220
|
markdown.ts # Contextual Markdown escaping for crowd-sourced values in content[]
|
|
218
221
|
mcp-server/
|
|
222
|
+
tools/read-source.ts # How tool text names an answer's source (off_api / off_mirror) and the mirror freshness note
|
|
219
223
|
tools/definitions/
|
|
220
224
|
get-product.tool.ts # off_get_product
|
|
221
225
|
search-products.tool.ts # off_search_products
|
|
@@ -316,6 +320,7 @@ When you complete a skill's checklist, check the boxes and add a completion time
|
|
|
316
320
|
| `bun run format` | Auto-fix formatting (safe fixes only) |
|
|
317
321
|
| `bun run format:unsafe` | Also apply Biome's unsafe autofixes — review the diff; they can change behavior |
|
|
318
322
|
| `bun run test` | Run tests (Vitest — use `bun run test`, not `bun test`) |
|
|
323
|
+
| `bun run mirror:init` / `mirror:refresh` / `mirror:status` / `mirror:verify` | Build, refresh, inspect, and integrity-check the local export mirror at `OFF_MIRROR_PATH` (checkout or Docker image) |
|
|
319
324
|
| `bun run start:stdio` | Production mode (stdio) |
|
|
320
325
|
| `bun run start:http` | Production mode (HTTP) |
|
|
321
326
|
| `bun run changelog:build` | Regenerate `CHANGELOG.md` from `changelog/*.md` |
|
package/CLAUDE.md
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
# Developer Protocol
|
|
2
2
|
|
|
3
3
|
**Server:** openfoodfacts-mcp-server
|
|
4
|
-
**Version:** 0.4.
|
|
4
|
+
**Version:** 0.4.2
|
|
5
5
|
**Framework:** [@cyanheads/mcp-ts-core](https://www.npmjs.com/package/@cyanheads/mcp-ts-core) `^0.13.18`
|
|
6
6
|
**Engines:** Bun ≥1.4.0, Node ≥24.0.0
|
|
7
7
|
**MCP SDK:** `@modelcontextprotocol/server` ^2.4.0
|
|
@@ -206,16 +206,20 @@ See framework CLAUDE.md and the `api-errors` skill for the full auto-classificat
|
|
|
206
206
|
src/
|
|
207
207
|
index.ts # createApp() entry point
|
|
208
208
|
config/
|
|
209
|
-
server-config.ts # Server-specific env vars (OFF_BASE_URL, rate limits)
|
|
209
|
+
server-config.ts # Server-specific env vars (OFF_BASE_URL, rate limits, OFF_MIRROR_PATH)
|
|
210
210
|
services/
|
|
211
|
+
mirror/ # Optional local export mirror (OFF_MIRROR_PATH): store, record builder, ingester, queries, refresh schedule
|
|
211
212
|
openfoodfacts/
|
|
212
|
-
openfoodfacts-service.ts # Open Food Facts API client (HTTP, rate limiting, retry)
|
|
213
|
+
openfoodfacts-service.ts # Open Food Facts API client (HTTP, rate limiting, retry); reads the mirror first when one is ready
|
|
214
|
+
user-agent.ts # Identifying User-Agent shared by the client and the mirror's downloads
|
|
215
|
+
nutrition-sets.ts # Stored as-sold nutrition sets: choosing one for a prepared-only read
|
|
213
216
|
types.ts # Domain types
|
|
214
217
|
taxonomy/
|
|
215
218
|
taxonomy-service.ts # Tag vocabulary — live resolution, offline sample, merge/fallback policy, tag-value canonicalization
|
|
216
219
|
utils/
|
|
217
220
|
markdown.ts # Contextual Markdown escaping for crowd-sourced values in content[]
|
|
218
221
|
mcp-server/
|
|
222
|
+
tools/read-source.ts # How tool text names an answer's source (off_api / off_mirror) and the mirror freshness note
|
|
219
223
|
tools/definitions/
|
|
220
224
|
get-product.tool.ts # off_get_product
|
|
221
225
|
search-products.tool.ts # off_search_products
|
|
@@ -316,6 +320,7 @@ When you complete a skill's checklist, check the boxes and add a completion time
|
|
|
316
320
|
| `bun run format` | Auto-fix formatting (safe fixes only) |
|
|
317
321
|
| `bun run format:unsafe` | Also apply Biome's unsafe autofixes — review the diff; they can change behavior |
|
|
318
322
|
| `bun run test` | Run tests (Vitest — use `bun run test`, not `bun test`) |
|
|
323
|
+
| `bun run mirror:init` / `mirror:refresh` / `mirror:status` / `mirror:verify` | Build, refresh, inspect, and integrity-check the local export mirror at `OFF_MIRROR_PATH` (checkout or Docker image) |
|
|
319
324
|
| `bun run start:stdio` | Production mode (stdio) |
|
|
320
325
|
| `bun run start:http` | Production mode (HTTP) |
|
|
321
326
|
| `bun run changelog:build` | Regenerate `CHANGELOG.md` from `changelog/*.md` |
|
package/Dockerfile
CHANGED
|
@@ -143,12 +143,30 @@ COPY --from=deps /usr/src/app/node_modules ./node_modules
|
|
|
143
143
|
COPY --from=build /usr/src/app/dist ./dist
|
|
144
144
|
COPY --from=build /usr/src/app/skills ./skills
|
|
145
145
|
|
|
146
|
+
# Mirror lifecycle scripts (`bun run mirror:init|refresh|status|verify` via
|
|
147
|
+
# `docker exec <container> …`). The shared context shim is imported by the
|
|
148
|
+
# three named scripts, so it travels with them. Bun honors tsconfig `paths` at
|
|
149
|
+
# runtime: a checkout's tsconfig.json maps `@/*` to `./src/*`, and this emitted
|
|
150
|
+
# one maps it to the compiled `./dist/*`, so the same package scripts run in both.
|
|
151
|
+
COPY --from=build /usr/src/app/scripts/mirror-init.ts \
|
|
152
|
+
/usr/src/app/scripts/mirror-refresh.ts \
|
|
153
|
+
/usr/src/app/scripts/mirror-status.ts \
|
|
154
|
+
/usr/src/app/scripts/_mirror-context.ts \
|
|
155
|
+
./scripts/
|
|
156
|
+
RUN echo '{"compilerOptions":{"baseUrl":".","paths":{"@/*":["./dist/*"]}}}' > tsconfig.json
|
|
157
|
+
|
|
146
158
|
# The 'oven/bun' image already provides a non-root user named 'bun'.
|
|
147
159
|
# We will use this existing user for enhanced security.
|
|
148
160
|
|
|
149
161
|
# Create and set permissions for the log directory, assigning ownership to the 'bun' user.
|
|
150
162
|
RUN mkdir -p /var/log/openfoodfacts-mcp-server && chown -R bun:bun /var/log/openfoodfacts-mcp-server
|
|
151
163
|
|
|
164
|
+
# Writable home for the export mirror (OFF_MIRROR_PATH, e.g. .mirror/openfoodfacts.sqlite),
|
|
165
|
+
# owned by the runtime user so a volume mounted over it is writable. A full init of the
|
|
166
|
+
# 4.9M-product export peaks at about 27 GB there while the export download sits beside the
|
|
167
|
+
# store, and leaves a store of about 14.3 GB.
|
|
168
|
+
RUN mkdir -p /usr/src/app/.mirror && chown -R bun:bun /usr/src/app/.mirror
|
|
169
|
+
|
|
152
170
|
# Switch to the non-root user
|
|
153
171
|
USER bun
|
|
154
172
|
|
package/README.md
CHANGED
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
|
|
8
8
|
<div align="center">
|
|
9
9
|
|
|
10
|
-
[](./CHANGELOG.md) [](./LICENSE) [](https://github.com/users/cyanheads/packages/container/package/openfoodfacts-mcp-server) [](https://modelcontextprotocol.io/) [](https://www.npmjs.com/package/@cyanheads/openfoodfacts-mcp-server) [](https://www.typescriptlang.org/) [](https://bun.sh/)
|
|
11
11
|
|
|
12
12
|
</div>
|
|
13
13
|
|
|
@@ -50,31 +50,37 @@ Packaged-food data from Open Food Facts, a crowd-sourced database of 3M+ product
|
|
|
50
50
|
|
|
51
51
|
### `off_get_product` <sub>tool</sub>
|
|
52
52
|
|
|
53
|
-
- `barcode` is digits only, 4–40 digits after any leading zeros; optional `fields` trims the response and pulls in what a field depends on (`nutriments` brings the serving size), with `requested_fields` echoing what was fetched
|
|
53
|
+
- `barcode` is digits only, 4–40 digits after any leading zeros; optional `fields` trims the response and pulls in what a field depends on (`nutriments` brings the serving size, quantity, and unit, and `serving_quantity` and `serving_quantity_unit` each bring the other plus the serving size), with `requested_fields` echoing what was fetched
|
|
54
54
|
- Returns ingredients (raw text plus a parsed tree up to three levels deep with `percent_estimate`), `allergens_tags`, `traces_tags`, `additives_tags`, `ingredients_analysis_tags` (vegan, vegetarian, palm-oil verdicts), `nutriscore_grade`, `nova_group`, `ecoscore_grade` (Green-Score), `nutriments` (per serving, and per 100 g, or per 100 ml for liquids), category/label/packaging/origin/country tags, `image_url`, and `completeness` (0–1)
|
|
55
55
|
- `traces_tags: ["en:none"]` means the label states no traces, while an empty array means none entered yet; a barcode no contributor has recorded fails as `not_found`
|
|
56
|
+
- Open Food Facts returns one preparation state of the nutrition per read. A read of prepared-product figures puts them under keys ending in `_prepared` in `additional_100g` and `additional_serving`, and prints them in "as prepared" sections. When a read returns only prepared figures, one more product read fetches the single as-sold set the record stores (`/api/v3.5` `fields=nutrition.input_sets`) and fills the named as-sold fields per 100 g, or per 100 ml for liquids, with no as-sold per-serving figure; `nutriments.stored_as_sold` reports `read`, `none` (the record stores no usable set), or `unread` with a `reason`, an `error`, and, when the failure named a wait, `retryAfter` in whole seconds (the call itself still succeeds). Other reads send nothing extra
|
|
57
|
+
- `source` (`off_api` or `off_mirror`) names where the product was read; a barcode a [local mirror](#local-export-mirror) holds is read from it with no upstream request, with `mirror_as_of` (the time of the newest Open Food Facts change the copy reflects) and a `mirror_snapshot` note, and the stored as-sold set comes from the same stored record
|
|
56
58
|
|
|
57
59
|
---
|
|
58
60
|
|
|
59
61
|
### `off_search_products` <sub>tool</sub>
|
|
60
62
|
|
|
61
63
|
- `query` (up to 24 words, all of which must match the name, generic name, categories, labels, or brand; punctuation separates words, so `milk-chocolate` requires both) plus the tag filters `categories_tag`, `brands_tag`, `labels_tag` (up to 10), `allergens_tag`, `traces_tag`, `ingredients_analysis_tag`, `additives_tag`, `nutrition_grade`, `nova_group`, `countries_tag`, the `exclude_allergens` / `exclude_traces` lists (up to 14 each), and up to 18 `nutrient_filters` (per 100 g, or per 100 ml for liquids), all combined as AND; optional `sort_by`
|
|
62
|
-
- `page_size` defaults to 20 and is served at up to 50 per call; a larger value
|
|
63
|
-
- `
|
|
64
|
+
- `page_size` defaults to 20 and is served at up to 50 per call; a larger value, with no ceiling, is served at 50 and flagged in `page_size_capped`, and `page` counts pages of 50. Rows carry `barcode`, `product_name`, `brands`, `nutriscore_grade`, `nova_group`, `ecoscore_grade`, and `categories_tags`, plus `total`, `total_is_lower_bound`, `last_page`, `omitted`, and `truncated` (more matches beyond this page). Exclusions add `exclusion_coverage`, since products with no allergen data entered pass them; `nutrient_filters` add `indexed_nutriments` (the index values the filters checked, which can differ from `off_get_product`)
|
|
65
|
+
- The text index compares `energy-kcal`, `fat`, `saturated-fat`, `carbohydrates`, and `sugars` as whole numbers (`sugars lte 3` returns a row at 3.53). A `nutrient_filters` search sends `gt` and `gte` on those five as `[floor(value) TO *]` so no match is lost, then checks every returned row against every constraint at the index's 32-bit precision. Rows that fail leave the page and count in `outside_constraints`, so a page can come back short or empty while later pages hold matches. `total`, `total_is_lower_bound`, and `last_page` describe the index's whole-number answer and still count those rows. A range inside one whole number (`sugars gt 3.9` and `lt 4`) can pass few of the 10,000 reachable rows; add `query` or tag filters to narrow it
|
|
66
|
+
- `query` or `nutrient_filters` searches a text index that lags the live database (flagged in `text_index_snapshot`), reaches only the first 10,000 results, and rejects `additives_tag` as `additives_filter_needs_tag_search`; tag-only searches read the live database through page 10, unless a [local mirror](#local-export-mirror) serves them: with every tag value confirmed, a mirror-served search has an exact `total` and reaches its first 10,000 results. A page past any of these limits fails as `page_out_of_range`
|
|
67
|
+
- `source` (`off_api` or `off_mirror`) names where the answer was read; from the mirror it also carries `mirror_as_of` and a `mirror_snapshot` note, and the default order and `sort_by` `popularity_key` and `unique_scans_n` follow the values stored with each record, which can differ from a live search, which ranks by its current scan counts
|
|
64
68
|
|
|
65
69
|
---
|
|
66
70
|
|
|
67
71
|
### `off_compare_products` <sub>tool</sub>
|
|
68
72
|
|
|
69
73
|
- 2–100 `barcodes`, one row each in input order; repeated barcodes are compared once, and those past the 10th distinct one are listed in `unprocessed` without being fetched
|
|
70
|
-
- Rows carry `found`, `nutriscore_grade`, `nova_group`, `ecoscore_grade`, energy, fat, saturated fat, sugars, salt, protein, and fiber (per 100 g, or per 100 ml for liquids), and `completeness`; missing values stay absent, never imputed
|
|
71
|
-
- `
|
|
74
|
+
- Rows carry `found`, `nutriscore_grade`, `nova_group`, `ecoscore_grade`, energy, fat, saturated fat, carbohydrates, sugars, salt, protein, and fiber (per 100 g, or per 100 ml for liquids), and `completeness`; missing values stay absent, never imputed
|
|
75
|
+
- A read of prepared-product figures adds a `prepared` object on the row with the same columns, read from the `*_prepared_100g` keys and never substituted for an as-sold figure. A row whose read returned only prepared figures gets the same stored as-sold read as `off_get_product`, once every barcode's product read has settled, and reports it as `stored_as_sold` (`read`, `none`, or `unread`, which never moves the barcode into `failed`). A row with as-sold figures appears in the as-sold table, a row with `prepared` in the "as prepared" table, and a row with both appears in both
|
|
76
|
+
- Each row carries `source` (`off_api` or `off_mirror`), and `mirror_as_of` for a row read from a [local mirror](#local-export-mirror), so one comparison can mix the two; a `mirror_snapshot` note appears when any row came from it
|
|
77
|
+
- `not_found` lists barcodes with no contributor record and `failed` lists fetches that failed, each with a `reason` and, when the failure named a wait, `retryAfter` in whole seconds; a failed barcode gets no row and never blocks the rest
|
|
72
78
|
|
|
73
79
|
---
|
|
74
80
|
|
|
75
81
|
### `off_browse_taxonomy` <sub>tool</sub>
|
|
76
82
|
|
|
77
|
-
- `facet` is one of `categories`, `labels`, `allergens`, `additives`, `countries`, `nova_groups`, `nutrition_grades`; `search` matches a substring of the tag ID, name, or a synonym ("shellfish" → `en:crustaceans`); `limit` (default 20) returns up to
|
|
83
|
+
- `facet` is one of `categories`, `labels`, `allergens`, `additives`, `countries`, `nova_groups`, `nutrition_grades`; `search` matches a substring of the tag ID, name, or a synonym ("shellfish" → `en:crustaceans`); `limit` (default 20) returns up to 200 per call, with no input maximum (a larger value returns the first 200); a cut result says how many matched; no paging. Each lookup asks for up to 2,000 suggestions, above the largest answer measured (1,313), so an E-number term such as `e314` resolves to `en:e314` and the match count is exact below that size
|
|
78
84
|
- Returns `tags[]` of `id` and `name`, where `id` goes to `off_search_products` unchanged; `nova_groups` and `nutrition_grades` come back complete with `total_in_facet`
|
|
79
85
|
- The five open facets resolve against the live Open Food Facts taxonomy and fall back to a small offline sample, with a `notice`, when it is unavailable; omitting `search` returns only that sample
|
|
80
86
|
|
|
@@ -85,17 +91,19 @@ Built on [`@cyanheads/mcp-ts-core`](https://github.com/cyanheads/mcp-ts-core): s
|
|
|
85
91
|
Open Food Facts-specific:
|
|
86
92
|
|
|
87
93
|
- No API key; the identifying `User-Agent` Open Food Facts' terms require is built in
|
|
88
|
-
- Per-endpoint request budgets (product 15/min, search 10/min, taxonomy 10/min); transient failures retry up to 4 attempts
|
|
94
|
+
- Per-endpoint request budgets (product 15/min, search 10/min, taxonomy 10/min); transient failures retry up to 4 attempts within a total deadline of 35 s for a product or search read and 20 s for a taxonomy lookup (one caller's search that canonicalizes a tag first can spend 55 s), after which the read fails as `upstream_timeout`, or `rate_limited` when its last finished attempt answered 429
|
|
95
|
+
- The stored as-sold read for a prepared-only product spends the product budget, so a barcode can take two product reads, and has its own 20 s deadline: a call settles within 35 s + 20 s = 55 s, and an unread set never fails the call or the row
|
|
96
|
+
- Optional [local export mirror](#local-export-mirror) (`OFF_MIRROR_PATH`): barcode reads and confirmed tag-only searches served from a SQLite copy of the export, kept current by daily deltas; a result names its `source` and, from the mirror, the time it reflects
|
|
89
97
|
- Nutriments normalized from hyphenated keys (`energy-kcal_100g` → `energy_kcal_100g`), with `additional_100g` / `additional_serving` maps that label each nutrient with its unit
|
|
90
98
|
- Tag filters take canonical IDs (`en:organic`, `en:no-gluten`); on text searches a case variant, synonym, or brand name is canonicalized first (`US` → `en:united-states`)
|
|
91
|
-
- Every field is contributor-entered, so a missing one means not yet recorded
|
|
99
|
+
- Every field is contributor-entered, so a missing one means not yet recorded, except that a product read returning only prepared-product nutrition is followed by a read of the as-sold figures the record stores, reported as `stored_as_sold`; Nutri-Score, NOVA, and Green-Score are Open Food Facts' own computed grades, returned as-is, and carry regional formula caveats
|
|
92
100
|
|
|
93
101
|
Agent-friendly output:
|
|
94
102
|
|
|
95
103
|
- Per-serving nutrition carries its denominator: `serving_size`, `serving_quantity`, `serving_quantity_unit`, and a note when none is recorded
|
|
96
104
|
- Graceful partial failure: `off_compare_products` returns resolved rows alongside `not_found` and `failed`
|
|
97
|
-
- Explicit count bounds: `total_is_lower_bound`, `last_page`, and `
|
|
98
|
-
- Typed failures: each carries a declared `reason` (`upstream_error`, `upstream_timeout`, `upstream_rejected`, `rate_limited`, plus per-tool input reasons) and a recovery hint; an incomplete upstream response is a retryable `upstream_error`, never an empty result or a not-found
|
|
105
|
+
- Explicit count bounds: `total_is_lower_bound`, `last_page`, `omitted`, and `outside_constraints` on searches
|
|
106
|
+
- Typed failures: each carries a declared `reason` (`upstream_error`, `upstream_timeout`, `upstream_rejected`, `rate_limited`, plus per-tool input reasons) and a recovery hint; an incomplete upstream response is a retryable `upstream_error`, or `upstream_timeout` when search flags a timeout, never an empty result or a not-found
|
|
99
107
|
|
|
100
108
|
## Getting started
|
|
101
109
|
|
|
@@ -134,6 +142,7 @@ No API key or account is needed: the server identifies itself to Open Food Facts
|
|
|
134
142
|
| `OFF_RATE_LIMIT_PRODUCT` | Product read budget (requests/min). The default is Open Food Facts' published per-IP limit; lower it on a shared outbound IP. | `15` |
|
|
135
143
|
| `OFF_RATE_LIMIT_SEARCH` | Search budget (requests/min). The default is Open Food Facts' published per-IP limit. | `10` |
|
|
136
144
|
| `OFF_RATE_LIMIT_TAXONOMY` | Taxonomy lookup budget (requests/min), shared by `off_browse_taxonomy`, tag canonicalization on text searches, and exclusion checks. | `10` |
|
|
145
|
+
| `OFF_MIRROR_PATH` | Path of the [local export mirror](#local-export-mirror)'s SQLite file. Unset disables the mirror. Works from a source checkout or the Docker image only. | unset |
|
|
137
146
|
| `MCP_TRANSPORT_TYPE` | Transport: `stdio` or `http`. | `stdio` |
|
|
138
147
|
| `MCP_HTTP_PORT` | HTTP server port. | `3010` |
|
|
139
148
|
| `MCP_AUTH_MODE` | Auth mode: `none`, `jwt`, or `oauth`. | `none` |
|
|
@@ -185,6 +194,26 @@ docker run --rm -p 3010:3010 ghcr.io/cyanheads/openfoodfacts-mcp-server:latest
|
|
|
185
194
|
|
|
186
195
|
The image defaults to HTTP transport, stateless session mode, and logs to `/var/log/openfoodfacts-mcp-server`. To run it over stdio from an MCP client, use `"command": "docker"` with `"args": ["run", "-i", "--rm", "-e", "MCP_TRANSPORT_TYPE=stdio", "ghcr.io/cyanheads/openfoodfacts-mcp-server:latest"]`. To build locally, `docker build -t openfoodfacts-mcp-server .`; OpenTelemetry peer dependencies are installed by default — build with `--build-arg OTEL_ENABLED=false` to omit them.
|
|
187
196
|
|
|
197
|
+
#### Local export mirror
|
|
198
|
+
|
|
199
|
+
Setting `OFF_MIRROR_PATH` keeps a local SQLite copy of the Open Food Facts export. `off_get_product` and `off_compare_products` read it first by barcode and read live for a barcode it lacks. A search by tag filters alone is served from it when every value is a confirmed tag or brand slug, with an exact `total` through its first 10,000 results; every other search is live. A store error falls through to a live read. The mirror scripts need a checkout or the Docker image, so the npm install and `.mcpb` bundle stay live-only.
|
|
200
|
+
|
|
201
|
+
In the image, mount a volume at the store directory, `/usr/src/app/.mirror`, set `OFF_MIRROR_PATH` to a file in it, and build the store once with `docker exec`:
|
|
202
|
+
|
|
203
|
+
```sh
|
|
204
|
+
docker run -d --name openfoodfacts-mcp-server -p 3010:3010 \
|
|
205
|
+
-v off-mirror:/usr/src/app/.mirror \
|
|
206
|
+
-e OFF_MIRROR_PATH=/usr/src/app/.mirror/openfoodfacts.sqlite \
|
|
207
|
+
ghcr.io/cyanheads/openfoodfacts-mcp-server:latest
|
|
208
|
+
|
|
209
|
+
docker exec openfoodfacts-mcp-server bun run mirror:init # full load of the export, then the deltas since; re-run after an interrupt to resume
|
|
210
|
+
docker exec openfoodfacts-mcp-server bun run mirror:status # health, product count, and the as-of time answers cite
|
|
211
|
+
docker exec openfoodfacts-mcp-server bun run mirror:verify # status plus SQLite's integrity check
|
|
212
|
+
docker exec openfoodfacts-mcp-server bun run mirror:refresh # one delta pass by hand
|
|
213
|
+
```
|
|
214
|
+
|
|
215
|
+
A full `mirror:init` of the 4.9M-product export took about 1 h 48 min in testing, peaked at about 27 GB on the volume while the 13.4 GB download sat beside the store, left a 14.3 GB store, and used under 350 MB of memory. Reads stay live until `mirror:init` completes. Once `OFF_MIRROR_PATH` is set the server refreshes the mirror itself every three hours, at minute 30 of the server's local time: it applies each new daily delta, and reloads the whole export when the last full load is 30 days old and the export has changed since, because deltas never report deleted products. An export or delta line that is not valid JSON, or runs past 16 MiB, is skipped and logged by line number, and `mirror:status` prints how many the last full load skipped. `mirror:status` and `mirror:verify` exit non-zero unless a sync has completed and the store holds products. From a checkout, run the same `bun run mirror:*` scripts with `OFF_MIRROR_PATH` set in `.env`.
|
|
216
|
+
|
|
188
217
|
## Project structure
|
|
189
218
|
|
|
190
219
|
| Directory | Purpose |
|
|
@@ -193,6 +222,8 @@ The image defaults to HTTP transport, stateless session mode, and logs to `/var/
|
|
|
193
222
|
| `src/config` | Server-specific environment variable parsing and validation with Zod. |
|
|
194
223
|
| `src/mcp-server/tools` | Tool definitions (`*.tool.ts`). |
|
|
195
224
|
| `src/services/openfoodfacts` | Open Food Facts API client — HTTP, rate limiting, retry, field normalization. |
|
|
225
|
+
| `src/services/mirror` | Local export mirror — SQLite store, record builder, export and delta ingester, tag-search queries, and the refresh schedule. |
|
|
226
|
+
| `scripts/mirror-*.ts` | `mirror:init`, `mirror:refresh`, `mirror:status`, and `mirror:verify`. |
|
|
196
227
|
| `src/services/taxonomy` | Tag vocabulary — live resolution, offline sample, and tag-value canonicalization. |
|
|
197
228
|
| `src/utils` | Markdown escaping for contributor-entered values in text output. |
|
|
198
229
|
| `skills/` | Published Agent Skills for the agents that call the tools. |
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
---
|
|
2
|
+
summary: "Checks every nutrient-filtered row against the caller bounds, caps each read with a total retry deadline, treats a flagged search timeout and a dropped connection as declared failures, labels prepared figures on product reads, adds prepared and carbohydrate figures to comparisons, and returns up to 200 taxonomy tags."
|
|
3
|
+
breaking: false
|
|
4
|
+
security: false
|
|
5
|
+
---
|
|
6
|
+
|
|
7
|
+
# 0.4.1 — 2026-10-30
|
|
8
|
+
|
|
9
|
+
## Changed
|
|
10
|
+
|
|
11
|
+
- **`off_get_product` prepared figures** ([#59](https://github.com/cyanheads/openfoodfacts-mcp-server/issues/59)): Open Food Facts returns one preparation state of the nutrition per read, so a read of prepared-product figures carries `_prepared` keys and no named as-sold field. The text now prints those keys in "as prepared" sections and states that the read returned only prepared figures, not that the record holds no as-sold ones; `structuredContent` values are unchanged and the field descriptions name `_prepared` keys.
|
|
12
|
+
- **`off_compare_products` prepared figures** ([#59](https://github.com/cyanheads/openfoodfacts-mcp-server/issues/59)): a row gains an optional `prepared` object with the same columns, read from `*_prepared_100g` keys and never substituted for an as-sold figure. The text adds an "as prepared" table, and a row with prepared figures only leaves the as-sold table. As-sold figures are not read for prepared products yet ([#73](https://github.com/cyanheads/openfoodfacts-mcp-server/issues/73)).
|
|
13
|
+
- **`off_compare_products` carbohydrates** ([#71](https://github.com/cyanheads/openfoodfacts-mcp-server/issues/71)): rows gain an optional `carbohydrates_100g`, and the text table a Carbohydrates column between saturated fat and sugars.
|
|
14
|
+
- **`off_browse_taxonomy` returns up to 200 tags** ([#70](https://github.com/cyanheads/openfoodfacts-mcp-server/issues/70)), up from 100. `limit` has no maximum: a value above 200 returns the first 200 and, when more matched, says how many did, instead of failing validation. Each lookup now asks for up to 2,000 suggestions, above the largest answer measured (1,313), so the match count is exact below that size; an answer that reached it would be cut with nothing flagging it.
|
|
15
|
+
- **`off_search_products` `page_size`** ([#70](https://github.com/cyanheads/openfoodfacts-mcp-server/issues/70)) has no maximum: a value over 10,000, previously rejected, is served at 50 and reported in `page_size_capped`.
|
|
16
|
+
- **Retry deadlines** ([#72](https://github.com/cyanheads/openfoodfacts-mcp-server/issues/72)): all attempts and backoff of one read share a deadline, 35 s for a product or search read and 20 s for a taxonomy lookup, and the request in flight is cut off at expiry. A read that would have finished later now fails as the retryable `upstream_timeout`, or as `rate_limited` when its last finished attempt answered 429. A taxonomy lookup that expires does not fail its tool by itself: `off_browse_taxonomy` falls back to its offline sample with a notice, a tag filter is sent normalized, and an exclusion ends as the retryable `unrecognized_exclusion`. A search that canonicalizes a tag first can spend 55 s for one caller; one joined to a lookup whose first caller cancels can run longer ([#75](https://github.com/cyanheads/openfoodfacts-mcp-server/issues/75)).
|
|
17
|
+
- **Docs and skill**: tool descriptions, the server instructions, the `openfoodfacts-guide` skill, and the README describe the row check, the deadlines, and prepared figures.
|
|
18
|
+
|
|
19
|
+
## Fixed
|
|
20
|
+
|
|
21
|
+
- **`off_search_products` `nutrient_filters`** ([#64](https://github.com/cyanheads/openfoodfacts-mcp-server/issues/64)): the text index compares `energy-kcal`, `fat`, `saturated-fat`, `carbohydrates`, and `sugars` as whole numbers, so `sugars lte 3` returned a row at 3.53 and `sugars gt 3` dropped matches. `gt` and `gte` on those five now widen to the whole number below, and every returned row is checked against every constraint at the index's 32-bit precision. Rows 0.4.0 returned outside the constraints are gone.
|
|
22
|
+
- **`outside_constraints`** ([#64](https://github.com/cyanheads/openfoodfacts-mcp-server/issues/64)): new optional count of the rows left off a page by that check. A page can come back short or empty while later pages hold matches, and `total`, `total_is_lower_bound`, and `last_page` still describe the index's whole-number answer, which the text now says.
|
|
23
|
+
- **Flagged search timeouts** ([#68](https://github.com/cyanheads/openfoodfacts-mcp-server/issues/68)): a text-search or autocomplete answer carrying `timed_out: true` was read as complete, reporting a partial page as exact and caching an empty autocomplete as `no_match`. The read now fails as the retryable `upstream_timeout`. A text search reports that reason, which `off_search_products` declares for it. A failed autocomplete is not cached: `off_browse_taxonomy` falls back to its offline sample with a notice, a tag filter is sent normalized, and an exclusion ends as the retryable `unrecognized_exclusion`.
|
|
24
|
+
- **`off_get_product` `serving_quantity`** ([#69](https://github.com/cyanheads/openfoodfacts-mcp-server/issues/69)): requesting `serving_quantity` now also fetches `serving_quantity_unit`, so a unit the record holds is no longer reported as not recorded. A record with no unit still reads "unit not recorded".
|
|
25
|
+
- **Dropped connections** ([#74](https://github.com/cyanheads/openfoodfacts-mcp-server/issues/74)): `off_get_product` and `off_search_products` failed with no `reason` when Open Food Facts closed the connection mid-body. They now fail as the retryable `upstream_error`. `off_compare_products` already listed such a barcode in `failed` as `upstream_error`, and `off_browse_taxonomy` already fell back to its offline sample with a notice; both now name the dropped connection instead of the runtime's message.
|
|
26
|
+
|
|
27
|
+
## Dependencies
|
|
28
|
+
|
|
29
|
+
- **`@biomejs/biome`** ^2.6.3 → ^2.6.4 (dev).
|
|
30
|
+
- **`@types/node`** ^26.6.5 → ^26.6.6 (dev).
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
---
|
|
2
|
+
summary: "Adds an optional local mirror of the Open Food Facts export that serves barcode reads and tag-only searches, reads a record's stored as-sold figures when a product read returns only prepared ones, and names why a taxonomy lookup failed, with the wait in whole seconds."
|
|
3
|
+
breaking: false
|
|
4
|
+
security: false
|
|
5
|
+
---
|
|
6
|
+
|
|
7
|
+
# 0.4.2 — 2026-11-01
|
|
8
|
+
|
|
9
|
+
## Added
|
|
10
|
+
|
|
11
|
+
- **Local export mirror** ([#79](https://github.com/cyanheads/openfoodfacts-mcp-server/issues/79)): setting `OFF_MIRROR_PATH` keeps a SQLite copy of the Open Food Facts export. `off_get_product` and `off_compare_products` read it first by barcode and read live for a barcode it lacks. `bun run mirror:init`, `mirror:refresh`, `mirror:status`, and `mirror:verify` build and inspect the store from a checkout or the Docker image, and the server refreshes it every three hours. A refresh reloads the whole export every 30 days, or when the listed deltas no longer reach the store's checkpoint, unless one HEAD request finds the export's ETag unchanged since the last load. An export or delta line that is not valid JSON, or runs past 16 MiB, is skipped and logged by line number, and `mirror:status` prints how many the last full load skipped. Only `mirror:init` creates the store: the serving process reads live while the path names no file, and a refresh there is refused as `mirror_not_initialized`. Opening the store waits at most 250 ms on a sync's write lock before the call reads live, and a `mirror:*` script that meets that lock names the sync holding it. With the variable unset nothing opens or schedules.
|
|
12
|
+
- **Mirror-served tag-only searches** ([#79](https://github.com/cyanheads/openfoodfacts-mcp-server/issues/79)): `off_search_products` with tag filters alone is served from the mirror when every tag value is confirmed, with an exact `total` through its first 10,000 results. Under the default order, `popularity_key`, and `unique_scans_n` the mirror sorts by the values stored with each record, so the order can differ from a live search.
|
|
13
|
+
- **`source`, `mirror_as_of`, `mirror_snapshot`** ([#79](https://github.com/cyanheads/openfoodfacts-mcp-server/issues/79)): product, compare (per row), and search results name `off_api` or `off_mirror`; a mirror answer also carries when the copy was last updated and a note saying so.
|
|
14
|
+
- **`stored_as_sold`** ([#73](https://github.com/cyanheads/openfoodfacts-mcp-server/issues/73)): when an `off_get_product` or `off_compare_products` read returns only prepared figures, one follow-up read fetches the record's stored as-sold set and fills the as-sold fields. The field reports `read`, `none`, or `unread` with a `reason`; an unread set never fails the call.
|
|
15
|
+
- **`cause` on `lookup_failed`** ([#77](https://github.com/cyanheads/openfoodfacts-mcp-server/issues/77)): a failed taxonomy lookup reports `budget_spent`, `rate_limited`, or `unavailable`, and the exclusion refusal, the zero-result notice, and the `off_browse_taxonomy` notice name it, blaming the vocabulary only for `unavailable`.
|
|
16
|
+
- **`unconfirmed[]` on `page_out_of_range`** ([#79](https://github.com/cyanheads/openfoodfacts-mcp-server/issues/79)): a tag-only page past 10 that the ready mirror cannot take because a value went unconfirmed is refused with those values named, and is retryable when each failed only at lookup.
|
|
17
|
+
|
|
18
|
+
## Changed
|
|
19
|
+
|
|
20
|
+
- **`retryAfter`** ([#76](https://github.com/cyanheads/openfoodfacts-mcp-server/issues/76)): published in whole seconds on every failure that knows its wait. Each `failed[]` entry of `off_compare_products` carries it, and a retryable `unrecognized_exclusion` refusal carries the longest wait among its unchecked values.
|
|
21
|
+
- **`off_compare_products` stored sets** ([#73](https://github.com/cyanheads/openfoodfacts-mcp-server/issues/73)): a row whose stored as-sold set was read stays in the as-sold table even when that set fills none of the compared columns.
|
|
22
|
+
- **Docs and skill**: tool descriptions, the server instructions, the `openfoodfacts-guide` skill, and the README describe the mirror, stored sets, and failure causes.
|
|
23
|
+
|
|
24
|
+
## Fixed
|
|
25
|
+
|
|
26
|
+
- **Shared taxonomy lookups** ([#75](https://github.com/cyanheads/openfoodfacts-mcp-server/issues/75)): a caller that joined a lookup whose starter cancelled restarted it with a fresh 20 s deadline and could outlast 55 s. It now restarts with what is left of its own, so a single call settles within 55 s. A joined caller that is itself cancelled now ends at once instead of waiting for the shared answer.
|
|
27
|
+
- **Brand values with a language prefix** ([#79](https://github.com/cyanheads/openfoodfacts-mcp-server/issues/79)): `xx:nutella` and `en:nutella` now drop the prefix before slugging. The text-search path had sent `xx-nutella` since 0.3.6.
|
|
28
|
+
|
|
29
|
+
## Dependencies
|
|
30
|
+
|
|
31
|
+
- **`node-cron`** ^4.6.0 (added, runs the scheduled refresh).
|
|
32
|
+
- **`better-sqlite3`** ^13.0.3 (added, dev; SQLite for the Node test lane).
|
|
33
|
+
- **`tsc-alias`** held at ^1.9.9 on the `devcheck.config.json` `outdated.allowlist`: installing 1.9.10 fails because the install's Socket security scanner gets a 403 from its upstream.
|
|
@@ -26,6 +26,7 @@ declare const ServerConfigSchema: z.ZodObject<{
|
|
|
26
26
|
rateLimitProduct: z.ZodDefault<z.ZodCoercedNumber<unknown>>;
|
|
27
27
|
rateLimitSearch: z.ZodDefault<z.ZodCoercedNumber<unknown>>;
|
|
28
28
|
rateLimitTaxonomy: z.ZodDefault<z.ZodCoercedNumber<unknown>>;
|
|
29
|
+
mirrorPath: z.ZodOptional<z.ZodString>;
|
|
29
30
|
}, z.core.$strip>;
|
|
30
31
|
export type ServerConfig = z.infer<typeof ServerConfigSchema>;
|
|
31
32
|
/** Lazy-parsed server config from environment variables. */
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"server-config.d.ts","sourceRoot":"","sources":["../../src/config/server-config.ts"],"names":[],"mappings":"AAAA;;;GAGG;AAEH,OAAO,EAAE,CAAC,EAAE,MAAM,wBAAwB,CAAC;AAG3C;;;;;;;;;;;;;;;;;GAiBG;AACH,QAAA,MAAM,kBAAkB
|
|
1
|
+
{"version":3,"file":"server-config.d.ts","sourceRoot":"","sources":["../../src/config/server-config.ts"],"names":[],"mappings":"AAAA;;;GAGG;AAEH,OAAO,EAAE,CAAC,EAAE,MAAM,wBAAwB,CAAC;AAG3C;;;;;;;;;;;;;;;;;GAiBG;AACH,QAAA,MAAM,kBAAkB;;;;;;iBAiCtB,CAAC;AAEH,MAAM,MAAM,YAAY,GAAG,CAAC,CAAC,KAAK,CAAC,OAAO,kBAAkB,CAAC,CAAC;AAI9D,4DAA4D;AAC5D,wBAAgB,eAAe,IAAI,YAAY,CAS9C"}
|
|
@@ -45,6 +45,16 @@ const ServerConfigSchema = z.object({
|
|
|
45
45
|
.min(1)
|
|
46
46
|
.default(10)
|
|
47
47
|
.describe('Taxonomy resolution rate limit (requests/min)'),
|
|
48
|
+
/**
|
|
49
|
+
* Unset means no mirror: no store opens and no refresh is scheduled. The `mirror:*` scripts build
|
|
50
|
+
* the store, and they run only from a checkout or the Docker image, so the variable appears in no
|
|
51
|
+
* npm-facing manifest.
|
|
52
|
+
*/
|
|
53
|
+
mirrorPath: z
|
|
54
|
+
.string()
|
|
55
|
+
.min(1)
|
|
56
|
+
.optional()
|
|
57
|
+
.describe('Local export mirror store path (SQLite file); unset disables the mirror'),
|
|
48
58
|
});
|
|
49
59
|
let _config;
|
|
50
60
|
/** Lazy-parsed server config from environment variables. */
|
|
@@ -54,6 +64,7 @@ export function getServerConfig() {
|
|
|
54
64
|
rateLimitProduct: 'OFF_RATE_LIMIT_PRODUCT',
|
|
55
65
|
rateLimitSearch: 'OFF_RATE_LIMIT_SEARCH',
|
|
56
66
|
rateLimitTaxonomy: 'OFF_RATE_LIMIT_TAXONOMY',
|
|
67
|
+
mirrorPath: 'OFF_MIRROR_PATH',
|
|
57
68
|
});
|
|
58
69
|
return _config;
|
|
59
70
|
}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"server-config.js","sourceRoot":"","sources":["../../src/config/server-config.ts"],"names":[],"mappings":"AAAA;;;GAGG;AAEH,OAAO,EAAE,CAAC,EAAE,MAAM,wBAAwB,CAAC;AAC3C,OAAO,EAAE,cAAc,EAAE,MAAM,+BAA+B,CAAC;AAE/D;;;;;;;;;;;;;;;;;GAiBG;AACH,MAAM,kBAAkB,GAAG,CAAC,CAAC,MAAM,CAAC;IAClC,OAAO,EAAE,CAAC;SACP,MAAM,EAAE;SACR,OAAO,CAAC,iCAAiC,CAAC;SAC1C,QAAQ,CAAC,8BAA8B,CAAC;IAC3C,gBAAgB,EAAE,CAAC,CAAC,MAAM;SACvB,MAAM,EAAE;SACR,GAAG,EAAE;SACL,GAAG,CAAC,CAAC,CAAC;SACN,OAAO,CAAC,EAAE,CAAC;SACX,QAAQ,CAAC,wCAAwC,CAAC;IACrD,eAAe,EAAE,CAAC,CAAC,MAAM;SACtB,MAAM,EAAE;SACR,GAAG,EAAE;SACL,GAAG,CAAC,CAAC,CAAC;SACN,OAAO,CAAC,EAAE,CAAC;SACX,QAAQ,CAAC,kCAAkC,CAAC;IAC/C,iBAAiB,EAAE,CAAC,CAAC,MAAM;SACxB,MAAM,EAAE;SACR,GAAG,EAAE;SACL,GAAG,CAAC,CAAC,CAAC;SACN,OAAO,CAAC,EAAE,CAAC;SACX,QAAQ,CAAC,+CAA+C,CAAC;
|
|
1
|
+
{"version":3,"file":"server-config.js","sourceRoot":"","sources":["../../src/config/server-config.ts"],"names":[],"mappings":"AAAA;;;GAGG;AAEH,OAAO,EAAE,CAAC,EAAE,MAAM,wBAAwB,CAAC;AAC3C,OAAO,EAAE,cAAc,EAAE,MAAM,+BAA+B,CAAC;AAE/D;;;;;;;;;;;;;;;;;GAiBG;AACH,MAAM,kBAAkB,GAAG,CAAC,CAAC,MAAM,CAAC;IAClC,OAAO,EAAE,CAAC;SACP,MAAM,EAAE;SACR,OAAO,CAAC,iCAAiC,CAAC;SAC1C,QAAQ,CAAC,8BAA8B,CAAC;IAC3C,gBAAgB,EAAE,CAAC,CAAC,MAAM;SACvB,MAAM,EAAE;SACR,GAAG,EAAE;SACL,GAAG,CAAC,CAAC,CAAC;SACN,OAAO,CAAC,EAAE,CAAC;SACX,QAAQ,CAAC,wCAAwC,CAAC;IACrD,eAAe,EAAE,CAAC,CAAC,MAAM;SACtB,MAAM,EAAE;SACR,GAAG,EAAE;SACL,GAAG,CAAC,CAAC,CAAC;SACN,OAAO,CAAC,EAAE,CAAC;SACX,QAAQ,CAAC,kCAAkC,CAAC;IAC/C,iBAAiB,EAAE,CAAC,CAAC,MAAM;SACxB,MAAM,EAAE;SACR,GAAG,EAAE;SACL,GAAG,CAAC,CAAC,CAAC;SACN,OAAO,CAAC,EAAE,CAAC;SACX,QAAQ,CAAC,+CAA+C,CAAC;IAC5D;;;;OAIG;IACH,UAAU,EAAE,CAAC;SACV,MAAM,EAAE;SACR,GAAG,CAAC,CAAC,CAAC;SACN,QAAQ,EAAE;SACV,QAAQ,CAAC,yEAAyE,CAAC;CACvF,CAAC,CAAC;AAIH,IAAI,OAAiC,CAAC;AAEtC,4DAA4D;AAC5D,MAAM,UAAU,eAAe;IAC7B,OAAO,KAAK,cAAc,CAAC,kBAAkB,EAAE;QAC7C,OAAO,EAAE,cAAc;QACvB,gBAAgB,EAAE,wBAAwB;QAC1C,eAAe,EAAE,uBAAuB;QACxC,iBAAiB,EAAE,yBAAyB;QAC5C,UAAU,EAAE,iBAAiB;KAC9B,CAAC,CAAC;IACH,OAAO,OAAO,CAAC;AACjB,CAAC"}
|
package/dist/index.js
CHANGED
|
@@ -5,6 +5,8 @@
|
|
|
5
5
|
*/
|
|
6
6
|
import { createApp } from '@cyanheads/mcp-ts-core';
|
|
7
7
|
import { allToolDefinitions } from './mcp-server/tools/definitions/index.js';
|
|
8
|
+
import { startMirrorRefreshSchedule, stopMirrorRefreshSchedule, } from './services/mirror/mirror-scheduler.js';
|
|
9
|
+
import { closeMirrorStore } from './services/mirror/mirror-store.js';
|
|
8
10
|
import { initOpenFoodFactsService } from './services/openfoodfacts/openfoodfacts-service.js';
|
|
9
11
|
import { initTaxonomyService } from './services/taxonomy/taxonomy-service.js';
|
|
10
12
|
await createApp({
|
|
@@ -12,11 +14,16 @@ await createApp({
|
|
|
12
14
|
title: 'openfoodfacts-mcp-server',
|
|
13
15
|
tools: allToolDefinitions,
|
|
14
16
|
skills: 'skills',
|
|
15
|
-
instructions: 'Query Open Food Facts, a free crowd-sourced database of 3M+ packaged foods: off_get_product looks up one product by barcode (4–40 digits), off_search_products finds products by text or tag filters, off_compare_products puts several side by side on nutrition, and off_browse_taxonomy resolves a human term to the canonical tag ID the other tools filter on — so start there when you have a category or label rather than a barcode. Every field is contributor-entered, so a missing one means "not yet recorded" rather than absent, and the computed Nutri-Score, NOVA, and Green-Score grades carry regional caveats. No API key is required; the data is ODbL 1.0, so cite Open Food Facts wherever you pass it on.',
|
|
17
|
+
instructions: 'Query Open Food Facts, a free crowd-sourced database of 3M+ packaged foods: off_get_product looks up one product by barcode (4–40 digits), off_search_products finds products by text or tag filters, off_compare_products puts several side by side on nutrition, and off_browse_taxonomy resolves a human term to the canonical tag ID the other tools filter on — so start there when you have a category or label rather than a barcode. Every field is contributor-entered, so a missing one means "not yet recorded" rather than absent, except that a product read returning only prepared-product nutrition is followed by a read of the as-sold figures the record stores, reported as stored_as_sold, and the computed Nutri-Score, NOVA, and Green-Score grades carry regional caveats. No API key is required; the data is ODbL 1.0, so cite Open Food Facts wherever you pass it on.',
|
|
16
18
|
sessionMode: 'stateless',
|
|
17
|
-
setup() {
|
|
19
|
+
async setup() {
|
|
18
20
|
initOpenFoodFactsService();
|
|
19
21
|
initTaxonomyService();
|
|
22
|
+
await startMirrorRefreshSchedule();
|
|
23
|
+
},
|
|
24
|
+
async teardown() {
|
|
25
|
+
await stopMirrorRefreshSchedule();
|
|
26
|
+
await closeMirrorStore();
|
|
20
27
|
},
|
|
21
28
|
});
|
|
22
29
|
//# sourceMappingURL=index.js.map
|
package/dist/index.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":";AACA;;;GAGG;AAEH,OAAO,EAAE,SAAS,EAAE,MAAM,wBAAwB,CAAC;AACnD,OAAO,EAAE,kBAAkB,EAAE,MAAM,yCAAyC,CAAC;AAC7E,OAAO,EAAE,wBAAwB,EAAE,MAAM,mDAAmD,CAAC;AAC7F,OAAO,EAAE,mBAAmB,EAAE,MAAM,yCAAyC,CAAC;AAE9E,MAAM,SAAS,CAAC;IACd,IAAI,EAAE,0BAA0B;IAChC,KAAK,EAAE,0BAA0B;IACjC,KAAK,EAAE,kBAAkB;IACzB,MAAM,EAAE,QAAQ;IAChB,YAAY,EACV,
|
|
1
|
+
{"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":";AACA;;;GAGG;AAEH,OAAO,EAAE,SAAS,EAAE,MAAM,wBAAwB,CAAC;AACnD,OAAO,EAAE,kBAAkB,EAAE,MAAM,yCAAyC,CAAC;AAC7E,OAAO,EACL,0BAA0B,EAC1B,yBAAyB,GAC1B,MAAM,uCAAuC,CAAC;AAC/C,OAAO,EAAE,gBAAgB,EAAE,MAAM,mCAAmC,CAAC;AACrE,OAAO,EAAE,wBAAwB,EAAE,MAAM,mDAAmD,CAAC;AAC7F,OAAO,EAAE,mBAAmB,EAAE,MAAM,yCAAyC,CAAC;AAE9E,MAAM,SAAS,CAAC;IACd,IAAI,EAAE,0BAA0B;IAChC,KAAK,EAAE,0BAA0B;IACjC,KAAK,EAAE,kBAAkB;IACzB,MAAM,EAAE,QAAQ;IAChB,YAAY,EACV,m2BAAm2B;IACr2B,WAAW,EAAE,WAAW;IACxB,KAAK,CAAC,KAAK;QACT,wBAAwB,EAAE,CAAC;QAC3B,mBAAmB,EAAE,CAAC;QACtB,MAAM,0BAA0B,EAAE,CAAC;IACrC,CAAC;IACD,KAAK,CAAC,QAAQ;QACZ,MAAM,yBAAyB,EAAE,CAAC;QAClC,MAAM,gBAAgB,EAAE,CAAC;IAC3B,CAAC;CACF,CAAC,CAAC"}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"browse-taxonomy.tool.d.ts","sourceRoot":"","sources":["../../../../src/mcp-server/tools/definitions/browse-taxonomy.tool.ts"],"names":[],"mappings":"AAAA;;;GAGG;AAEH,OAAO,EAAQ,CAAC,EAAE,MAAM,wBAAwB,CAAC;
|
|
1
|
+
{"version":3,"file":"browse-taxonomy.tool.d.ts","sourceRoot":"","sources":["../../../../src/mcp-server/tools/definitions/browse-taxonomy.tool.ts"],"names":[],"mappings":"AAAA;;;GAGG;AAEH,OAAO,EAAQ,CAAC,EAAE,MAAM,wBAAwB,CAAC;AAUjD,eAAO,MAAM,qBAAqB;;;;;;;;;;;;;;;;;;;;;;;;EA6KhC,CAAC"}
|
|
@@ -3,14 +3,13 @@
|
|
|
3
3
|
* @module mcp-server/tools/definitions/browse-taxonomy
|
|
4
4
|
*/
|
|
5
5
|
import { tool, z } from '@cyanheads/mcp-ts-core';
|
|
6
|
-
import { MAX_AUTOCOMPLETE_SIZE } from '../../../services/openfoodfacts/openfoodfacts-service.js';
|
|
7
6
|
import { getTaxonomyService } from '../../../services/taxonomy/taxonomy-service.js';
|
|
8
7
|
import { mdInline, mdInlineCode } from '../../../utils/markdown.js';
|
|
9
8
|
/**
|
|
10
|
-
* Most tags one call returns. A larger `limit
|
|
11
|
-
*
|
|
9
|
+
* Most tags one call returns. A larger `limit` returns this many and discloses the cut when more
|
|
10
|
+
* matched.
|
|
12
11
|
*/
|
|
13
|
-
const TAXONOMY_LIMIT_CAP =
|
|
12
|
+
const TAXONOMY_LIMIT_CAP = 200;
|
|
14
13
|
export const offBrowseTaxonomyTool = tool('off_browse_taxonomy', {
|
|
15
14
|
title: 'Browse Food Facts Taxonomy',
|
|
16
15
|
description: 'Resolve a human term to the canonical Open Food Facts tag ID that off_search_products filters on. Covers categories, labels/certifications, allergens, additives, countries, NOVA groups, and Nutri-Score grades. Pass a search term to resolve against the Open Food Facts vocabulary, which holds tens of thousands of tags; omitting it returns only a small reference list for each facet except NOVA groups and Nutri-Score grades, which are complete. Most tag IDs use the "en:" prefix (e.g. "en:organic", "en:no-gluten", "en:crustaceans"); NOVA groups return bare digits "1"-"4" and Nutri-Score grades bare letters "a"-"e". Pass the id through to off_search_products exactly as returned. Category tags are frequently plural ("kombucha" resolves to "en:kombuchas"), so use the returned id rather than constructing one.',
|
|
@@ -35,13 +34,19 @@ export const offBrowseTaxonomyTool = tool('off_browse_taxonomy', {
|
|
|
35
34
|
.string()
|
|
36
35
|
.optional()
|
|
37
36
|
.describe('Term to resolve. Matched case-insensitively as a substring of the tag ID, the display name, or a common synonym of either ("shellfish" resolves to en:crustaceans, "gluten free" to en:no-gluten). A single word works best ("hummus", not "hummus dip"). Omit only to see a small reference list — Open Food Facts cannot list the full vocabulary without a term, so an unfiltered call is not a view of the full facet.'),
|
|
37
|
+
// `.int()` would advertise a `maximum` of 2^53 - 1 for a value the handler clamps to the cap.
|
|
38
38
|
limit: z
|
|
39
39
|
.number()
|
|
40
|
-
.
|
|
40
|
+
.multipleOf(1, 'Send limit as a whole number, not a fractional one.')
|
|
41
|
+
// multipleOf tolerates float noise (20.000000000000004). It stays for what it advertises; this
|
|
42
|
+
// closes the gap, only where it passed, so 1.5 reports once.
|
|
43
|
+
.refine(Number.isInteger, {
|
|
44
|
+
error: 'Send limit as a whole number, not a fractional one.',
|
|
45
|
+
when: ({ issues }) => issues.length === 0,
|
|
46
|
+
})
|
|
41
47
|
.min(1)
|
|
42
|
-
.max(MAX_AUTOCOMPLETE_SIZE)
|
|
43
48
|
.default(20)
|
|
44
|
-
.describe(`Maximum entries to return (default 20). One call returns at most ${TAXONOMY_LIMIT_CAP}: a larger value
|
|
49
|
+
.describe(`Maximum entries to return (default 20). One call returns at most ${TAXONOMY_LIMIT_CAP}: a larger value returns the first ${TAXONOMY_LIMIT_CAP} and, when more matched, says how many did. There is no offset or page input: Open Food Facts offers no cursor for this lookup, so narrow the search term rather than paging. The tag spelling the term itself (e.g. "lentil" → en:lentils) is listed first among the live matches, so it is not the one a small limit cuts.`),
|
|
45
50
|
}),
|
|
46
51
|
output: z.object({
|
|
47
52
|
facet: z.string().describe('The facet name that was queried (echoes the input).'),
|
|
@@ -64,7 +69,7 @@ export const offBrowseTaxonomyTool = tool('off_browse_taxonomy', {
|
|
|
64
69
|
notice: z
|
|
65
70
|
.string()
|
|
66
71
|
.optional()
|
|
67
|
-
.describe(`Caveat about the answer — that the listing is a limited reference list rather than the full vocabulary, that the live vocabulary
|
|
72
|
+
.describe(`Caveat about the answer — that the listing is a limited reference list rather than the full vocabulary, that the live vocabulary went unchecked and why (unreachable or no usable answer, Open Food Facts rate-limiting this server, or this server's own taxonomy budget spent, with the wait in seconds when one is known), that nothing matched and why, or that the limit cut matches, naming how many matched and the next call: a larger limit while one would list more, otherwise a narrower term, which is the only route past the first ${TAXONOMY_LIMIT_CAP} (a limit above ${TAXONOMY_LIMIT_CAP} is named too).`),
|
|
68
73
|
truncated: z.boolean().optional().describe('True when more tags exist beyond the limit.'),
|
|
69
74
|
shown: z.number().optional().describe('Number of tags returned.'),
|
|
70
75
|
cap: z
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"browse-taxonomy.tool.js","sourceRoot":"","sources":["../../../../src/mcp-server/tools/definitions/browse-taxonomy.tool.ts"],"names":[],"mappings":"AAAA;;;GAGG;AAEH,OAAO,EAAE,IAAI,EAAE,CAAC,EAAE,MAAM,wBAAwB,CAAC;AACjD,OAAO,
|
|
1
|
+
{"version":3,"file":"browse-taxonomy.tool.js","sourceRoot":"","sources":["../../../../src/mcp-server/tools/definitions/browse-taxonomy.tool.ts"],"names":[],"mappings":"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"}
|