Skip to main content
Glama
cyanheads

@cyanheads/openfoodfacts-mcp-server

by cyanheads

Version License Docker MCP SDK npm TypeScript Bun

Install in Claude Desktop Install in Cursor Install in VS Code

Framework

Public Hosted Server: https://openfoodfacts.caseyjhand.com/mcp


Overview

Food product data from Open Food Facts, a crowd-sourced database of 3M+ packaged food products. Look up items by barcode, search by text and nutrition/allergen/label tags, compare products side-by-side, and resolve everyday terms to the canonical tag vocabulary from any MCP client. Runs as a stdio process, a local Streamable HTTP server, or the public hosted endpoint above.

Tools

Tool

Description

off_get_product

Fetch a packaged food product by barcode. Returns name, brand, quantity, ingredients, declared and trace allergens, additives, the vegan/vegetarian/palm-oil analysis, Nutri-Score, NOVA group, Green-Score, nutrition per 100g/serving, categories, labels, countries of sale, and data completeness.

off_search_products

Search by text query, structured tag filters (category, brand, labels, allergen, trace, vegan/vegetarian/palm-oil verdict, additive, Nutri-Score grade, NOVA group, country), allergen and trace exclusions, and numeric per-100 g nutrient thresholds. Returns summary rows with barcodes for follow-up lookups.

off_compare_products

Side-by-side nutrition and scoring comparison for 2–10 products by barcode. Returns a normalized table of energy, macros, salt, Nutri-Score, NOVA, and Green-Score.

off_browse_taxonomy

Resolve a human term to the canonical tag ID (categories, labels, allergens, additives, countries, NOVA groups, Nutri-Score grades) that off_search_products filters on, against the live Open Food Facts taxonomy.

Related MCP server: openfoodfacts-mcp

Capability reference

off_get_product tool

  • Accepts every barcode Open Food Facts serves: digits only, 4–40 digits after any leading zeros — EAN-13, EAN-8, UPC-A, UPC-E, and the shorter and longer codes the database also holds

  • Returns ingredients (raw text and parsed list with percent estimates, vegan/vegetarian flags, and each entry's sub-ingredients nested under it — wheat flour under "cereal", palm oil under "vegetable oils" — up to three levels deep), all 14 major allergens as tag IDs, E-number additives, Nutri-Score (a–e, unknown, not-applicable), NOVA 1–4, Green-Score (a-plus, a–f, unknown, not-applicable), every nutrient Open Food Facts holds per 100g and per serving, the serving size those per-serving figures are measured against, categories/labels/packaging/origins/countries of sale as canonical tag IDs, front image URL, and data completeness score (0–1)

  • traces_tags carries the "may contain" allergen warning separately from the declared allergens_tags; ["en:none"] is the label stating no traces, while an empty array means not yet entered — never trace-free

  • ingredients_analysis_tags carries the vegan, vegetarian, and palm-oil verdicts Open Food Facts computes itself, including its "maybe" states, rather than leaving the per-ingredient flags to be aggregated by the caller

  • Optional fields parameter restricts the response to a subset (e.g., scores only, or nutrition only); a field that cannot be read on its own arrives with what it depends on — nutriments brings the serving size its per-serving figures are measured against — and requested_fields echoes the full set that was fetched

  • Open Food Facts is crowd-sourced — a missing field means "not yet entered by contributors," not that the attribute is absent from the actual product

  • A barcode no contributor has recorded raises the not_found error carrying a recovery hint — it is never returned as an empty result


off_search_products tool

  • Full-text query plus structured tag filters — categories_tag, brands_tag, labels_tag (one label, or an array that must all apply), allergens_tag, traces_tag (the "may contain" warning), ingredients_analysis_tag (one of the 12 vegan, vegetarian, and palm-oil verdicts), additives_tag, nutrition_grade (a–e), nova_group (1–4), countries_tag — and numeric nutrient_filters, all combining as AND; tag values are canonical IDs, resolved via off_browse_taxonomy (brands_tag matches an exact slug, not free text)

  • exclude_allergens and exclude_traces drop products that declare an allergen or warn of a trace, on both paths. Each value must be an allergen tag the Open Food Facts vocabulary confirms — an unrecognized one would exclude nothing, so it is rejected before any search is sent. A product with no allergen or trace data entered passes an exclusion, and every response carrying one says so and points to off_get_product for a per-product check

  • Every word of query must match a product's name, generic name, categories, labels, or brand (ingredients and quantity are not searched), in any of the 31 languages the text index analyzes, so a product named only in French or Russian is found by that name; stop words of English, French, Spanish, German, and Italian ("with", "the", "de", "mit") are not required, and query takes at most 24 words

  • On a search carrying query or nutrient_filters, each tag value is resolved to its canonical form before it is sent — a brand name is slugged (Nutella → nutella), and a case variant, synonym, or singular resolves where the Open Food Facts vocabulary confirms it (US → en:united-states, en:peanut → en:peanuts); anything unconfirmed is matched exactly, and an empty result names which values were

  • Numeric nutrient_filters express per-100 g thresholds over energy-kcal, fat, saturated-fat, carbohydrates, sugars, fiber, proteins, salt, and sodium — each a { nutrient, operator, value } triple with lt / lte / gt / gte; pair two entries on one nutrient for a range. They AND with every other filter and are served by the text backend, so supplying one routes the search there even without query

  • additives_tag filters only on searches carrying neither query nor nutrient_filters — both route to a backend with no additives field, so the pairing is rejected up front rather than silently returning zero hits

  • Pagination via page (1-based) and page_size (1–50, default 20); tag-only searches are served through page 10, and text searches serve only the first 10,000 results (page * page_size beyond that); a request past either bound is rejected before it is sent, and last_page reports the deepest page that can be requested

  • total is exact on tag-only searches; text searches stop counting at 10,000 and set total_is_lower_bound: true with the count rendered as 10000+

  • The two paths read different indexes: a search carrying query is answered by a text index that lags the live database, and says so on both response surfaces; a tag-only search reads the live database. A recently contributed product can be missing from the first and present in the second

  • sort_by (last_modified_t, unique_scans_n, created_t, popularity_key) orders newest or highest first on both paths; omitting it leaves text searches relevance-ranked

  • A page past the end of a result set is reported as an exhausted page naming the deepest page that holds products, not as a zero-match search — the broaden-the-filters guidance appears only when nothing matched

  • Returns summary rows (barcode, name, brand, Nutri-Score, NOVA, categories) — chain to off_get_product for full label data; counts reflect contributed products, not the market. Every returned barcode is one off_get_product accepts: a match Open Food Facts stores under a code it cannot serve (00000636) is left off the page and counted in omitted

  • Own client-side budget of ~10 requests/min, kept well inside what Open Food Facts asks of clients


off_compare_products tool

  • Accepts 2–10 barcodes, compared in the order provided

  • Returns a normalized comparison table: energy (kcal/100g), fat, saturated fat, sugars, salt, protein, fiber, Nutri-Score, NOVA group, and Green-Score; missing nutrition data is preserved as null, never imputed

  • not_found lists barcodes with no contributor record (not an error — the product may simply not be entered yet)

  • failed lists barcodes whose fetch itself failed, with a per-barcode reason — kept separate from not_found, and a failed barcode never blocks the rows that did resolve


off_browse_taxonomy tool

  • Facets categories, labels, allergens, additives, countries resolve live against the Open Food Facts taxonomy (case-insensitive substring match on tag ID, display name, or a common synonym — "shellfish" resolves to en:crustaceans); upstream tags are often plural, so pass the returned id through unchanged. Among the live matches, the tag spelling the term itself ranks first (lentil → en:lentils ahead of en:lentil-soups), so a small limit does not cut it

  • nova_groups and nutrition_grades are closed vocabularies, returned complete, with bare "1"–"4" / "a"–"e" ids

  • Live lookups fall back to a small in-process sample when Open Food Facts is unreachable or the budget is spent, and say so rather than failing; omitting search returns only that sample, since Open Food Facts can't enumerate a full facet — no total_in_facet is reported for the open facets

  • limit controls results (1–100, default 20); there is no offset or page — narrow the search term instead

  • Own client-side budget of ~10 requests/min, separate from the search budget

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 required — the identifying User-Agent header (required by OFF terms) is baked into the service layer

  • Token-bucket rate limiting per endpoint class: product reads (~15/min), search (~10/min), taxonomy resolution (~10/min). The product and search defaults are the per-IP ceilings Open Food Facts publishes; lower them on a shared outbound IP. Budgets count upstream requests, so a retried request spends its own slot and a budget exhausted mid-retry surfaces as rate_limited rather than sending. A local refusal says so — it never reports itself as an Open Food Facts rate limit

  • Automatic retry (4 attempts, 500ms base) for transient failures only — 5xx other than 501, timeouts, and 429 (honoring Retry-After), with HTML error page detection for 503 during high load. The HTTP status decides: a 4xx or a 501 is sent once and never retried, and the upstream's own explanation is surfaced instead — a rendered error page served with a refusal is reported as a refusal, not as load

  • Nutriments normalized from raw hyphenated keys (energy-kcal_100g) to underscore form — the _100g and _serving variants of every nutrient on the record, with the macros as named fields and the rest in an open map that carries each nutrient's own unit (micronutrients are reported in grams, so calcium 0.071 is 71 mg)

  • Live tag resolution for off_browse_taxonomy against the Open Food Facts taxonomy, merged behind a small in-process sample that covers offline operation and is authoritative for E-number lookups, which the upstream suggester answers poorly

Agent-friendly output:

  • Per-serving nutrition always carries its denominator — serving_size as printed plus the parsed serving_quantity/serving_quantity_unit, and an explicit note when Open Food Facts has recorded none

  • Computed scores (Nutri-Score, NOVA, Green-Score) returned as-is with regional caveat notes — not interpreted or normalized to health claims

  • Graceful partial failure — off_compare_products returns resolved rows even when others fail, splitting confirmed-missing barcodes into not_found and failed fetches into failed

  • Every failure carries a declared reason and a recovery hint on both client surfaces — timeouts, upstream outages, upstream rejections, and rate limits each resolve to their own error code and their own next step

Getting started

Public Hosted Instance

A public instance is available at https://openfoodfacts.caseyjhand.com/mcp — no installation required. Point any MCP client at it via Streamable HTTP:

{
  "mcpServers": {
    "openfoodfacts-mcp-server": {
      "type": "streamable-http",
      "url": "https://openfoodfacts.caseyjhand.com/mcp"
    }
  }
}

Self-Hosted / Local

No API key is required. Add the following to your MCP client configuration file.

{
  "mcpServers": {
    "openfoodfacts-mcp-server": {
      "type": "stdio",
      "command": "bunx",
      "args": ["@cyanheads/openfoodfacts-mcp-server@latest"],
      "env": {
        "MCP_TRANSPORT_TYPE": "stdio",
        "MCP_LOG_LEVEL": "info"
      }
    }
  }
}

Or with npx (no Bun required):

{
  "mcpServers": {
    "openfoodfacts-mcp-server": {
      "type": "stdio",
      "command": "npx",
      "args": ["-y", "@cyanheads/openfoodfacts-mcp-server@latest"],
      "env": {
        "MCP_TRANSPORT_TYPE": "stdio",
        "MCP_LOG_LEVEL": "info"
      }
    }
  }
}

Or with Docker:

{
  "mcpServers": {
    "openfoodfacts-mcp-server": {
      "type": "stdio",
      "command": "docker",
      "args": [
        "run", "-i", "--rm",
        "-e", "MCP_TRANSPORT_TYPE=stdio",
        "ghcr.io/cyanheads/openfoodfacts-mcp-server:latest"
      ]
    }
  }
}

For Streamable HTTP, set the transport and start the server:

MCP_TRANSPORT_TYPE=http MCP_HTTP_PORT=3010 bun run start:http
# Server listens at http://localhost:3010/mcp

Prerequisites

  • Bun v1.3.0 or higher (or Node.js v24+).

  • No API key needed. The server sends an identifying User-Agent to comply with Open Food Facts' terms of service — this is baked in and requires no configuration.

Installation

  1. Clone the repository:

git clone https://github.com/cyanheads/openfoodfacts-mcp-server.git
  1. Navigate into the directory:

cd openfoodfacts-mcp-server
  1. Install dependencies:

bun install
  1. Configure environment:

cp .env.example .env
# edit .env if you need to override rate limits or the base URL

Configuration

All configuration is validated at startup via Zod schemas in src/config/server-config.ts.

Variable

Description

Default

OFF_BASE_URL

Open Food Facts API base URL. Override for local testing against a mock server.

https://world.openfoodfacts.org

OFF_RATE_LIMIT_PRODUCT

Product read rate limit (requests/min). Matches the 15 req/min/IP Open Food Facts documents for product reads.

15

OFF_RATE_LIMIT_SEARCH

Search rate limit (requests/min).

10

OFF_RATE_LIMIT_TAXONOMY

Taxonomy resolution rate limit (requests/min), shared by off_browse_taxonomy, tag-value resolution on text searches, and the check on allergen and trace exclusions. A spent budget falls back to the offline sample, or to the tag value as normalized, rather than failing — except that an exclusion it could not check is refused as retryable.

10

MCP_TRANSPORT_TYPE

Transport: stdio or http.

stdio

MCP_HTTP_PORT

HTTP server port.

3010

MCP_AUTH_MODE

Auth mode: none, jwt, or oauth.

none

MCP_LOG_LEVEL

Log level (debug, info, warning, error).

info

LOGS_DIR

Log file directory (Node.js only).

<project-root>/logs

OTEL_ENABLED

Enable OpenTelemetry instrumentation.

false

See .env.example for the full list of optional overrides.

Running the server

Local development

  • Build and run:

    # One-time build
    bun run rebuild
    
    # Run the built server
    bun run start:stdio
    # or
    bun run start:http
  • Run checks and tests:

    bun run devcheck   # Lint, format, typecheck, security
    bun run test       # Vitest test suite
    bun run lint:mcp   # Validate MCP definitions against spec

Docker

docker build -t openfoodfacts-mcp-server .
docker run --rm -p 3010:3010 openfoodfacts-mcp-server

The Dockerfile defaults to HTTP transport, stateless session mode, and logs to /var/log/openfoodfacts-mcp-server. OpenTelemetry peer dependencies are installed by default — build with --build-arg OTEL_ENABLED=false to omit them.

Project structure

Directory

Purpose

src/index.ts

createApp() entry point — registers tools and inits services.

src/config

Server-specific environment variable parsing and validation with Zod.

src/mcp-server/tools

Tool definitions (*.tool.ts).

src/services/openfoodfacts

Open Food Facts API client — HTTP, rate limiting, retry, field normalization.

src/services/taxonomy

Tag vocabulary service — live resolution, offline sample, merge and fallback policy for off_browse_taxonomy, and tag-value canonicalization for text searches.

tests/

Unit and integration tests mirroring src/.

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

Attribution

Open Food Facts data is released under the Open Database License (ODbL) 1.0. Downstream use must cite Open Food Facts.

Contributing

Bugs, feature requests, and documentation gaps all belong in an issue — see CONTRIBUTING.md for the forms, what makes a report actionable, and how to tell a server bug from a framework one. Vulnerabilities go through private disclosure, never a public issue.

Working on the code? Both gates must be green:

bun run devcheck
bun run test

License

Apache-2.0 — see LICENSE for details.

Related MCP Connectors

Related MCP Servers

  • A
    license
    A
    quality
    D
    maintenance
    Enables AI assistants to access the Open Food Facts database to query detailed food product information, nutritional data, and environmental scores. Supports product lookup by barcode, smart search with filtering, nutritional analysis, product comparison, and dietary recommendations to help users make informed food choices.
    5
    1
    MIT
  • A
    license
    A
    quality
    B
    maintenance
    An MCP server for the Open Food Facts API that allows users to search, read, and contribute to a global food database. It enables looking up nutrition data by barcode or name and managing product information through natural language.
    10
    74 npm
    3
    MIT