@cyanheads/openfoodfacts-mcp-server
Look up food products by barcode, search by keyword, tag, allergen, or nutrition filter, compare products side-by-side, and browse the canonical tag vocabulary via MCP. STDIO or Streamable HTTP.
4 Tools
Overview
Packaged-food data from Open Food Facts, a crowd-sourced database of 3M+ products. Look up items by barcode, search by text, tags, allergens, and nutrient thresholds, compare products side by side, and resolve everyday terms to the canonical tags the filters take.
Tools
Skills
Capability reference
off_get_product tool
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
- 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)
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
- 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
source (off_api or off_mirror) names where the product was read; a barcode a local mirror holds is read from it with no upstream request, with mirror_as_of (the time up to which the copy reflects every product contributed or edited on Open Food Facts) and a mirror_snapshot note, and the stored as-sold set comes from the same stored record
off_search_products tool
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
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)
- 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
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 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
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
off_compare_products tool
- 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
- 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
- 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
- Each row carries
source (off_api or off_mirror), and mirror_as_of for a row read from a local mirror, so one comparison can mix the two; a mirror_snapshot note appears when any row came from it
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
off_browse_taxonomy tool
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
- 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
- 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
Features
Built on @cyanheads/mcp-ts-core: stdio and Streamable HTTP transports, pluggable auth (none / jwt / oauth), swappable storage (in-memory, filesystem, Supabase, Cloudflare KV/R2/D1), structured logging with optional OpenTelemetry tracing.
Open Food Facts-specific:
- No API key; the identifying
User-Agent Open Food Facts' terms require is built in
- 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
- 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
- Optional 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
- Nutriments normalized from hyphenated keys (
energy-kcal_100g → energy_kcal_100g), with additional_100g / additional_serving maps that label each nutrient with its unit
- 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)
- 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
Agent-friendly output:
- Per-serving nutrition carries its denominator:
serving_size, serving_quantity, serving_quantity_unit, and a note when none is recorded
- Graceful partial failure:
off_compare_products returns resolved rows alongside not_found and failed
- Explicit count bounds:
total_is_lower_bound, last_page, omitted, and outside_constraints on searches
- 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
Getting started
Connect to the hosted instance, no install:
Or run it locally:
For other clients, add this to the MCP config file:
No API key or account is needed: the server identifies itself to Open Food Facts with its own User-Agent. For Docker or a source build, see Running the server.
Configuration
See .env.example for the full list of optional overrides.
Running the server
Local development
Requires Bun v1.4.0 or higher (or Node.js v24+).
-
Clone and install:
-
Build and run:
-
Run checks and tests:
Docker
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.
Local export mirror
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.
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:
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. An export that builds no product at all is refused before it deletes anything (export_no_products): the mirror keeps serving what it holds, and each refresh downloads the export again, applying no delta, until an export that lists products replaces it. While a reload runs, mirror_as_of can drop below the last delta's end, to a time the rewritten products reflect, until the load completes and the deltas re-apply. 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.
Project structure
Development guide
See CLAUDE.md for development guidelines and architectural rules. The short version:
- Handlers throw, framework catches — no
try/catch in tool logic
- Use
ctx.log for request-scoped logging, ctx.state for tenant-scoped storage
- Register new tools via the barrel in
src/mcp-server/tools/definitions/index.ts
- Wrap external API calls: validate raw → normalize to domain type → return output schema; never fabricate missing fields
Contributing
Issues are welcome — see CONTRIBUTING.md for the forms and what makes a report actionable. Report vulnerabilities through private disclosure, never a public issue. Run checks and tests before submitting:
License
Apache-2.0 — see LICENSE for details. Open Food Facts data is released under the Open Database License (ODbL) 1.0; downstream use must cite Open Food Facts.